test_slicer_presets.py 40 KB

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