test_slicer_presets.py 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001
  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_printer_preset_carries_the_preset_it_was_saved_from(self):
  345. """#3250: a printer preset saved under a name of its own is in no
  346. process's compatible_printers; the SliceModal needs its parent."""
  347. sp._orca_cloud_cache.clear()
  348. svc_mock = MagicMock()
  349. svc_mock.list_profiles = AsyncMock(
  350. return_value=[
  351. {
  352. "id": "m1",
  353. "name": "Bambu Lab H2D 0.4 nozzle - Skew",
  354. "content": {"type": "printer", "inherits": " Bambu Lab H2D 0.4 nozzle "},
  355. },
  356. {"id": "m2", "name": "Orca X1C", "content": {"type": "printer", "inherits": ""}},
  357. {"id": "m3", "name": "Odd", "content": {"type": "printer", "inherits": ["x"]}},
  358. ]
  359. )
  360. svc_mock.close = AsyncMock()
  361. user = MagicMock(id=1)
  362. user.has_permission = MagicMock(return_value=True)
  363. with (
  364. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  365. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  366. ):
  367. slots, _ = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  368. assert [p.inherits for p in slots["printer"]] == ["Bambu Lab H2D 0.4 nozzle", None, None]
  369. @pytest.mark.asyncio
  370. async def test_missing_or_malformed_compatible_printers_stays_none(self):
  371. """No data must read as "unknown", never as "compatible with nothing" —
  372. the SliceModal falls back to the name matcher for those."""
  373. sp._orca_cloud_cache.clear()
  374. svc_mock = MagicMock()
  375. svc_mock.list_profiles = AsyncMock(
  376. return_value=[
  377. {"id": "f1", "name": "No list", "content": {"type": "filament"}},
  378. {"id": "f2", "name": "Empty list", "content": {"type": "filament", "compatible_printers": []}},
  379. {"id": "f3", "name": "Blanks", "content": {"type": "filament", "compatible_printers": ["", " "]}},
  380. {"id": "f4", "name": "Wrong type", "content": {"type": "filament", "compatible_printers": {"a": 1}}},
  381. ]
  382. )
  383. svc_mock.close = AsyncMock()
  384. user = MagicMock(id=1)
  385. user.has_permission = MagicMock(return_value=True)
  386. with (
  387. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  388. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  389. ):
  390. slots, _status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  391. assert [p.compatible_printers for p in slots["filament"]] == [None, None, None, None]
  392. @pytest.mark.asyncio
  393. async def test_cache_hit_skips_orca_call(self):
  394. """A second call within TTL must reuse the cached slots and NOT
  395. hit the Orca service again — same TTL as Bambu Cloud (5 min)."""
  396. sp._orca_cloud_cache.clear()
  397. svc_mock = MagicMock()
  398. svc_mock.list_profiles = AsyncMock(return_value=[])
  399. svc_mock.close = AsyncMock()
  400. user = MagicMock(id=1)
  401. user.has_permission = MagicMock(return_value=True)
  402. with (
  403. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  404. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)) as build,
  405. ):
  406. await sp._fetch_orca_cloud_presets(MagicMock(), user)
  407. await sp._fetch_orca_cloud_presets(MagicMock(), user)
  408. # Build is the cache miss signal — second call reused the cache.
  409. build.assert_awaited_once()
  410. class TestFetchCloudPresets:
  411. """`_fetch_cloud_presets` translates token state and cloud errors into
  412. the four ``cloud_status`` values the SliceModal banner consumes."""
  413. @pytest.mark.asyncio
  414. async def test_no_token_returns_not_authenticated(self):
  415. sp._cloud_cache.clear()
  416. with patch.object(sp, "get_stored_token", AsyncMock(return_value=(None, None, None))):
  417. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  418. assert status == "not_authenticated"
  419. assert slots == {"printer": [], "process": [], "filament": []}
  420. @pytest.mark.asyncio
  421. async def test_user_without_cloud_auth_returns_not_authenticated(self):
  422. """Defence-in-depth: a user lacking CLOUD_AUTH must NOT see cloud
  423. presets even if their User row carries a stale cloud_token from a
  424. previous permission state. Token lookup is skipped entirely."""
  425. sp._cloud_cache.clear()
  426. user = MagicMock(id=1)
  427. user.has_permission = MagicMock(return_value=False)
  428. with patch.object(sp, "get_stored_token", AsyncMock(return_value=("leftover-token", None, None))) as get_tok:
  429. slots, status = await sp._fetch_cloud_presets(MagicMock(), user)
  430. assert status == "not_authenticated"
  431. assert slots["printer"] == []
  432. # Token was never read — the perm check short-circuits ahead of it.
  433. get_tok.assert_not_called()
  434. @pytest.mark.asyncio
  435. async def test_auth_error_returns_expired(self):
  436. sp._cloud_cache.clear()
  437. cloud_mock = MagicMock()
  438. cloud_mock.set_token = MagicMock()
  439. cloud_mock.get_slicer_settings = AsyncMock(side_effect=sp.BambuCloudAuthError("expired"))
  440. cloud_mock.close = AsyncMock()
  441. with (
  442. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", "e@x", None))),
  443. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  444. ):
  445. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  446. assert status == "expired"
  447. assert slots["printer"] == []
  448. cloud_mock.close.assert_awaited_once()
  449. @pytest.mark.asyncio
  450. async def test_cloud_error_returns_unreachable(self):
  451. sp._cloud_cache.clear()
  452. cloud_mock = MagicMock()
  453. cloud_mock.set_token = MagicMock()
  454. cloud_mock.get_slicer_settings = AsyncMock(side_effect=sp.BambuCloudError("net down"))
  455. cloud_mock.close = AsyncMock()
  456. with (
  457. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  458. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  459. ):
  460. _slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  461. assert status == "unreachable"
  462. @pytest.mark.asyncio
  463. async def test_happy_path_shapes_private_then_public(self):
  464. """Cloud presets split into private (user-custom) + public (Bambu's
  465. stock cloud presets). Private should sort before public so a user's
  466. own customisations sit at the top of the dropdown."""
  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": {
  473. "private": [{"setting_id": "PFUprivate1", "name": "My X1C"}],
  474. "public": [{"setting_id": "PFUpublic1", "name": "Bambu X1C Stock"}],
  475. },
  476. "print": {"private": [], "public": []},
  477. "filament": {"private": [], "public": []},
  478. }
  479. )
  480. cloud_mock.close = AsyncMock()
  481. with (
  482. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  483. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  484. ):
  485. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  486. assert status == "ok"
  487. names = [p.name for p in slots["printer"]]
  488. assert names == ["My X1C", "Bambu X1C Stock"]
  489. @pytest.mark.asyncio
  490. async def test_cache_hit_skips_cloud_call(self):
  491. """A second call within TTL must reuse the cached slots and NOT
  492. hit Bambu Cloud again."""
  493. sp._cloud_cache.clear()
  494. cloud_mock = MagicMock()
  495. cloud_mock.set_token = MagicMock()
  496. cloud_mock.get_slicer_settings = AsyncMock(
  497. return_value={
  498. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  499. "print": {"private": [], "public": []},
  500. "filament": {"private": [], "public": []},
  501. }
  502. )
  503. cloud_mock.close = AsyncMock()
  504. user = _user_with_cloud_auth(user_id=42)
  505. with (
  506. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  507. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  508. ):
  509. await sp._fetch_cloud_presets(MagicMock(), user)
  510. await sp._fetch_cloud_presets(MagicMock(), user)
  511. cloud_mock.get_slicer_settings.assert_awaited_once()
  512. @pytest.mark.asyncio
  513. async def test_cache_is_per_user(self):
  514. """User A's cached cloud presets must not surface for user B."""
  515. sp._cloud_cache.clear()
  516. def make_mock(name: str):
  517. m = MagicMock()
  518. m.set_token = MagicMock()
  519. m.get_slicer_settings = AsyncMock(
  520. return_value={
  521. "printer": {"private": [{"setting_id": f"id-{name}", "name": name}], "public": []},
  522. "print": {"private": [], "public": []},
  523. "filament": {"private": [], "public": []},
  524. }
  525. )
  526. m.close = AsyncMock()
  527. return m
  528. sequence = [make_mock("AliceX1C"), make_mock("BobX1C")]
  529. with (
  530. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  531. patch.object(sp, "BambuCloudService", side_effect=sequence),
  532. ):
  533. alice_slots, _ = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth(1))
  534. bob_slots, _ = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth(2))
  535. assert alice_slots["printer"][0].name == "AliceX1C"
  536. assert bob_slots["printer"][0].name == "BobX1C"
  537. @pytest.mark.asyncio
  538. async def test_cache_invalidates_on_token_change(self):
  539. """A token change (logout + login, admin reset, region switch) must
  540. bypass the cache for that user — pinning a real-world auth bug
  541. where user re-login + cache-stuck-on-old-cloud-account would
  542. silently serve a different account's preset list for ~5 minutes."""
  543. sp._cloud_cache.clear()
  544. def make_mock(name: str):
  545. m = MagicMock()
  546. m.set_token = MagicMock()
  547. m.get_slicer_settings = AsyncMock(
  548. return_value={
  549. "printer": {"private": [{"setting_id": f"id-{name}", "name": name}], "public": []},
  550. "print": {"private": [], "public": []},
  551. "filament": {"private": [], "public": []},
  552. }
  553. )
  554. m.close = AsyncMock()
  555. return m
  556. # Same user_id, different token between calls — the second call must
  557. # NOT serve the first call's cached slots.
  558. services = [make_mock("OldAccountX1C"), make_mock("NewAccountX1C")]
  559. token_sequence = [("tok-old", None, None), ("tok-new", None, None)]
  560. user = _user_with_cloud_auth(user_id=7)
  561. with (
  562. patch.object(sp, "get_stored_token", AsyncMock(side_effect=token_sequence)),
  563. patch.object(sp, "BambuCloudService", side_effect=services),
  564. ):
  565. first, _ = await sp._fetch_cloud_presets(MagicMock(), user)
  566. second, _ = await sp._fetch_cloud_presets(MagicMock(), user)
  567. assert first["printer"][0].name == "OldAccountX1C"
  568. assert second["printer"][0].name == "NewAccountX1C"
  569. @pytest.mark.asyncio
  570. async def test_refresh_bypasses_cloud_cache(self):
  571. """``refresh=True`` must skip an otherwise-warm cache entry and hit
  572. Bambu Cloud again — wiring for the SliceModal's Refresh button so a
  573. user who deletes a cloud preset in Bambu Studio / Handy doesn't have
  574. to wait for the 5-minute TTL to expire (#1581)."""
  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=99)
  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)
  592. # Without refresh, the second call hits cache (covered by
  593. # test_cache_hit_skips_cloud_call). With refresh=True it MUST
  594. # re-fetch.
  595. await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
  596. assert cloud_mock.get_slicer_settings.await_count == 2
  597. @pytest.mark.asyncio
  598. async def test_refresh_writes_back_to_cache(self):
  599. """A refresh call must still update the cache so a subsequent normal
  600. call doesn't re-hit the cloud immediately afterwards."""
  601. sp._cloud_cache.clear()
  602. cloud_mock = MagicMock()
  603. cloud_mock.set_token = MagicMock()
  604. cloud_mock.get_slicer_settings = AsyncMock(
  605. return_value={
  606. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  607. "print": {"private": [], "public": []},
  608. "filament": {"private": [], "public": []},
  609. }
  610. )
  611. cloud_mock.close = AsyncMock()
  612. user = _user_with_cloud_auth(user_id=101)
  613. with (
  614. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  615. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  616. ):
  617. await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
  618. await sp._fetch_cloud_presets(MagicMock(), user)
  619. # Two calls — first refresh, second a normal cache hit.
  620. assert cloud_mock.get_slicer_settings.await_count == 1
  621. class TestFetchBundledPresets:
  622. """Standard tier reaches out to the slicer-api sidecar; tolerate the
  623. sidecar being absent / unreachable so the modal still works."""
  624. @pytest.mark.asyncio
  625. async def test_no_sidecar_url_returns_empty(self):
  626. sp._bundled_cache = None
  627. with patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)):
  628. slots = await sp._fetch_bundled_presets(MagicMock())
  629. assert slots == {"printer": [], "process": [], "filament": []}
  630. # No URL means no useful cache result either — second call should
  631. # try again (so users who configure a URL mid-session see results).
  632. assert sp._bundled_cache is None
  633. @pytest.mark.asyncio
  634. async def test_sidecar_error_returns_empty(self):
  635. sp._bundled_cache = None
  636. svc_mock = MagicMock()
  637. svc_mock.list_bundled_profiles = AsyncMock(side_effect=sp.SlicerApiError("boom"))
  638. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  639. svc_mock.__aexit__ = AsyncMock(return_value=False)
  640. with (
  641. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://nope")),
  642. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  643. ):
  644. slots = await sp._fetch_bundled_presets(MagicMock())
  645. assert slots == {"printer": [], "process": [], "filament": []}
  646. @pytest.mark.asyncio
  647. async def test_happy_path_shapes_response(self):
  648. sp._bundled_cache = None
  649. svc_mock = MagicMock()
  650. svc_mock.list_bundled_profiles = AsyncMock(
  651. return_value={
  652. "printer": [{"name": "Bambu X1C 0.4", "base_id": None}],
  653. "process": [{"name": "0.20mm Standard", "base_id": "fdm_process_common"}],
  654. "filament": [{"name": "Bambu PLA Basic", "base_id": "fdm_filament_pla"}],
  655. }
  656. )
  657. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  658. svc_mock.__aexit__ = AsyncMock(return_value=False)
  659. with (
  660. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  661. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  662. ):
  663. slots = await sp._fetch_bundled_presets(MagicMock())
  664. assert slots["printer"][0].name == "Bambu X1C 0.4"
  665. assert slots["printer"][0].source == "standard"
  666. # Bundled presets are addressed by name (the slicer's inheritance
  667. # walker resolves them by name), so id == name.
  668. assert slots["printer"][0].id == "Bambu X1C 0.4"
  669. @pytest.mark.asyncio
  670. async def test_cache_hit_skips_sidecar(self):
  671. """A second call within TTL must serve from the cached entry and not
  672. re-hit the sidecar HTTP."""
  673. sp._bundled_cache = (
  674. time.monotonic(),
  675. {
  676. "printer": [UnifiedPreset(id="Cached", name="Cached", source="standard")],
  677. "process": [],
  678. "filament": [],
  679. },
  680. )
  681. # If `SlicerApiService` is constructed at all we've missed the cache.
  682. with patch.object(sp, "SlicerApiService", side_effect=AssertionError("cache miss!")):
  683. slots = await sp._fetch_bundled_presets(MagicMock())
  684. assert slots["printer"][0].name == "Cached"
  685. @pytest.mark.asyncio
  686. async def test_refresh_bypasses_bundled_cache(self):
  687. """``refresh=True`` must re-hit the sidecar even when the in-process
  688. cache is warm — paired with the cloud-cache refresh, this is what
  689. powers the SliceModal's Refresh button (#1581)."""
  690. sp._bundled_cache = (
  691. time.monotonic(),
  692. {
  693. "printer": [UnifiedPreset(id="Stale", name="Stale", source="standard")],
  694. "process": [],
  695. "filament": [],
  696. },
  697. )
  698. svc_mock = MagicMock()
  699. svc_mock.list_bundled_profiles = AsyncMock(
  700. return_value={
  701. "printer": [{"name": "Fresh", "base_id": None}],
  702. "process": [],
  703. "filament": [],
  704. }
  705. )
  706. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  707. svc_mock.__aexit__ = AsyncMock(return_value=False)
  708. with (
  709. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  710. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  711. ):
  712. slots = await sp._fetch_bundled_presets(MagicMock(), refresh=True)
  713. svc_mock.list_bundled_profiles.assert_awaited_once()
  714. assert [p.name for p in slots["printer"]] == ["Fresh"]
  715. # The fresh result must also be written back to the cache so a
  716. # subsequent normal (non-refresh) call doesn't re-hit the sidecar.
  717. assert sp._bundled_cache is not None
  718. assert [p.name for p in sp._bundled_cache[1]["printer"]] == ["Fresh"]
  719. class TestResolveSlicerApiUrl:
  720. """`_resolve_slicer_api_url` must respect the user's `preferred_slicer`
  721. setting just like the slice route does. The bundled-listing fetch
  722. used to be hardcoded to OrcaSlicer's URL, which left the Standard
  723. tier permanently empty for BambuStudio installs."""
  724. @pytest.mark.asyncio
  725. async def test_bambu_studio_preference_uses_bambu_url(self):
  726. """When the user prefers Bambu Studio, the listing fetch must hit
  727. the bambu-studio-api sidecar (port 3001 by default), not orca's
  728. port 3003."""
  729. async def fake_get_setting(_db, key):
  730. return {
  731. "preferred_slicer": "bambu_studio",
  732. "bambu_studio_api_url": "http://bambu-studio-api:3000",
  733. }.get(key)
  734. with patch(
  735. "backend.app.api.routes.settings.get_setting",
  736. new=fake_get_setting,
  737. ):
  738. url = await sp._resolve_slicer_api_url(MagicMock())
  739. assert url == "http://bambu-studio-api:3000"
  740. @pytest.mark.asyncio
  741. async def test_orcaslicer_preference_uses_orca_url(self):
  742. async def fake_get_setting(_db, key):
  743. return {
  744. "preferred_slicer": "orcaslicer",
  745. "orcaslicer_api_url": "http://orca-slicer-api:3000",
  746. }.get(key)
  747. with patch(
  748. "backend.app.api.routes.settings.get_setting",
  749. new=fake_get_setting,
  750. ):
  751. url = await sp._resolve_slicer_api_url(MagicMock())
  752. assert url == "http://orca-slicer-api:3000"
  753. @pytest.mark.asyncio
  754. async def test_default_preference_is_bambu_studio(self):
  755. """Empty preferred_slicer → bambu_studio (matches the slice route's
  756. default at library.py:_run_slicer_with_fallback)."""
  757. async def fake_get_setting(_db, key):
  758. return {
  759. # preferred_slicer not set
  760. "bambu_studio_api_url": "http://bambu-default:3000",
  761. }.get(key)
  762. with patch(
  763. "backend.app.api.routes.settings.get_setting",
  764. new=fake_get_setting,
  765. ):
  766. url = await sp._resolve_slicer_api_url(MagicMock())
  767. assert url == "http://bambu-default:3000"
  768. @pytest.mark.asyncio
  769. async def test_unknown_preference_returns_none(self):
  770. """An unrecognised preferred_slicer value (e.g. set out-of-band by
  771. a stale migration) returns None so the modal degrades to "no
  772. Standard tier" rather than crashing — the slice route raises 400
  773. in this case but the listing is informational, so be lenient."""
  774. async def fake_get_setting(_db, key):
  775. return {"preferred_slicer": "prusaslicer"}.get(key)
  776. with patch(
  777. "backend.app.api.routes.settings.get_setting",
  778. new=fake_get_setting,
  779. ):
  780. url = await sp._resolve_slicer_api_url(MagicMock())
  781. assert url is None
  782. class TestParseCompatiblePrinters:
  783. """``compatible_printers`` exposed for local process / filament presets so
  784. the SliceModal can filter the dropdowns by the selected printer (#1325)."""
  785. def test_parses_json_array(self):
  786. raw = '["Bambu Lab X1 Carbon 0.4 nozzle", "Bambu Lab X1 0.4 nozzle"]'
  787. assert sp._parse_compatible_printers(raw) == [
  788. "Bambu Lab X1 Carbon 0.4 nozzle",
  789. "Bambu Lab X1 0.4 nozzle",
  790. ]
  791. def test_none_and_empty_return_none(self):
  792. assert sp._parse_compatible_printers(None) is None
  793. assert sp._parse_compatible_printers("") is None
  794. assert sp._parse_compatible_printers("[]") is None
  795. def test_malformed_json_returns_none(self):
  796. assert sp._parse_compatible_printers("not json") is None
  797. # A JSON value that isn't an array is treated as absent, not an error.
  798. assert sp._parse_compatible_printers('"a string"') is None
  799. def test_drops_non_string_and_blank_entries(self):
  800. assert sp._parse_compatible_printers('["X1C", 5, "", " ", "A1"]') == [
  801. "X1C",
  802. "A1",
  803. ]
  804. class TestListPrinterModels:
  805. """``GET /slicer/printer-models`` exposes ``PRINTER_MODEL_MAP`` so the
  806. frontend doesn't duplicate the Bambu model registry (#1325 follow-up)."""
  807. def test_returns_canonical_printer_model_map(self):
  808. from backend.app.utils.printer_models import PRINTER_MODEL_MAP
  809. result = sp.list_printer_models()
  810. # Same shape - mapping from "Bambu Lab <model>" to short code.
  811. assert result == PRINTER_MODEL_MAP
  812. # Spot-check a few entries: the SliceModal name-fallback (#1325)
  813. # specifically depends on these resolving.
  814. assert result["Bambu Lab X1 Carbon"] == "X1C"
  815. assert result["Bambu Lab P2S"] == "P2S"
  816. assert result["Bambu Lab A1 mini"] == "A1 Mini"
  817. assert result["Bambu Lab H2D Pro"] == "H2D Pro"
  818. def test_returns_a_copy_not_the_module_dict(self):
  819. # A response handler must never hand out the live module-level dict —
  820. # accidental mutation by middleware / serialisers would silently
  821. # corrupt the registry for every subsequent request.
  822. from backend.app.utils.printer_models import PRINTER_MODEL_MAP
  823. result = sp.list_printer_models()
  824. assert result is not PRINTER_MODEL_MAP
  825. class TestNoUserWithAuthOn:
  826. """The sign-in stored without a user is the auth-off install's, and may be
  827. left over after auth was turned on. A caller with no user while auth is
  828. on (an API key without Allow Cloud Access) must not list its presets."""
  829. @pytest.mark.asyncio
  830. async def test_bambu_cloud(self):
  831. sp._cloud_cache.clear()
  832. with (
  833. patch.object(sp, "is_auth_enabled", AsyncMock(return_value=True)),
  834. patch.object(sp, "get_stored_token", AsyncMock(return_value=("global-tok", None, None))) as get_tok,
  835. ):
  836. slots, status = await sp._fetch_cloud_presets(MagicMock(), None)
  837. assert status == "not_authenticated"
  838. assert slots == {"printer": [], "process": [], "filament": []}
  839. get_tok.assert_not_called()
  840. @pytest.mark.asyncio
  841. async def test_orca_cloud(self):
  842. sp._orca_cloud_cache.clear()
  843. with (
  844. patch.object(sp, "is_auth_enabled", AsyncMock(return_value=True)),
  845. patch.object(sp, "_load_orca_credentials", AsyncMock()) as load,
  846. ):
  847. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), None)
  848. assert status == "not_authenticated"
  849. assert slots == {"printer": [], "process": [], "filament": []}
  850. load.assert_not_called()
  851. class TestLocalPrinterInherits:
  852. """#3250: local printer presets report the preset they were saved from."""
  853. @pytest.mark.asyncio
  854. async def test_local_printer_preset_carries_inherits(self, db_session):
  855. from backend.app.models.local_preset import LocalPreset
  856. db_session.add_all(
  857. [
  858. LocalPreset(
  859. name="Bambu Lab H2D 0.4 nozzle - Skew",
  860. preset_type="printer",
  861. source="orcaslicer",
  862. setting="{}",
  863. inherits="Bambu Lab H2D 0.4 nozzle",
  864. ),
  865. LocalPreset(name="Plain", preset_type="printer", source="orcaslicer", setting="{}", inherits=" "),
  866. LocalPreset(
  867. name="0.20mm Mine",
  868. preset_type="process",
  869. source="orcaslicer",
  870. setting="{}",
  871. inherits="0.20mm Standard @BBL H2D",
  872. ),
  873. ]
  874. )
  875. await db_session.commit()
  876. slots = await sp._fetch_local_presets(db_session)
  877. by_name = {p.name: p for p in slots["printer"]}
  878. assert by_name["Bambu Lab H2D 0.4 nozzle - Skew"].inherits == "Bambu Lab H2D 0.4 nozzle"
  879. assert by_name["Plain"].inherits is None
  880. # Only printer presets need it; a process is matched by its own list.
  881. assert slots["process"][0].inherits is None