test_slicer_presets.py 33 KB

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