test_slicer_presets.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915
  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. _oc, c, l_, s = sp._dedupe_by_name(_slot([]), 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. _oc, _c, l_, _s = sp._dedupe_by_name(_slot([]), 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. _oc, _c, _l, s = sp._dedupe_by_name(_slot([]), 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. _oc, c, _l, _s = sp._dedupe_by_name(_slot([]), 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. _oc, _c, l_, _s = sp._dedupe_by_name(_slot([]), 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 TestFetchOrcaCloudPresets:
  92. """``_fetch_orca_cloud_presets`` mirrors the Bambu Cloud fetcher's status
  93. vocabulary (``ok`` / ``not_authenticated`` / ``expired`` / ``unreachable``)
  94. and the same permission-shortcut + caching behaviour. Tests pin the
  95. contract so a future bug in either fetcher doesn't silently desync them."""
  96. def _orca_creds(self, token: str | None = "tok") -> MagicMock:
  97. creds = MagicMock()
  98. creds.token = token
  99. return creds
  100. @pytest.mark.asyncio
  101. async def test_no_token_returns_not_authenticated(self):
  102. sp._orca_cloud_cache.clear()
  103. with patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds(None))):
  104. user = MagicMock(id=1)
  105. user.has_permission = MagicMock(return_value=True)
  106. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  107. assert status == "not_authenticated"
  108. assert slots == {"printer": [], "process": [], "filament": []}
  109. @pytest.mark.asyncio
  110. async def test_user_without_orca_cloud_auth_returns_not_authenticated(self):
  111. """Defence-in-depth — a user lacking ORCA_CLOUD_AUTH must not see Orca
  112. presets even if their User row carries a stale token. Credentials
  113. lookup must short-circuit ahead of the token read."""
  114. sp._orca_cloud_cache.clear()
  115. user = MagicMock(id=1)
  116. user.has_permission = MagicMock(return_value=False)
  117. with patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))) as load:
  118. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  119. assert status == "not_authenticated"
  120. assert slots["printer"] == []
  121. load.assert_not_called()
  122. @pytest.mark.asyncio
  123. async def test_auth_error_returns_expired(self):
  124. sp._orca_cloud_cache.clear()
  125. svc_mock = MagicMock()
  126. svc_mock.list_profiles = AsyncMock(side_effect=sp.OrcaCloudAuthError("expired"))
  127. svc_mock.close = AsyncMock()
  128. user = MagicMock(id=1)
  129. user.has_permission = MagicMock(return_value=True)
  130. with (
  131. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  132. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  133. ):
  134. _slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  135. assert status == "expired"
  136. svc_mock.close.assert_awaited_once()
  137. @pytest.mark.asyncio
  138. async def test_orca_error_returns_unreachable(self):
  139. sp._orca_cloud_cache.clear()
  140. svc_mock = MagicMock()
  141. svc_mock.list_profiles = AsyncMock(side_effect=sp.OrcaCloudError("net down"))
  142. svc_mock.close = AsyncMock()
  143. user = MagicMock(id=1)
  144. user.has_permission = MagicMock(return_value=True)
  145. with (
  146. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  147. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  148. ):
  149. _slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  150. assert status == "unreachable"
  151. @pytest.mark.asyncio
  152. async def test_happy_path_shapes_grouped_by_type(self):
  153. """Orca content.type values map onto Bambu Cloud's preset type vocab
  154. (``printer`` / ``print`` → ``process`` / ``filament``). Verify the
  155. full mapping by feeding one of each shape."""
  156. sp._orca_cloud_cache.clear()
  157. svc_mock = MagicMock()
  158. svc_mock.list_profiles = AsyncMock(
  159. return_value=[
  160. {"id": "m1", "name": "Orca X1C", "content": {"type": "printer"}},
  161. {"id": "p1", "name": "Orca 0.20mm", "content": {"type": "print"}},
  162. {
  163. "id": "f1",
  164. "name": "Orca PLA",
  165. "content": {
  166. "type": "filament",
  167. "filament_type": ["PLA"],
  168. "default_filament_colour": ["#000000"],
  169. },
  170. },
  171. ]
  172. )
  173. svc_mock.close = AsyncMock()
  174. user = MagicMock(id=1)
  175. user.has_permission = MagicMock(return_value=True)
  176. with (
  177. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  178. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  179. ):
  180. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  181. assert status == "ok"
  182. assert [p.name for p in slots["printer"]] == ["Orca X1C"]
  183. assert [p.name for p in slots["process"]] == ["Orca 0.20mm"]
  184. filament = slots["filament"]
  185. assert [p.name for p in filament] == ["Orca PLA"]
  186. # Inline metadata extracted from the content blob (Orca's sync_pull
  187. # returns full content, so unlike Bambu Cloud we don't need a second
  188. # per-preset fetch to enrich filament_type / filament_colour).
  189. assert filament[0].filament_type == "PLA"
  190. assert filament[0].filament_colour == "#000000"
  191. @pytest.mark.asyncio
  192. async def test_cache_hit_skips_orca_call(self):
  193. """A second call within TTL must reuse the cached slots and NOT
  194. hit the Orca service again — same TTL as Bambu Cloud (5 min)."""
  195. sp._orca_cloud_cache.clear()
  196. svc_mock = MagicMock()
  197. svc_mock.list_profiles = AsyncMock(return_value=[])
  198. svc_mock.close = AsyncMock()
  199. user = MagicMock(id=1)
  200. user.has_permission = MagicMock(return_value=True)
  201. with (
  202. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  203. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)) as build,
  204. ):
  205. await sp._fetch_orca_cloud_presets(MagicMock(), user)
  206. await sp._fetch_orca_cloud_presets(MagicMock(), user)
  207. # Build is the cache miss signal — second call reused the cache.
  208. build.assert_awaited_once()
  209. class TestFetchCloudPresets:
  210. """`_fetch_cloud_presets` translates token state and cloud errors into
  211. the four ``cloud_status`` values the SliceModal banner consumes."""
  212. @pytest.mark.asyncio
  213. async def test_no_token_returns_not_authenticated(self):
  214. sp._cloud_cache.clear()
  215. with patch.object(sp, "get_stored_token", AsyncMock(return_value=(None, None, None))):
  216. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  217. assert status == "not_authenticated"
  218. assert slots == {"printer": [], "process": [], "filament": []}
  219. @pytest.mark.asyncio
  220. async def test_user_without_cloud_auth_returns_not_authenticated(self):
  221. """Defence-in-depth: a user lacking CLOUD_AUTH must NOT see cloud
  222. presets even if their User row carries a stale cloud_token from a
  223. previous permission state. Token lookup is skipped entirely."""
  224. sp._cloud_cache.clear()
  225. user = MagicMock(id=1)
  226. user.has_permission = MagicMock(return_value=False)
  227. with patch.object(sp, "get_stored_token", AsyncMock(return_value=("leftover-token", None, None))) as get_tok:
  228. slots, status = await sp._fetch_cloud_presets(MagicMock(), user)
  229. assert status == "not_authenticated"
  230. assert slots["printer"] == []
  231. # Token was never read — the perm check short-circuits ahead of it.
  232. get_tok.assert_not_called()
  233. @pytest.mark.asyncio
  234. async def test_auth_error_returns_expired(self):
  235. sp._cloud_cache.clear()
  236. cloud_mock = MagicMock()
  237. cloud_mock.set_token = MagicMock()
  238. cloud_mock.get_slicer_settings = AsyncMock(side_effect=sp.BambuCloudAuthError("expired"))
  239. cloud_mock.close = AsyncMock()
  240. with (
  241. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", "e@x", None))),
  242. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  243. ):
  244. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  245. assert status == "expired"
  246. assert slots["printer"] == []
  247. cloud_mock.close.assert_awaited_once()
  248. @pytest.mark.asyncio
  249. async def test_cloud_error_returns_unreachable(self):
  250. sp._cloud_cache.clear()
  251. cloud_mock = MagicMock()
  252. cloud_mock.set_token = MagicMock()
  253. cloud_mock.get_slicer_settings = AsyncMock(side_effect=sp.BambuCloudError("net down"))
  254. cloud_mock.close = AsyncMock()
  255. with (
  256. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  257. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  258. ):
  259. _slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  260. assert status == "unreachable"
  261. @pytest.mark.asyncio
  262. async def test_happy_path_shapes_private_then_public(self):
  263. """Cloud presets split into private (user-custom) + public (Bambu's
  264. stock cloud presets). Private should sort before public so a user's
  265. own customisations sit at the top of the dropdown."""
  266. sp._cloud_cache.clear()
  267. cloud_mock = MagicMock()
  268. cloud_mock.set_token = MagicMock()
  269. cloud_mock.get_slicer_settings = AsyncMock(
  270. return_value={
  271. "printer": {
  272. "private": [{"setting_id": "PFUprivate1", "name": "My X1C"}],
  273. "public": [{"setting_id": "PFUpublic1", "name": "Bambu X1C Stock"}],
  274. },
  275. "print": {"private": [], "public": []},
  276. "filament": {"private": [], "public": []},
  277. }
  278. )
  279. cloud_mock.close = AsyncMock()
  280. with (
  281. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  282. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  283. ):
  284. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  285. assert status == "ok"
  286. names = [p.name for p in slots["printer"]]
  287. assert names == ["My X1C", "Bambu X1C Stock"]
  288. @pytest.mark.asyncio
  289. async def test_cache_hit_skips_cloud_call(self):
  290. """A second call within TTL must reuse the cached slots and NOT
  291. hit Bambu Cloud again."""
  292. sp._cloud_cache.clear()
  293. cloud_mock = MagicMock()
  294. cloud_mock.set_token = MagicMock()
  295. cloud_mock.get_slicer_settings = AsyncMock(
  296. return_value={
  297. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  298. "print": {"private": [], "public": []},
  299. "filament": {"private": [], "public": []},
  300. }
  301. )
  302. cloud_mock.close = AsyncMock()
  303. user = _user_with_cloud_auth(user_id=42)
  304. with (
  305. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  306. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  307. ):
  308. await sp._fetch_cloud_presets(MagicMock(), user)
  309. await sp._fetch_cloud_presets(MagicMock(), user)
  310. cloud_mock.get_slicer_settings.assert_awaited_once()
  311. @pytest.mark.asyncio
  312. async def test_cache_is_per_user(self):
  313. """User A's cached cloud presets must not surface for user B."""
  314. sp._cloud_cache.clear()
  315. def make_mock(name: str):
  316. m = MagicMock()
  317. m.set_token = MagicMock()
  318. m.get_slicer_settings = AsyncMock(
  319. return_value={
  320. "printer": {"private": [{"setting_id": f"id-{name}", "name": name}], "public": []},
  321. "print": {"private": [], "public": []},
  322. "filament": {"private": [], "public": []},
  323. }
  324. )
  325. m.close = AsyncMock()
  326. return m
  327. sequence = [make_mock("AliceX1C"), make_mock("BobX1C")]
  328. with (
  329. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  330. patch.object(sp, "BambuCloudService", side_effect=sequence),
  331. ):
  332. alice_slots, _ = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth(1))
  333. bob_slots, _ = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth(2))
  334. assert alice_slots["printer"][0].name == "AliceX1C"
  335. assert bob_slots["printer"][0].name == "BobX1C"
  336. @pytest.mark.asyncio
  337. async def test_cache_invalidates_on_token_change(self):
  338. """A token change (logout + login, admin reset, region switch) must
  339. bypass the cache for that user — pinning a real-world auth bug
  340. where user re-login + cache-stuck-on-old-cloud-account would
  341. silently serve a different account's preset list for ~5 minutes."""
  342. sp._cloud_cache.clear()
  343. def make_mock(name: str):
  344. m = MagicMock()
  345. m.set_token = MagicMock()
  346. m.get_slicer_settings = AsyncMock(
  347. return_value={
  348. "printer": {"private": [{"setting_id": f"id-{name}", "name": name}], "public": []},
  349. "print": {"private": [], "public": []},
  350. "filament": {"private": [], "public": []},
  351. }
  352. )
  353. m.close = AsyncMock()
  354. return m
  355. # Same user_id, different token between calls — the second call must
  356. # NOT serve the first call's cached slots.
  357. services = [make_mock("OldAccountX1C"), make_mock("NewAccountX1C")]
  358. token_sequence = [("tok-old", None, None), ("tok-new", None, None)]
  359. user = _user_with_cloud_auth(user_id=7)
  360. with (
  361. patch.object(sp, "get_stored_token", AsyncMock(side_effect=token_sequence)),
  362. patch.object(sp, "BambuCloudService", side_effect=services),
  363. ):
  364. first, _ = await sp._fetch_cloud_presets(MagicMock(), user)
  365. second, _ = await sp._fetch_cloud_presets(MagicMock(), user)
  366. assert first["printer"][0].name == "OldAccountX1C"
  367. assert second["printer"][0].name == "NewAccountX1C"
  368. @pytest.mark.asyncio
  369. async def test_refresh_bypasses_cloud_cache(self):
  370. """``refresh=True`` must skip an otherwise-warm cache entry and hit
  371. Bambu Cloud again — wiring for the SliceModal's Refresh button so a
  372. user who deletes a cloud preset in Bambu Studio / Handy doesn't have
  373. to wait for the 5-minute TTL to expire (#1581)."""
  374. sp._cloud_cache.clear()
  375. cloud_mock = MagicMock()
  376. cloud_mock.set_token = MagicMock()
  377. cloud_mock.get_slicer_settings = AsyncMock(
  378. return_value={
  379. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  380. "print": {"private": [], "public": []},
  381. "filament": {"private": [], "public": []},
  382. }
  383. )
  384. cloud_mock.close = AsyncMock()
  385. user = _user_with_cloud_auth(user_id=99)
  386. with (
  387. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  388. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  389. ):
  390. await sp._fetch_cloud_presets(MagicMock(), user)
  391. # Without refresh, the second call hits cache (covered by
  392. # test_cache_hit_skips_cloud_call). With refresh=True it MUST
  393. # re-fetch.
  394. await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
  395. assert cloud_mock.get_slicer_settings.await_count == 2
  396. @pytest.mark.asyncio
  397. async def test_refresh_writes_back_to_cache(self):
  398. """A refresh call must still update the cache so a subsequent normal
  399. call doesn't re-hit the cloud immediately afterwards."""
  400. sp._cloud_cache.clear()
  401. cloud_mock = MagicMock()
  402. cloud_mock.set_token = MagicMock()
  403. cloud_mock.get_slicer_settings = AsyncMock(
  404. return_value={
  405. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  406. "print": {"private": [], "public": []},
  407. "filament": {"private": [], "public": []},
  408. }
  409. )
  410. cloud_mock.close = AsyncMock()
  411. user = _user_with_cloud_auth(user_id=101)
  412. with (
  413. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  414. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  415. ):
  416. await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
  417. await sp._fetch_cloud_presets(MagicMock(), user)
  418. # Two calls — first refresh, second a normal cache hit.
  419. assert cloud_mock.get_slicer_settings.await_count == 1
  420. class TestFetchBundledPresets:
  421. """Standard tier reaches out to the slicer-api sidecar; tolerate the
  422. sidecar being absent / unreachable so the modal still works."""
  423. @pytest.mark.asyncio
  424. async def test_no_sidecar_url_returns_empty(self):
  425. sp._bundled_cache = None
  426. with patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)):
  427. slots = await sp._fetch_bundled_presets(MagicMock())
  428. assert slots == {"printer": [], "process": [], "filament": []}
  429. # No URL means no useful cache result either — second call should
  430. # try again (so users who configure a URL mid-session see results).
  431. assert sp._bundled_cache is None
  432. @pytest.mark.asyncio
  433. async def test_sidecar_error_returns_empty(self):
  434. sp._bundled_cache = None
  435. svc_mock = MagicMock()
  436. svc_mock.list_bundled_profiles = AsyncMock(side_effect=sp.SlicerApiError("boom"))
  437. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  438. svc_mock.__aexit__ = AsyncMock(return_value=False)
  439. with (
  440. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://nope")),
  441. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  442. ):
  443. slots = await sp._fetch_bundled_presets(MagicMock())
  444. assert slots == {"printer": [], "process": [], "filament": []}
  445. @pytest.mark.asyncio
  446. async def test_happy_path_shapes_response(self):
  447. sp._bundled_cache = None
  448. svc_mock = MagicMock()
  449. svc_mock.list_bundled_profiles = AsyncMock(
  450. return_value={
  451. "printer": [{"name": "Bambu X1C 0.4", "base_id": None}],
  452. "process": [{"name": "0.20mm Standard", "base_id": "fdm_process_common"}],
  453. "filament": [{"name": "Bambu PLA Basic", "base_id": "fdm_filament_pla"}],
  454. }
  455. )
  456. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  457. svc_mock.__aexit__ = AsyncMock(return_value=False)
  458. with (
  459. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  460. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  461. ):
  462. slots = await sp._fetch_bundled_presets(MagicMock())
  463. assert slots["printer"][0].name == "Bambu X1C 0.4"
  464. assert slots["printer"][0].source == "standard"
  465. # Bundled presets are addressed by name (the slicer's inheritance
  466. # walker resolves them by name), so id == name.
  467. assert slots["printer"][0].id == "Bambu X1C 0.4"
  468. @pytest.mark.asyncio
  469. async def test_cache_hit_skips_sidecar(self):
  470. """A second call within TTL must serve from the cached entry and not
  471. re-hit the sidecar HTTP."""
  472. sp._bundled_cache = (
  473. time.monotonic(),
  474. {
  475. "printer": [UnifiedPreset(id="Cached", name="Cached", source="standard")],
  476. "process": [],
  477. "filament": [],
  478. },
  479. )
  480. # If `SlicerApiService` is constructed at all we've missed the cache.
  481. with patch.object(sp, "SlicerApiService", side_effect=AssertionError("cache miss!")):
  482. slots = await sp._fetch_bundled_presets(MagicMock())
  483. assert slots["printer"][0].name == "Cached"
  484. @pytest.mark.asyncio
  485. async def test_refresh_bypasses_bundled_cache(self):
  486. """``refresh=True`` must re-hit the sidecar even when the in-process
  487. cache is warm — paired with the cloud-cache refresh, this is what
  488. powers the SliceModal's Refresh button (#1581)."""
  489. sp._bundled_cache = (
  490. time.monotonic(),
  491. {
  492. "printer": [UnifiedPreset(id="Stale", name="Stale", source="standard")],
  493. "process": [],
  494. "filament": [],
  495. },
  496. )
  497. svc_mock = MagicMock()
  498. svc_mock.list_bundled_profiles = AsyncMock(
  499. return_value={
  500. "printer": [{"name": "Fresh", "base_id": None}],
  501. "process": [],
  502. "filament": [],
  503. }
  504. )
  505. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  506. svc_mock.__aexit__ = AsyncMock(return_value=False)
  507. with (
  508. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  509. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  510. ):
  511. slots = await sp._fetch_bundled_presets(MagicMock(), refresh=True)
  512. svc_mock.list_bundled_profiles.assert_awaited_once()
  513. assert [p.name for p in slots["printer"]] == ["Fresh"]
  514. # The fresh result must also be written back to the cache so a
  515. # subsequent normal (non-refresh) call doesn't re-hit the sidecar.
  516. assert sp._bundled_cache is not None
  517. assert [p.name for p in sp._bundled_cache[1]["printer"]] == ["Fresh"]
  518. class TestResolveSlicerApiUrl:
  519. """`_resolve_slicer_api_url` must respect the user's `preferred_slicer`
  520. setting just like the slice route does. The bundled-listing fetch
  521. used to be hardcoded to OrcaSlicer's URL, which left the Standard
  522. tier permanently empty for BambuStudio installs."""
  523. @pytest.mark.asyncio
  524. async def test_bambu_studio_preference_uses_bambu_url(self):
  525. """When the user prefers Bambu Studio, the listing fetch must hit
  526. the bambu-studio-api sidecar (port 3001 by default), not orca's
  527. port 3003."""
  528. async def fake_get_setting(_db, key):
  529. return {
  530. "preferred_slicer": "bambu_studio",
  531. "bambu_studio_api_url": "http://bambu-studio-api:3000",
  532. }.get(key)
  533. with patch(
  534. "backend.app.api.routes.settings.get_setting",
  535. new=fake_get_setting,
  536. ):
  537. url = await sp._resolve_slicer_api_url(MagicMock())
  538. assert url == "http://bambu-studio-api:3000"
  539. @pytest.mark.asyncio
  540. async def test_orcaslicer_preference_uses_orca_url(self):
  541. async def fake_get_setting(_db, key):
  542. return {
  543. "preferred_slicer": "orcaslicer",
  544. "orcaslicer_api_url": "http://orca-slicer-api:3000",
  545. }.get(key)
  546. with patch(
  547. "backend.app.api.routes.settings.get_setting",
  548. new=fake_get_setting,
  549. ):
  550. url = await sp._resolve_slicer_api_url(MagicMock())
  551. assert url == "http://orca-slicer-api:3000"
  552. @pytest.mark.asyncio
  553. async def test_default_preference_is_bambu_studio(self):
  554. """Empty preferred_slicer → bambu_studio (matches the slice route's
  555. default at library.py:_run_slicer_with_fallback)."""
  556. async def fake_get_setting(_db, key):
  557. return {
  558. # preferred_slicer not set
  559. "bambu_studio_api_url": "http://bambu-default:3000",
  560. }.get(key)
  561. with patch(
  562. "backend.app.api.routes.settings.get_setting",
  563. new=fake_get_setting,
  564. ):
  565. url = await sp._resolve_slicer_api_url(MagicMock())
  566. assert url == "http://bambu-default:3000"
  567. @pytest.mark.asyncio
  568. async def test_unknown_preference_returns_none(self):
  569. """An unrecognised preferred_slicer value (e.g. set out-of-band by
  570. a stale migration) returns None so the modal degrades to "no
  571. Standard tier" rather than crashing — the slice route raises 400
  572. in this case but the listing is informational, so be lenient."""
  573. async def fake_get_setting(_db, key):
  574. return {"preferred_slicer": "prusaslicer"}.get(key)
  575. with patch(
  576. "backend.app.api.routes.settings.get_setting",
  577. new=fake_get_setting,
  578. ):
  579. url = await sp._resolve_slicer_api_url(MagicMock())
  580. assert url is None
  581. class TestBundleRoutes:
  582. """Route-level coverage for the bundle proxy endpoints. Each route
  583. resolves the sidecar URL via _resolve_slicer_api_url, then proxies the
  584. operation through SlicerApiService. We mock both pieces so we can pin
  585. the HTTP-status mapping (sidecar input error → 400, BundleNotFoundError
  586. → 404, unreachable → 503) without spinning up a sidecar.
  587. """
  588. SAMPLE_SUMMARY = sp.BundleSummary(
  589. id="abc123def456abcd",
  590. printer_preset_name="# Bambu Lab H2D 0.4 nozzle",
  591. printer=["# Bambu Lab H2D 0.4 nozzle"],
  592. process=["# 0.20mm Standard @BBL H2D"],
  593. filament=["# Bambu PLA Basic @BBL H2D"],
  594. version="02.06.00.50",
  595. )
  596. def _patched_service(self, **methods) -> MagicMock:
  597. """Build a SlicerApiService mock that supports `async with` and
  598. exposes the bundle methods via AsyncMock per the override dict."""
  599. svc = MagicMock()
  600. svc.__aenter__ = AsyncMock(return_value=svc)
  601. svc.__aexit__ = AsyncMock(return_value=False)
  602. for name, mock in methods.items():
  603. setattr(svc, name, mock)
  604. return svc
  605. @pytest.mark.asyncio
  606. async def test_import_bundle_happy_path(self):
  607. from io import BytesIO
  608. from fastapi import UploadFile
  609. svc = self._patched_service(
  610. import_bundle=AsyncMock(return_value=self.SAMPLE_SUMMARY),
  611. )
  612. with (
  613. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  614. patch.object(sp, "SlicerApiService", return_value=svc),
  615. ):
  616. file = UploadFile(filename="H2D.bbscfg", file=BytesIO(b"PK\x03\x04"))
  617. result = await sp.import_slicer_bundle(file=file, db=MagicMock(), _=None)
  618. assert result["id"] == "abc123def456abcd"
  619. assert result["printer"] == ["# Bambu Lab H2D 0.4 nozzle"]
  620. svc.import_bundle.assert_awaited_once()
  621. kwargs = svc.import_bundle.await_args.kwargs
  622. assert kwargs["filename"] == "H2D.bbscfg"
  623. @pytest.mark.asyncio
  624. async def test_import_bundle_no_sidecar_returns_503(self):
  625. from io import BytesIO
  626. from fastapi import HTTPException, UploadFile
  627. with (
  628. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)),
  629. pytest.raises(HTTPException) as exc,
  630. ):
  631. await sp.import_slicer_bundle(
  632. file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")),
  633. db=MagicMock(),
  634. _=None,
  635. )
  636. assert exc.value.status_code == 503
  637. @pytest.mark.asyncio
  638. async def test_import_bundle_empty_file_returns_400(self):
  639. from io import BytesIO
  640. from fastapi import HTTPException, UploadFile
  641. with (
  642. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  643. pytest.raises(HTTPException) as exc,
  644. ):
  645. await sp.import_slicer_bundle(
  646. file=UploadFile(filename="x.bbscfg", file=BytesIO(b"")),
  647. db=MagicMock(),
  648. _=None,
  649. )
  650. assert exc.value.status_code == 400
  651. @pytest.mark.asyncio
  652. async def test_import_bundle_sidecar_400_passes_through(self, caplog):
  653. from io import BytesIO
  654. from fastapi import HTTPException, UploadFile
  655. svc = self._patched_service(
  656. import_bundle=AsyncMock(side_effect=sp.SlicerInputError("bad zip")),
  657. )
  658. with (
  659. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  660. patch.object(sp, "SlicerApiService", return_value=svc),
  661. caplog.at_level("WARNING", logger="backend.app.api.routes.slicer_presets"),
  662. pytest.raises(HTTPException) as exc,
  663. ):
  664. await sp.import_slicer_bundle(
  665. file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")),
  666. db=MagicMock(),
  667. _=None,
  668. )
  669. assert exc.value.status_code == 400
  670. # #1312: the sidecar's reject reason MUST land in the log so it
  671. # ends up in support bundles without us having to ask reporters
  672. # to copy the FE toast.
  673. assert any("bad zip" in r.message for r in caplog.records)
  674. assert any("x.bbscfg" in r.message for r in caplog.records)
  675. @pytest.mark.asyncio
  676. async def test_import_bundle_sidecar_unreachable_returns_503(self):
  677. from io import BytesIO
  678. from fastapi import HTTPException, UploadFile
  679. svc = self._patched_service(
  680. import_bundle=AsyncMock(side_effect=sp.SlicerApiUnavailableError("offline")),
  681. )
  682. with (
  683. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  684. patch.object(sp, "SlicerApiService", return_value=svc),
  685. pytest.raises(HTTPException) as exc,
  686. ):
  687. await sp.import_slicer_bundle(
  688. file=UploadFile(filename="x.bbscfg", file=BytesIO(b"x")),
  689. db=MagicMock(),
  690. _=None,
  691. )
  692. assert exc.value.status_code == 503
  693. @pytest.mark.asyncio
  694. async def test_list_bundles_happy_path(self):
  695. svc = self._patched_service(
  696. list_bundles=AsyncMock(return_value=[self.SAMPLE_SUMMARY]),
  697. )
  698. with (
  699. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  700. patch.object(sp, "SlicerApiService", return_value=svc),
  701. ):
  702. result = await sp.list_slicer_bundles(db=MagicMock(), _=None)
  703. assert len(result) == 1
  704. assert result[0]["id"] == "abc123def456abcd"
  705. @pytest.mark.asyncio
  706. async def test_list_bundles_no_sidecar_returns_empty(self):
  707. # Differs from import: list returns [] instead of 503 so the
  708. # SliceModal still renders cleanly when no sidecar is configured
  709. # (matches bundled-tier behaviour above).
  710. with patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)):
  711. result = await sp.list_slicer_bundles(db=MagicMock(), _=None)
  712. assert result == []
  713. @pytest.mark.asyncio
  714. async def test_list_bundles_sidecar_unreachable_returns_503(self):
  715. from fastapi import HTTPException
  716. svc = self._patched_service(
  717. list_bundles=AsyncMock(side_effect=sp.SlicerApiUnavailableError("offline")),
  718. )
  719. with (
  720. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  721. patch.object(sp, "SlicerApiService", return_value=svc),
  722. pytest.raises(HTTPException) as exc,
  723. ):
  724. await sp.list_slicer_bundles(db=MagicMock(), _=None)
  725. assert exc.value.status_code == 503
  726. @pytest.mark.asyncio
  727. async def test_get_bundle_404(self):
  728. from fastapi import HTTPException
  729. svc = self._patched_service(
  730. get_bundle=AsyncMock(side_effect=sp.BundleNotFoundError("not found")),
  731. )
  732. with (
  733. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  734. patch.object(sp, "SlicerApiService", return_value=svc),
  735. pytest.raises(HTTPException) as exc,
  736. ):
  737. await sp.get_slicer_bundle("missing", db=MagicMock(), _=None)
  738. assert exc.value.status_code == 404
  739. @pytest.mark.asyncio
  740. async def test_delete_bundle_204(self):
  741. # delete returns None on success; FastAPI sends 204 because the route
  742. # declares status_code=204.
  743. svc = self._patched_service(delete_bundle=AsyncMock(return_value=None))
  744. with (
  745. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  746. patch.object(sp, "SlicerApiService", return_value=svc),
  747. ):
  748. result = await sp.delete_slicer_bundle("abc", db=MagicMock(), _=None)
  749. assert result is None
  750. svc.delete_bundle.assert_awaited_once_with("abc")
  751. @pytest.mark.asyncio
  752. async def test_delete_bundle_404(self):
  753. from fastapi import HTTPException
  754. svc = self._patched_service(
  755. delete_bundle=AsyncMock(side_effect=sp.BundleNotFoundError("not found")),
  756. )
  757. with (
  758. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  759. patch.object(sp, "SlicerApiService", return_value=svc),
  760. pytest.raises(HTTPException) as exc,
  761. ):
  762. await sp.delete_slicer_bundle("missing", db=MagicMock(), _=None)
  763. assert exc.value.status_code == 404
  764. class TestParseCompatiblePrinters:
  765. """``compatible_printers`` exposed for local process / filament presets so
  766. the SliceModal can filter the dropdowns by the selected printer (#1325)."""
  767. def test_parses_json_array(self):
  768. raw = '["Bambu Lab X1 Carbon 0.4 nozzle", "Bambu Lab X1 0.4 nozzle"]'
  769. assert sp._parse_compatible_printers(raw) == [
  770. "Bambu Lab X1 Carbon 0.4 nozzle",
  771. "Bambu Lab X1 0.4 nozzle",
  772. ]
  773. def test_none_and_empty_return_none(self):
  774. assert sp._parse_compatible_printers(None) is None
  775. assert sp._parse_compatible_printers("") is None
  776. assert sp._parse_compatible_printers("[]") is None
  777. def test_malformed_json_returns_none(self):
  778. assert sp._parse_compatible_printers("not json") is None
  779. # A JSON value that isn't an array is treated as absent, not an error.
  780. assert sp._parse_compatible_printers('"a string"') is None
  781. def test_drops_non_string_and_blank_entries(self):
  782. assert sp._parse_compatible_printers('["X1C", 5, "", " ", "A1"]') == [
  783. "X1C",
  784. "A1",
  785. ]
  786. class TestListPrinterModels:
  787. """``GET /slicer/printer-models`` exposes ``PRINTER_MODEL_MAP`` so the
  788. frontend doesn't duplicate the Bambu model registry (#1325 follow-up)."""
  789. def test_returns_canonical_printer_model_map(self):
  790. from backend.app.utils.printer_models import PRINTER_MODEL_MAP
  791. result = sp.list_printer_models()
  792. # Same shape - mapping from "Bambu Lab <model>" to short code.
  793. assert result == PRINTER_MODEL_MAP
  794. # Spot-check a few entries: the SliceModal name-fallback (#1325)
  795. # specifically depends on these resolving.
  796. assert result["Bambu Lab X1 Carbon"] == "X1C"
  797. assert result["Bambu Lab P2S"] == "P2S"
  798. assert result["Bambu Lab A1 mini"] == "A1 Mini"
  799. assert result["Bambu Lab H2D Pro"] == "H2D Pro"
  800. def test_returns_a_copy_not_the_module_dict(self):
  801. # A response handler must never hand out the live module-level dict —
  802. # accidental mutation by middleware / serialisers would silently
  803. # corrupt the registry for every subsequent request.
  804. from backend.app.utils.printer_models import PRINTER_MODEL_MAP
  805. result = sp.list_printer_models()
  806. assert result is not PRINTER_MODEL_MAP