test_slicer_presets.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788
  1. """Tests for the unified slicer-presets endpoint helpers.
  2. The endpoint stitches together three preset sources (cloud / local /
  3. standard) with name-based dedup. These tests pin the dedup logic, the
  4. cloud-status mapping, and the per-user / sidecar caches at the
  5. helper level — full HTTP integration is covered by the routes test.
  6. """
  7. from __future__ import annotations
  8. import time
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from backend.app.api.routes import slicer_presets as sp
  12. from backend.app.schemas.slicer_presets import UnifiedPreset
  13. def _slot(items: list[tuple[str, str, str]]) -> dict[str, list[UnifiedPreset]]:
  14. """Helper: build a single-slot dict from (id, name, source) tuples placed
  15. on the printer slot. Process / filament default to empty so each test
  16. only exercises the slot it cares about."""
  17. return {
  18. "printer": [UnifiedPreset(id=i, name=n, source=s) for i, n, s in items],
  19. "process": [],
  20. "filament": [],
  21. }
  22. class TestDedupeByName:
  23. """Cloud > local > standard, by ``name``, order preserved within tier."""
  24. def test_cloud_wins_over_local_and_standard(self):
  25. cloud = _slot([("cid1", "Bambu PLA Basic", "cloud")])
  26. local = _slot([("lid1", "Bambu PLA Basic", "local")])
  27. standard = _slot([("Bambu PLA Basic", "Bambu PLA Basic", "standard")])
  28. c, l_, s = sp._dedupe_by_name(cloud, local, standard)
  29. assert [p.source for p in c["printer"]] == ["cloud"]
  30. assert l_["printer"] == []
  31. assert s["printer"] == []
  32. def test_local_filtered_only_when_present_in_cloud(self):
  33. cloud = _slot([("cid1", "Custom PLA", "cloud")])
  34. local = _slot(
  35. [
  36. ("lid1", "Custom PLA", "local"), # filtered (in cloud)
  37. ("lid2", "My Workhorse PLA", "local"), # kept
  38. ]
  39. )
  40. standard = _slot([])
  41. _c, l_, _s = sp._dedupe_by_name(cloud, local, standard)
  42. assert [p.name for p in l_["printer"]] == ["My Workhorse PLA"]
  43. def test_standard_filtered_against_both_higher_tiers(self):
  44. cloud = _slot([("c1", "A", "cloud")])
  45. local = _slot([("l1", "B", "local")])
  46. standard = _slot(
  47. [
  48. ("A", "A", "standard"), # filtered (in cloud)
  49. ("B", "B", "standard"), # filtered (in local)
  50. ("C", "C", "standard"), # kept
  51. ]
  52. )
  53. _c, _l, s = sp._dedupe_by_name(cloud, local, standard)
  54. assert [p.name for p in s["printer"]] == ["C"]
  55. def test_preserves_order_within_tier(self):
  56. """A tier's input order must be preserved in its output — nothing in
  57. the dedupe pass should sort, reverse, or otherwise reorder entries."""
  58. cloud = _slot(
  59. [
  60. ("c1", "Z-First", "cloud"),
  61. ("c2", "A-Second", "cloud"),
  62. ("c3", "M-Third", "cloud"),
  63. ]
  64. )
  65. c, _l, _s = sp._dedupe_by_name(cloud, _slot([]), _slot([]))
  66. assert [p.name for p in c["printer"]] == ["Z-First", "A-Second", "M-Third"]
  67. def test_dedupe_is_per_slot(self):
  68. """A name colliding across DIFFERENT slots must NOT cross-filter —
  69. a "Custom" filament shouldn't hide a "Custom" printer."""
  70. cloud = {
  71. "printer": [],
  72. "process": [],
  73. "filament": [UnifiedPreset(id="cf1", name="Custom", source="cloud")],
  74. }
  75. local = {
  76. "printer": [UnifiedPreset(id="lp1", name="Custom", source="local")],
  77. "process": [],
  78. "filament": [],
  79. }
  80. _c, l_, _s = sp._dedupe_by_name(cloud, local, _slot([]))
  81. # The filament-tier collision must NOT remove the printer-tier "Custom".
  82. assert [p.name for p in l_["printer"]] == ["Custom"]
  83. def _user_with_cloud_auth(user_id: int = 1) -> MagicMock:
  84. """Construct a mock User that passes the CLOUD_AUTH permission check.
  85. `MagicMock` defaults `.has_permission(...)` to a truthy MagicMock object,
  86. which would coincidentally pass the gate — but explicit is better than
  87. accidental. Setting `.return_value = True` documents the intent."""
  88. user = MagicMock(id=user_id)
  89. user.has_permission = MagicMock(return_value=True)
  90. return user
  91. class TestFetchCloudPresets:
  92. """`_fetch_cloud_presets` translates token state and cloud errors into
  93. the four ``cloud_status`` values the SliceModal banner consumes."""
  94. @pytest.mark.asyncio
  95. async def test_no_token_returns_not_authenticated(self):
  96. sp._cloud_cache.clear()
  97. with patch.object(sp, "get_stored_token", AsyncMock(return_value=(None, None, None))):
  98. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  99. assert status == "not_authenticated"
  100. assert slots == {"printer": [], "process": [], "filament": []}
  101. @pytest.mark.asyncio
  102. async def test_user_without_cloud_auth_returns_not_authenticated(self):
  103. """Defence-in-depth: a user lacking CLOUD_AUTH must NOT see cloud
  104. presets even if their User row carries a stale cloud_token from a
  105. previous permission state. Token lookup is skipped entirely."""
  106. sp._cloud_cache.clear()
  107. user = MagicMock(id=1)
  108. user.has_permission = MagicMock(return_value=False)
  109. with patch.object(sp, "get_stored_token", AsyncMock(return_value=("leftover-token", None, None))) as get_tok:
  110. slots, status = await sp._fetch_cloud_presets(MagicMock(), user)
  111. assert status == "not_authenticated"
  112. assert slots["printer"] == []
  113. # Token was never read — the perm check short-circuits ahead of it.
  114. get_tok.assert_not_called()
  115. @pytest.mark.asyncio
  116. async def test_auth_error_returns_expired(self):
  117. sp._cloud_cache.clear()
  118. cloud_mock = MagicMock()
  119. cloud_mock.set_token = MagicMock()
  120. cloud_mock.get_slicer_settings = AsyncMock(side_effect=sp.BambuCloudAuthError("expired"))
  121. cloud_mock.close = AsyncMock()
  122. with (
  123. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", "e@x", None))),
  124. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  125. ):
  126. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  127. assert status == "expired"
  128. assert slots["printer"] == []
  129. cloud_mock.close.assert_awaited_once()
  130. @pytest.mark.asyncio
  131. async def test_cloud_error_returns_unreachable(self):
  132. sp._cloud_cache.clear()
  133. cloud_mock = MagicMock()
  134. cloud_mock.set_token = MagicMock()
  135. cloud_mock.get_slicer_settings = AsyncMock(side_effect=sp.BambuCloudError("net down"))
  136. cloud_mock.close = AsyncMock()
  137. with (
  138. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  139. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  140. ):
  141. _slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  142. assert status == "unreachable"
  143. @pytest.mark.asyncio
  144. async def test_happy_path_shapes_private_then_public(self):
  145. """Cloud presets split into private (user-custom) + public (Bambu's
  146. stock cloud presets). Private should sort before public so a user's
  147. own customisations sit at the top of the dropdown."""
  148. sp._cloud_cache.clear()
  149. cloud_mock = MagicMock()
  150. cloud_mock.set_token = MagicMock()
  151. cloud_mock.get_slicer_settings = AsyncMock(
  152. return_value={
  153. "printer": {
  154. "private": [{"setting_id": "PFUprivate1", "name": "My X1C"}],
  155. "public": [{"setting_id": "PFUpublic1", "name": "Bambu X1C Stock"}],
  156. },
  157. "print": {"private": [], "public": []},
  158. "filament": {"private": [], "public": []},
  159. }
  160. )
  161. cloud_mock.close = AsyncMock()
  162. with (
  163. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  164. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  165. ):
  166. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  167. assert status == "ok"
  168. names = [p.name for p in slots["printer"]]
  169. assert names == ["My X1C", "Bambu X1C Stock"]
  170. @pytest.mark.asyncio
  171. async def test_cache_hit_skips_cloud_call(self):
  172. """A second call within TTL must reuse the cached slots and NOT
  173. hit Bambu Cloud again."""
  174. sp._cloud_cache.clear()
  175. cloud_mock = MagicMock()
  176. cloud_mock.set_token = MagicMock()
  177. cloud_mock.get_slicer_settings = AsyncMock(
  178. return_value={
  179. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  180. "print": {"private": [], "public": []},
  181. "filament": {"private": [], "public": []},
  182. }
  183. )
  184. cloud_mock.close = AsyncMock()
  185. user = _user_with_cloud_auth(user_id=42)
  186. with (
  187. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  188. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  189. ):
  190. await sp._fetch_cloud_presets(MagicMock(), user)
  191. await sp._fetch_cloud_presets(MagicMock(), user)
  192. cloud_mock.get_slicer_settings.assert_awaited_once()
  193. @pytest.mark.asyncio
  194. async def test_cache_is_per_user(self):
  195. """User A's cached cloud presets must not surface for user B."""
  196. sp._cloud_cache.clear()
  197. def make_mock(name: str):
  198. m = MagicMock()
  199. m.set_token = MagicMock()
  200. m.get_slicer_settings = AsyncMock(
  201. return_value={
  202. "printer": {"private": [{"setting_id": f"id-{name}", "name": name}], "public": []},
  203. "print": {"private": [], "public": []},
  204. "filament": {"private": [], "public": []},
  205. }
  206. )
  207. m.close = AsyncMock()
  208. return m
  209. sequence = [make_mock("AliceX1C"), make_mock("BobX1C")]
  210. with (
  211. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  212. patch.object(sp, "BambuCloudService", side_effect=sequence),
  213. ):
  214. alice_slots, _ = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth(1))
  215. bob_slots, _ = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth(2))
  216. assert alice_slots["printer"][0].name == "AliceX1C"
  217. assert bob_slots["printer"][0].name == "BobX1C"
  218. @pytest.mark.asyncio
  219. async def test_cache_invalidates_on_token_change(self):
  220. """A token change (logout + login, admin reset, region switch) must
  221. bypass the cache for that user — pinning a real-world auth bug
  222. where user re-login + cache-stuck-on-old-cloud-account would
  223. silently serve a different account's preset list for ~5 minutes."""
  224. sp._cloud_cache.clear()
  225. def make_mock(name: str):
  226. m = MagicMock()
  227. m.set_token = MagicMock()
  228. m.get_slicer_settings = AsyncMock(
  229. return_value={
  230. "printer": {"private": [{"setting_id": f"id-{name}", "name": name}], "public": []},
  231. "print": {"private": [], "public": []},
  232. "filament": {"private": [], "public": []},
  233. }
  234. )
  235. m.close = AsyncMock()
  236. return m
  237. # Same user_id, different token between calls — the second call must
  238. # NOT serve the first call's cached slots.
  239. services = [make_mock("OldAccountX1C"), make_mock("NewAccountX1C")]
  240. token_sequence = [("tok-old", None, None), ("tok-new", None, None)]
  241. user = _user_with_cloud_auth(user_id=7)
  242. with (
  243. patch.object(sp, "get_stored_token", AsyncMock(side_effect=token_sequence)),
  244. patch.object(sp, "BambuCloudService", side_effect=services),
  245. ):
  246. first, _ = await sp._fetch_cloud_presets(MagicMock(), user)
  247. second, _ = await sp._fetch_cloud_presets(MagicMock(), user)
  248. assert first["printer"][0].name == "OldAccountX1C"
  249. assert second["printer"][0].name == "NewAccountX1C"
  250. @pytest.mark.asyncio
  251. async def test_refresh_bypasses_cloud_cache(self):
  252. """``refresh=True`` must skip an otherwise-warm cache entry and hit
  253. Bambu Cloud again — wiring for the SliceModal's Refresh button so a
  254. user who deletes a cloud preset in Bambu Studio / Handy doesn't have
  255. to wait for the 5-minute TTL to expire (#1581)."""
  256. sp._cloud_cache.clear()
  257. cloud_mock = MagicMock()
  258. cloud_mock.set_token = MagicMock()
  259. cloud_mock.get_slicer_settings = AsyncMock(
  260. return_value={
  261. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  262. "print": {"private": [], "public": []},
  263. "filament": {"private": [], "public": []},
  264. }
  265. )
  266. cloud_mock.close = AsyncMock()
  267. user = _user_with_cloud_auth(user_id=99)
  268. with (
  269. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  270. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  271. ):
  272. await sp._fetch_cloud_presets(MagicMock(), user)
  273. # Without refresh, the second call hits cache (covered by
  274. # test_cache_hit_skips_cloud_call). With refresh=True it MUST
  275. # re-fetch.
  276. await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
  277. assert cloud_mock.get_slicer_settings.await_count == 2
  278. @pytest.mark.asyncio
  279. async def test_refresh_writes_back_to_cache(self):
  280. """A refresh call must still update the cache so a subsequent normal
  281. call doesn't re-hit the cloud immediately afterwards."""
  282. sp._cloud_cache.clear()
  283. cloud_mock = MagicMock()
  284. cloud_mock.set_token = MagicMock()
  285. cloud_mock.get_slicer_settings = AsyncMock(
  286. return_value={
  287. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  288. "print": {"private": [], "public": []},
  289. "filament": {"private": [], "public": []},
  290. }
  291. )
  292. cloud_mock.close = AsyncMock()
  293. user = _user_with_cloud_auth(user_id=101)
  294. with (
  295. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  296. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  297. ):
  298. await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
  299. await sp._fetch_cloud_presets(MagicMock(), user)
  300. # Two calls — first refresh, second a normal cache hit.
  301. assert cloud_mock.get_slicer_settings.await_count == 1
  302. class TestFetchBundledPresets:
  303. """Standard tier reaches out to the slicer-api sidecar; tolerate the
  304. sidecar being absent / unreachable so the modal still works."""
  305. @pytest.mark.asyncio
  306. async def test_no_sidecar_url_returns_empty(self):
  307. sp._bundled_cache = None
  308. with patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)):
  309. slots = await sp._fetch_bundled_presets(MagicMock())
  310. assert slots == {"printer": [], "process": [], "filament": []}
  311. # No URL means no useful cache result either — second call should
  312. # try again (so users who configure a URL mid-session see results).
  313. assert sp._bundled_cache is None
  314. @pytest.mark.asyncio
  315. async def test_sidecar_error_returns_empty(self):
  316. sp._bundled_cache = None
  317. svc_mock = MagicMock()
  318. svc_mock.list_bundled_profiles = AsyncMock(side_effect=sp.SlicerApiError("boom"))
  319. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  320. svc_mock.__aexit__ = AsyncMock(return_value=False)
  321. with (
  322. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://nope")),
  323. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  324. ):
  325. slots = await sp._fetch_bundled_presets(MagicMock())
  326. assert slots == {"printer": [], "process": [], "filament": []}
  327. @pytest.mark.asyncio
  328. async def test_happy_path_shapes_response(self):
  329. sp._bundled_cache = None
  330. svc_mock = MagicMock()
  331. svc_mock.list_bundled_profiles = AsyncMock(
  332. return_value={
  333. "printer": [{"name": "Bambu X1C 0.4", "base_id": None}],
  334. "process": [{"name": "0.20mm Standard", "base_id": "fdm_process_common"}],
  335. "filament": [{"name": "Bambu PLA Basic", "base_id": "fdm_filament_pla"}],
  336. }
  337. )
  338. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  339. svc_mock.__aexit__ = AsyncMock(return_value=False)
  340. with (
  341. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  342. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  343. ):
  344. slots = await sp._fetch_bundled_presets(MagicMock())
  345. assert slots["printer"][0].name == "Bambu X1C 0.4"
  346. assert slots["printer"][0].source == "standard"
  347. # Bundled presets are addressed by name (the slicer's inheritance
  348. # walker resolves them by name), so id == name.
  349. assert slots["printer"][0].id == "Bambu X1C 0.4"
  350. @pytest.mark.asyncio
  351. async def test_cache_hit_skips_sidecar(self):
  352. """A second call within TTL must serve from the cached entry and not
  353. re-hit the sidecar HTTP."""
  354. sp._bundled_cache = (
  355. time.monotonic(),
  356. {
  357. "printer": [UnifiedPreset(id="Cached", name="Cached", source="standard")],
  358. "process": [],
  359. "filament": [],
  360. },
  361. )
  362. # If `SlicerApiService` is constructed at all we've missed the cache.
  363. with patch.object(sp, "SlicerApiService", side_effect=AssertionError("cache miss!")):
  364. slots = await sp._fetch_bundled_presets(MagicMock())
  365. assert slots["printer"][0].name == "Cached"
  366. @pytest.mark.asyncio
  367. async def test_refresh_bypasses_bundled_cache(self):
  368. """``refresh=True`` must re-hit the sidecar even when the in-process
  369. cache is warm — paired with the cloud-cache refresh, this is what
  370. powers the SliceModal's Refresh button (#1581)."""
  371. sp._bundled_cache = (
  372. time.monotonic(),
  373. {
  374. "printer": [UnifiedPreset(id="Stale", name="Stale", source="standard")],
  375. "process": [],
  376. "filament": [],
  377. },
  378. )
  379. svc_mock = MagicMock()
  380. svc_mock.list_bundled_profiles = AsyncMock(
  381. return_value={
  382. "printer": [{"name": "Fresh", "base_id": None}],
  383. "process": [],
  384. "filament": [],
  385. }
  386. )
  387. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  388. svc_mock.__aexit__ = AsyncMock(return_value=False)
  389. with (
  390. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  391. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  392. ):
  393. slots = await sp._fetch_bundled_presets(MagicMock(), refresh=True)
  394. svc_mock.list_bundled_profiles.assert_awaited_once()
  395. assert [p.name for p in slots["printer"]] == ["Fresh"]
  396. # The fresh result must also be written back to the cache so a
  397. # subsequent normal (non-refresh) call doesn't re-hit the sidecar.
  398. assert sp._bundled_cache is not None
  399. assert [p.name for p in sp._bundled_cache[1]["printer"]] == ["Fresh"]
  400. class TestResolveSlicerApiUrl:
  401. """`_resolve_slicer_api_url` must respect the user's `preferred_slicer`
  402. setting just like the slice route does. The bundled-listing fetch
  403. used to be hardcoded to OrcaSlicer's URL, which left the Standard
  404. tier permanently empty for BambuStudio installs."""
  405. @pytest.mark.asyncio
  406. async def test_bambu_studio_preference_uses_bambu_url(self):
  407. """When the user prefers Bambu Studio, the listing fetch must hit
  408. the bambu-studio-api sidecar (port 3001 by default), not orca's
  409. port 3003."""
  410. async def fake_get_setting(_db, key):
  411. return {
  412. "preferred_slicer": "bambu_studio",
  413. "bambu_studio_api_url": "http://bambu-studio-api:3000",
  414. }.get(key)
  415. with patch(
  416. "backend.app.api.routes.settings.get_setting",
  417. new=fake_get_setting,
  418. ):
  419. url = await sp._resolve_slicer_api_url(MagicMock())
  420. assert url == "http://bambu-studio-api:3000"
  421. @pytest.mark.asyncio
  422. async def test_orcaslicer_preference_uses_orca_url(self):
  423. async def fake_get_setting(_db, key):
  424. return {
  425. "preferred_slicer": "orcaslicer",
  426. "orcaslicer_api_url": "http://orca-slicer-api:3000",
  427. }.get(key)
  428. with patch(
  429. "backend.app.api.routes.settings.get_setting",
  430. new=fake_get_setting,
  431. ):
  432. url = await sp._resolve_slicer_api_url(MagicMock())
  433. assert url == "http://orca-slicer-api:3000"
  434. @pytest.mark.asyncio
  435. async def test_default_preference_is_bambu_studio(self):
  436. """Empty preferred_slicer → bambu_studio (matches the slice route's
  437. default at library.py:_run_slicer_with_fallback)."""
  438. async def fake_get_setting(_db, key):
  439. return {
  440. # preferred_slicer not set
  441. "bambu_studio_api_url": "http://bambu-default:3000",
  442. }.get(key)
  443. with patch(
  444. "backend.app.api.routes.settings.get_setting",
  445. new=fake_get_setting,
  446. ):
  447. url = await sp._resolve_slicer_api_url(MagicMock())
  448. assert url == "http://bambu-default:3000"
  449. @pytest.mark.asyncio
  450. async def test_unknown_preference_returns_none(self):
  451. """An unrecognised preferred_slicer value (e.g. set out-of-band by
  452. a stale migration) returns None so the modal degrades to "no
  453. Standard tier" rather than crashing — the slice route raises 400
  454. in this case but the listing is informational, so be lenient."""
  455. async def fake_get_setting(_db, key):
  456. return {"preferred_slicer": "prusaslicer"}.get(key)
  457. with patch(
  458. "backend.app.api.routes.settings.get_setting",
  459. new=fake_get_setting,
  460. ):
  461. url = await sp._resolve_slicer_api_url(MagicMock())
  462. assert url is None
  463. class TestBundleRoutes:
  464. """Route-level coverage for the bundle proxy endpoints. Each route
  465. resolves the sidecar URL via _resolve_slicer_api_url, then proxies the
  466. operation through SlicerApiService. We mock both pieces so we can pin
  467. the HTTP-status mapping (sidecar input error → 400, BundleNotFoundError
  468. → 404, unreachable → 503) without spinning up a sidecar.
  469. """
  470. SAMPLE_SUMMARY = sp.BundleSummary(
  471. id="abc123def456abcd",
  472. printer_preset_name="# Bambu Lab H2D 0.4 nozzle",
  473. printer=["# Bambu Lab H2D 0.4 nozzle"],
  474. process=["# 0.20mm Standard @BBL H2D"],
  475. filament=["# Bambu PLA Basic @BBL H2D"],
  476. version="02.06.00.50",
  477. )
  478. def _patched_service(self, **methods) -> MagicMock:
  479. """Build a SlicerApiService mock that supports `async with` and
  480. exposes the bundle methods via AsyncMock per the override dict."""
  481. svc = MagicMock()
  482. svc.__aenter__ = AsyncMock(return_value=svc)
  483. svc.__aexit__ = AsyncMock(return_value=False)
  484. for name, mock in methods.items():
  485. setattr(svc, name, mock)
  486. return svc
  487. @pytest.mark.asyncio
  488. async def test_import_bundle_happy_path(self):
  489. from io import BytesIO
  490. from fastapi import UploadFile
  491. svc = self._patched_service(
  492. import_bundle=AsyncMock(return_value=self.SAMPLE_SUMMARY),
  493. )
  494. with (
  495. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  496. patch.object(sp, "SlicerApiService", return_value=svc),
  497. ):
  498. file = UploadFile(filename="H2D.bbscfg", file=BytesIO(b"PK\x03\x04"))
  499. result = await sp.import_slicer_bundle(file=file, db=MagicMock(), _=None)
  500. assert result["id"] == "abc123def456abcd"
  501. assert result["printer"] == ["# Bambu Lab H2D 0.4 nozzle"]
  502. svc.import_bundle.assert_awaited_once()
  503. kwargs = svc.import_bundle.await_args.kwargs
  504. assert kwargs["filename"] == "H2D.bbscfg"
  505. @pytest.mark.asyncio
  506. async def test_import_bundle_no_sidecar_returns_503(self):
  507. from io import BytesIO
  508. from fastapi import HTTPException, UploadFile
  509. with (
  510. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)),
  511. pytest.raises(HTTPException) as exc,
  512. ):
  513. await sp.import_slicer_bundle(
  514. file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")),
  515. db=MagicMock(),
  516. _=None,
  517. )
  518. assert exc.value.status_code == 503
  519. @pytest.mark.asyncio
  520. async def test_import_bundle_empty_file_returns_400(self):
  521. from io import BytesIO
  522. from fastapi import HTTPException, UploadFile
  523. with (
  524. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  525. pytest.raises(HTTPException) as exc,
  526. ):
  527. await sp.import_slicer_bundle(
  528. file=UploadFile(filename="x.bbscfg", file=BytesIO(b"")),
  529. db=MagicMock(),
  530. _=None,
  531. )
  532. assert exc.value.status_code == 400
  533. @pytest.mark.asyncio
  534. async def test_import_bundle_sidecar_400_passes_through(self, caplog):
  535. from io import BytesIO
  536. from fastapi import HTTPException, UploadFile
  537. svc = self._patched_service(
  538. import_bundle=AsyncMock(side_effect=sp.SlicerInputError("bad zip")),
  539. )
  540. with (
  541. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  542. patch.object(sp, "SlicerApiService", return_value=svc),
  543. caplog.at_level("WARNING", logger="backend.app.api.routes.slicer_presets"),
  544. pytest.raises(HTTPException) as exc,
  545. ):
  546. await sp.import_slicer_bundle(
  547. file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")),
  548. db=MagicMock(),
  549. _=None,
  550. )
  551. assert exc.value.status_code == 400
  552. # #1312: the sidecar's reject reason MUST land in the log so it
  553. # ends up in support bundles without us having to ask reporters
  554. # to copy the FE toast.
  555. assert any("bad zip" in r.message for r in caplog.records)
  556. assert any("x.bbscfg" in r.message for r in caplog.records)
  557. @pytest.mark.asyncio
  558. async def test_import_bundle_sidecar_unreachable_returns_503(self):
  559. from io import BytesIO
  560. from fastapi import HTTPException, UploadFile
  561. svc = self._patched_service(
  562. import_bundle=AsyncMock(side_effect=sp.SlicerApiUnavailableError("offline")),
  563. )
  564. with (
  565. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  566. patch.object(sp, "SlicerApiService", return_value=svc),
  567. pytest.raises(HTTPException) as exc,
  568. ):
  569. await sp.import_slicer_bundle(
  570. file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")),
  571. db=MagicMock(),
  572. _=None,
  573. )
  574. assert exc.value.status_code == 503
  575. @pytest.mark.asyncio
  576. async def test_list_bundles_happy_path(self):
  577. svc = self._patched_service(
  578. list_bundles=AsyncMock(return_value=[self.SAMPLE_SUMMARY]),
  579. )
  580. with (
  581. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  582. patch.object(sp, "SlicerApiService", return_value=svc),
  583. ):
  584. result = await sp.list_slicer_bundles(db=MagicMock(), _=None)
  585. assert len(result) == 1
  586. assert result[0]["id"] == "abc123def456abcd"
  587. @pytest.mark.asyncio
  588. async def test_list_bundles_no_sidecar_returns_empty(self):
  589. # Differs from import: list returns [] instead of 503 so the
  590. # SliceModal still renders cleanly when no sidecar is configured
  591. # (matches bundled-tier behaviour above).
  592. with patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)):
  593. result = await sp.list_slicer_bundles(db=MagicMock(), _=None)
  594. assert result == []
  595. @pytest.mark.asyncio
  596. async def test_list_bundles_sidecar_unreachable_returns_503(self):
  597. from fastapi import HTTPException
  598. svc = self._patched_service(
  599. list_bundles=AsyncMock(side_effect=sp.SlicerApiUnavailableError("offline")),
  600. )
  601. with (
  602. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  603. patch.object(sp, "SlicerApiService", return_value=svc),
  604. pytest.raises(HTTPException) as exc,
  605. ):
  606. await sp.list_slicer_bundles(db=MagicMock(), _=None)
  607. assert exc.value.status_code == 503
  608. @pytest.mark.asyncio
  609. async def test_get_bundle_404(self):
  610. from fastapi import HTTPException
  611. svc = self._patched_service(
  612. get_bundle=AsyncMock(side_effect=sp.BundleNotFoundError("not found")),
  613. )
  614. with (
  615. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  616. patch.object(sp, "SlicerApiService", return_value=svc),
  617. pytest.raises(HTTPException) as exc,
  618. ):
  619. await sp.get_slicer_bundle("missing", db=MagicMock(), _=None)
  620. assert exc.value.status_code == 404
  621. @pytest.mark.asyncio
  622. async def test_delete_bundle_204(self):
  623. # delete returns None on success; FastAPI sends 204 because the route
  624. # declares status_code=204.
  625. svc = self._patched_service(delete_bundle=AsyncMock(return_value=None))
  626. with (
  627. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  628. patch.object(sp, "SlicerApiService", return_value=svc),
  629. ):
  630. result = await sp.delete_slicer_bundle("abc", db=MagicMock(), _=None)
  631. assert result is None
  632. svc.delete_bundle.assert_awaited_once_with("abc")
  633. @pytest.mark.asyncio
  634. async def test_delete_bundle_404(self):
  635. from fastapi import HTTPException
  636. svc = self._patched_service(
  637. delete_bundle=AsyncMock(side_effect=sp.BundleNotFoundError("not found")),
  638. )
  639. with (
  640. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  641. patch.object(sp, "SlicerApiService", return_value=svc),
  642. pytest.raises(HTTPException) as exc,
  643. ):
  644. await sp.delete_slicer_bundle("missing", db=MagicMock(), _=None)
  645. assert exc.value.status_code == 404
  646. class TestParseCompatiblePrinters:
  647. """``compatible_printers`` exposed for local process / filament presets so
  648. the SliceModal can filter the dropdowns by the selected printer (#1325)."""
  649. def test_parses_json_array(self):
  650. raw = '["Bambu Lab X1 Carbon 0.4 nozzle", "Bambu Lab X1 0.4 nozzle"]'
  651. assert sp._parse_compatible_printers(raw) == [
  652. "Bambu Lab X1 Carbon 0.4 nozzle",
  653. "Bambu Lab X1 0.4 nozzle",
  654. ]
  655. def test_none_and_empty_return_none(self):
  656. assert sp._parse_compatible_printers(None) is None
  657. assert sp._parse_compatible_printers("") is None
  658. assert sp._parse_compatible_printers("[]") is None
  659. def test_malformed_json_returns_none(self):
  660. assert sp._parse_compatible_printers("not json") is None
  661. # A JSON value that isn't an array is treated as absent, not an error.
  662. assert sp._parse_compatible_printers('"a string"') is None
  663. def test_drops_non_string_and_blank_entries(self):
  664. assert sp._parse_compatible_printers('["X1C", 5, "", " ", "A1"]') == [
  665. "X1C",
  666. "A1",
  667. ]
  668. class TestListPrinterModels:
  669. """``GET /slicer/printer-models`` exposes ``PRINTER_MODEL_MAP`` so the
  670. frontend doesn't duplicate the Bambu model registry (#1325 follow-up)."""
  671. def test_returns_canonical_printer_model_map(self):
  672. from backend.app.utils.printer_models import PRINTER_MODEL_MAP
  673. result = sp.list_printer_models()
  674. # Same shape - mapping from "Bambu Lab <model>" to short code.
  675. assert result == PRINTER_MODEL_MAP
  676. # Spot-check a few entries: the SliceModal name-fallback (#1325)
  677. # specifically depends on these resolving.
  678. assert result["Bambu Lab X1 Carbon"] == "X1C"
  679. assert result["Bambu Lab P2S"] == "P2S"
  680. assert result["Bambu Lab A1 mini"] == "A1 Mini"
  681. assert result["Bambu Lab H2D Pro"] == "H2D Pro"
  682. def test_returns_a_copy_not_the_module_dict(self):
  683. # A response handler must never hand out the live module-level dict —
  684. # accidental mutation by middleware / serialisers would silently
  685. # corrupt the registry for every subsequent request.
  686. from backend.app.utils.printer_models import PRINTER_MODEL_MAP
  687. result = sp.list_printer_models()
  688. assert result is not PRINTER_MODEL_MAP