test_slicer_presets.py 57 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283
  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 — except the Standard
  5. presets that Bambu Cloud's stock list also has (#3386). Bambu Cloud filament
  6. metadata is enriched from same-named entries in the other tiers so it
  7. can still score in the SliceModal's auto-pick. These tests pin the
  8. enrich behaviour, the cloud-status mapping, and the per-user / sidecar
  9. caches at the helper level — full HTTP integration is covered by the
  10. routes test.
  11. """
  12. from __future__ import annotations
  13. import time
  14. from unittest.mock import AsyncMock, MagicMock, patch
  15. import pytest
  16. from backend.app.api.routes import slicer_presets as sp
  17. from backend.app.schemas.slicer import PresetRef
  18. from backend.app.schemas.slicer_presets import UnifiedPreset
  19. def _slot(items: list[tuple[str, str, str]]) -> dict[str, list[UnifiedPreset]]:
  20. """Helper: build a single-slot dict from (id, name, source) tuples placed
  21. on the printer slot. Process / filament default to empty so each test
  22. only exercises the slot it cares about."""
  23. return {
  24. "printer": [UnifiedPreset(id=i, name=n, source=s) for i, n, s in items],
  25. "process": [],
  26. "filament": [],
  27. }
  28. class TestEnrichCloudMetadata:
  29. """No cross-tier dedup — every tier's full list comes back; Bambu Cloud
  30. filament metadata is enriched from same-named entries in other tiers."""
  31. def test_same_name_in_all_tiers_appears_in_every_tier(self):
  32. """Critical regression guard for #1712: a user who has imported a
  33. local profile AND signed in to Orca AND has Bambu Cloud with the
  34. same name should see it under EACH source, not just the highest-
  35. priority tier. The order is used for auto-pick + group rendering;
  36. it is NOT used to hide profiles."""
  37. orca = _slot([("oid1", "Bambu PLA Basic", "orca_cloud")])
  38. cloud = _slot([("cid1", "Bambu PLA Basic", "cloud")])
  39. local = _slot([("lid1", "Bambu PLA Basic", "local")])
  40. standard = _slot([("Bambu PLA Basic", "Bambu PLA Basic", "standard")])
  41. oc, c, l_, s = sp._enrich_cloud_metadata(orca, cloud, local, standard)
  42. assert [p.source for p in l_["printer"]] == ["local"]
  43. assert [p.source for p in oc["printer"]] == ["orca_cloud"]
  44. assert [p.source for p in c["printer"]] == ["cloud"]
  45. assert [p.source for p in s["printer"]] == ["standard"]
  46. def test_preserves_order_within_tier(self):
  47. """A tier's input order must be preserved — nothing in the enrich
  48. pass should sort, reverse, or otherwise reorder entries."""
  49. cloud = _slot(
  50. [
  51. ("c1", "Z-First", "cloud"),
  52. ("c2", "A-Second", "cloud"),
  53. ("c3", "M-Third", "cloud"),
  54. ]
  55. )
  56. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, _slot([]), _slot([]))
  57. assert [p.name for p in c["printer"]] == ["Z-First", "A-Second", "M-Third"]
  58. def test_bambu_cloud_filament_metadata_backfilled_from_local(self):
  59. """Bambu Cloud's list response omits filament_type/colour for
  60. rate-limit reasons. A same-named local entry's metadata fills in
  61. so the cloud entry can still score in pickFilamentForSlot."""
  62. local = {
  63. "printer": [],
  64. "process": [],
  65. "filament": [
  66. UnifiedPreset(
  67. id="lp1",
  68. name="Bambu PLA Basic",
  69. source="local",
  70. filament_type="PLA",
  71. filament_colour="#FF0000",
  72. )
  73. ],
  74. }
  75. cloud = {
  76. "printer": [],
  77. "process": [],
  78. "filament": [UnifiedPreset(id="cp1", name="Bambu PLA Basic", source="cloud")],
  79. }
  80. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
  81. # Cloud entry now carries the local entry's metadata.
  82. assert c["filament"][0].filament_type == "PLA"
  83. assert c["filament"][0].filament_colour == "#FF0000"
  84. # Local entry is untouched.
  85. assert local["filament"][0].filament_type == "PLA"
  86. def test_bambu_cloud_metadata_falls_back_through_orca_and_standard(self):
  87. """When local doesn't carry the name, orca_cloud / standard fill in."""
  88. orca = {
  89. "printer": [],
  90. "process": [],
  91. "filament": [
  92. UnifiedPreset(
  93. id="o1",
  94. name="Bambu PLA Basic",
  95. source="orca_cloud",
  96. filament_type="PLA",
  97. filament_colour="#00FF00",
  98. )
  99. ],
  100. }
  101. cloud = {
  102. "printer": [],
  103. "process": [],
  104. "filament": [UnifiedPreset(id="cp1", name="Bambu PLA Basic", source="cloud")],
  105. }
  106. _oc, c, _l, _s = sp._enrich_cloud_metadata(orca, cloud, _slot([]), _slot([]))
  107. assert c["filament"][0].filament_type == "PLA"
  108. assert c["filament"][0].filament_colour == "#00FF00"
  109. def test_bambu_cloud_keeps_its_own_metadata_when_present(self):
  110. """If Bambu Cloud already has filament_type / filament_colour the
  111. enrich pass must not overwrite them with a different same-named
  112. entry's values."""
  113. local = {
  114. "printer": [],
  115. "process": [],
  116. "filament": [
  117. UnifiedPreset(
  118. id="lp1",
  119. name="Bambu PLA Basic",
  120. source="local",
  121. filament_type="PETG",
  122. filament_colour="#000000",
  123. )
  124. ],
  125. }
  126. cloud = {
  127. "printer": [],
  128. "process": [],
  129. "filament": [
  130. UnifiedPreset(
  131. id="cp1",
  132. name="Bambu PLA Basic",
  133. source="cloud",
  134. filament_type="PLA",
  135. filament_colour="#FFFFFF",
  136. )
  137. ],
  138. }
  139. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
  140. assert c["filament"][0].filament_type == "PLA"
  141. assert c["filament"][0].filament_colour == "#FFFFFF"
  142. def _filaments(source: str, *items: tuple[str, str | None]) -> dict[str, list[UnifiedPreset]]:
  143. """A tier with only filament presets, from (name, filament_type) pairs."""
  144. return {
  145. "printer": [],
  146. "process": [],
  147. "filament": [UnifiedPreset(id=n, name=n, source=source, filament_type=t) for n, t in items],
  148. }
  149. class TestEnrichMaterialWithoutTwin:
  150. """#3403: with OrcaSlicer preferred, the bundle has no H2C profiles, so a
  151. Bambu Cloud "@BBL H2C" filament has no same-named twin and used to stay
  152. untyped -- a PETG-CF slot then auto-picked Generic PETG."""
  153. def _bundled(self) -> dict[str, list[UnifiedPreset]]:
  154. # The OrcaSlicer 2.4.2 bundle as it lists these: every printer's
  155. # profile but the H2C, and Orca's own "@System" generics.
  156. return _filaments(
  157. "standard",
  158. ("Generic PETG-CF @BBL X1C", "PETG-CF"),
  159. ("Generic PETG-CF @BBL H2D", "PETG-CF"),
  160. ("Generic PETG-CF @System", "PETG"),
  161. ("Generic PETG @BBL X1C", "PETG"),
  162. ("Generic PE-CF @BBL X1C", "PE-CF"),
  163. ("Generic PE-CF @System", "PE"),
  164. )
  165. def test_bambu_cloud_stock_profile_takes_the_material_of_its_other_printers(self):
  166. cloud = _filaments(
  167. "cloud",
  168. ("Generic PETG @BBL H2C", None),
  169. ("Generic PETG-CF @BBL H2C", None),
  170. ("Generic PETG-CF @BBL H2C 0.4 nozzle", None),
  171. ("Generic PE-CF @BBL H2C", None),
  172. )
  173. _oc, c, _l, _s = sp._enrich_cloud_metadata(
  174. _filaments("orca_cloud"), cloud, _filaments("local"), self._bundled()
  175. )
  176. assert [p.filament_type for p in c["filament"]] == ["PETG", "PETG-CF", "PETG-CF", "PE-CF"]
  177. # Material only: the colour of another printer's profile isn't this one's.
  178. assert all(p.filament_colour is None for p in c["filament"])
  179. def test_disagreeing_bundled_materials_leave_it_unknown(self):
  180. bundled = _filaments("standard", ("Odd PLA @BBL X1C", "PLA"), ("Odd PLA @BBL A1", "PLA-CF"))
  181. cloud = _filaments("cloud", ("Odd PLA @BBL H2C", None))
  182. _oc, c, _l, _s = sp._enrich_cloud_metadata(_filaments("orca_cloud"), cloud, _filaments("local"), bundled)
  183. assert c["filament"][0].filament_type is None
  184. def test_only_bundled_bbl_names_count(self):
  185. """A name without "@BBL", or a user's own "@BBL"-named profile, is no
  186. source of another profile's material."""
  187. local = _filaments("local", ("Generic PLA @BBL X1C", "PETG"))
  188. cloud = _filaments("cloud", ("Generic PETG-CF", None), ("Generic PLA @BBL H2C", None))
  189. _oc, c, _l, _s = sp._enrich_cloud_metadata(_filaments("orca_cloud"), cloud, local, self._bundled())
  190. assert [p.filament_type for p in c["filament"]] == [None, None]
  191. def test_a_users_copy_of_a_stock_profile_is_left_unknown(self):
  192. """Its material may be the one the user changed it to."""
  193. cloud = _filaments("cloud", ("Generic PETG @BBL H2C - Copy", None), ("Generic PETG @BBL H2C 0.4 nozzle", None))
  194. _oc, c, _l, _s = sp._enrich_cloud_metadata(
  195. _filaments("orca_cloud"), cloud, _filaments("local"), self._bundled()
  196. )
  197. assert [p.filament_type for p in c["filament"]] == [None, "PETG"]
  198. def test_a_stated_material_is_kept(self):
  199. cloud = _filaments("cloud", ("Generic PETG-CF @BBL H2C", "PETG"))
  200. _oc, c, _l, _s = sp._enrich_cloud_metadata(
  201. _filaments("orca_cloud"), cloud, _filaments("local"), self._bundled()
  202. )
  203. assert c["filament"][0].filament_type == "PETG"
  204. def test_orca_cloud_profile_takes_the_material_of_its_parent(self):
  205. orca = _filaments(
  206. "orca_cloud",
  207. ("My PETG-CF", None),
  208. ("My PETG-CF fast", None),
  209. ("My H2C PETG-CF", None),
  210. ("Loop A", None),
  211. ("Loop B", None),
  212. ("Typed", "PLA"),
  213. ("From System", None),
  214. )
  215. parents = {
  216. "My PETG-CF": "Generic PETG-CF @BBL X1C", # bundled, same name
  217. "My PETG-CF fast": "My PETG-CF", # through another user profile
  218. "My H2C PETG-CF": "Generic PETG-CF @BBL H2C", # not bundled: by filament
  219. "Loop A": "Loop B",
  220. "Loop B": "Loop A",
  221. "Typed": "Generic PETG @BBL X1C",
  222. # Orca's own generic states the coarser PETG: no source.
  223. "From System": "Generic PETG-CF @System",
  224. }
  225. for p in orca["filament"]:
  226. p.inherits = parents[p.name]
  227. oc, _c, _l, _s = sp._enrich_cloud_metadata(orca, _filaments("cloud"), _filaments("local"), self._bundled())
  228. assert [p.filament_type for p in oc["filament"]] == ["PETG-CF", "PETG-CF", "PETG-CF", None, None, "PLA", None]
  229. class TestEnrichCompatiblePrinters:
  230. """#2628: the same name bridge carries ``compatible_printers`` onto the
  231. tiers that don't ship one. Bambu Cloud never does — so a profile whose
  232. name carries no printer model reads as "compatibility unknown", which the
  233. SliceModal treats as usable and auto-picks for the wrong printer."""
  234. COMPAT = ["Bambu Lab X1 Carbon 0.2 nozzle", "Bambu Lab P1S 0.2 nozzle"]
  235. def _tier(self, source: str, slot: str, compat: list[str] | None) -> dict[str, list[UnifiedPreset]]:
  236. empty: dict[str, list[UnifiedPreset]] = {"printer": [], "process": [], "filament": []}
  237. empty[slot] = [
  238. UnifiedPreset(id=f"{source}1", name="Overture PLA Matte @0.2", source=source, compatible_printers=compat)
  239. ]
  240. return empty
  241. def test_bambu_cloud_borrows_the_list_from_a_same_named_local_import(self):
  242. local = self._tier("local", "filament", self.COMPAT)
  243. cloud = self._tier("cloud", "filament", None)
  244. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
  245. assert c["filament"][0].compatible_printers == self.COMPAT
  246. def test_bambu_cloud_borrows_from_orca_cloud_too(self):
  247. orca = self._tier("orca_cloud", "filament", self.COMPAT)
  248. cloud = self._tier("cloud", "filament", None)
  249. _oc, c, _l, _s = sp._enrich_cloud_metadata(orca, cloud, _slot([]), _slot([]))
  250. assert c["filament"][0].compatible_printers == self.COMPAT
  251. def test_orca_cloud_borrows_when_its_own_content_had_no_list(self):
  252. orca = self._tier("orca_cloud", "filament", None)
  253. local = self._tier("local", "filament", self.COMPAT)
  254. oc, _c, _l, _s = sp._enrich_cloud_metadata(orca, _slot([]), local, _slot([]))
  255. assert oc["filament"][0].compatible_printers == self.COMPAT
  256. def test_process_slot_is_bridged_as_well(self):
  257. local = self._tier("local", "process", self.COMPAT)
  258. cloud = self._tier("cloud", "process", None)
  259. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
  260. assert c["process"][0].compatible_printers == self.COMPAT
  261. def test_never_overwrites_a_list_the_entry_already_has(self):
  262. own = ["Bambu Lab P2S 0.4 nozzle"]
  263. local = self._tier("local", "filament", self.COMPAT)
  264. cloud = self._tier("cloud", "filament", own)
  265. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
  266. assert c["filament"][0].compatible_printers == own
  267. def test_no_donor_leaves_the_entry_unclassified(self):
  268. """Absent evidence the entry must stay None — the SliceModal then
  269. falls back to the name matcher instead of hiding the profile."""
  270. cloud = self._tier("cloud", "filament", None)
  271. _oc, c, _l, _s = sp._enrich_cloud_metadata(_slot([]), cloud, _slot([]), _slot([]))
  272. assert c["filament"][0].compatible_printers is None
  273. def test_borrowed_list_is_copied_not_shared(self):
  274. """A later mutation of one tier's list must not reach through to the
  275. other — these objects are cached per user between requests."""
  276. local = self._tier("local", "filament", list(self.COMPAT))
  277. cloud = self._tier("cloud", "filament", None)
  278. _oc, c, l_, _s = sp._enrich_cloud_metadata(_slot([]), cloud, local, _slot([]))
  279. l_["filament"][0].compatible_printers.append("Bambu Lab H2D 0.4 nozzle")
  280. assert c["filament"][0].compatible_printers == self.COMPAT
  281. def _user_with_cloud_auth(user_id: int = 1) -> MagicMock:
  282. """Construct a mock User that passes the CLOUD_AUTH permission check.
  283. `MagicMock` defaults `.has_permission(...)` to a truthy MagicMock object,
  284. which would coincidentally pass the gate — but explicit is better than
  285. accidental. Setting `.return_value = True` documents the intent."""
  286. user = MagicMock(id=user_id)
  287. user.has_permission = MagicMock(return_value=True)
  288. return user
  289. class TestFetchOrcaCloudPresets:
  290. """``_fetch_orca_cloud_presets`` mirrors the Bambu Cloud fetcher's status
  291. vocabulary (``ok`` / ``not_authenticated`` / ``expired`` / ``unreachable``)
  292. and the same permission-shortcut + caching behaviour. Tests pin the
  293. contract so a future bug in either fetcher doesn't silently desync them."""
  294. def _orca_creds(self, token: str | None = "tok") -> MagicMock:
  295. creds = MagicMock()
  296. creds.token = token
  297. return creds
  298. @pytest.mark.asyncio
  299. async def test_no_token_returns_not_authenticated(self):
  300. sp._orca_cloud_cache.clear()
  301. with patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds(None))):
  302. user = MagicMock(id=1)
  303. user.has_permission = MagicMock(return_value=True)
  304. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  305. assert status == "not_authenticated"
  306. assert slots == {"printer": [], "process": [], "filament": []}
  307. @pytest.mark.asyncio
  308. async def test_user_without_orca_cloud_auth_returns_not_authenticated(self):
  309. """Defence-in-depth — a user lacking ORCA_CLOUD_AUTH must not see Orca
  310. presets even if their User row carries a stale token. Credentials
  311. lookup must short-circuit ahead of the token read."""
  312. sp._orca_cloud_cache.clear()
  313. user = MagicMock(id=1)
  314. user.has_permission = MagicMock(return_value=False)
  315. with patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))) as load:
  316. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  317. assert status == "not_authenticated"
  318. assert slots["printer"] == []
  319. load.assert_not_called()
  320. @pytest.mark.asyncio
  321. async def test_auth_error_returns_expired(self):
  322. sp._orca_cloud_cache.clear()
  323. svc_mock = MagicMock()
  324. svc_mock.list_profiles = AsyncMock(side_effect=sp.OrcaCloudAuthError("expired"))
  325. svc_mock.close = AsyncMock()
  326. user = MagicMock(id=1)
  327. user.has_permission = MagicMock(return_value=True)
  328. with (
  329. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  330. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  331. ):
  332. _slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  333. assert status == "expired"
  334. svc_mock.close.assert_awaited_once()
  335. @pytest.mark.asyncio
  336. async def test_orca_error_returns_unreachable(self):
  337. sp._orca_cloud_cache.clear()
  338. svc_mock = MagicMock()
  339. svc_mock.list_profiles = AsyncMock(side_effect=sp.OrcaCloudError("net down"))
  340. svc_mock.close = AsyncMock()
  341. user = MagicMock(id=1)
  342. user.has_permission = MagicMock(return_value=True)
  343. with (
  344. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  345. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  346. ):
  347. _slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  348. assert status == "unreachable"
  349. @pytest.mark.asyncio
  350. async def test_happy_path_shapes_grouped_by_type(self):
  351. """Orca content.type values map onto Bambu Cloud's preset type vocab
  352. (``printer`` / ``print`` → ``process`` / ``filament``). Verify the
  353. full mapping by feeding one of each shape."""
  354. sp._orca_cloud_cache.clear()
  355. svc_mock = MagicMock()
  356. svc_mock.list_profiles = AsyncMock(
  357. return_value=[
  358. {"id": "m1", "name": "Orca X1C", "content": {"type": "printer"}},
  359. {"id": "p1", "name": "Orca 0.20mm", "content": {"type": "print"}},
  360. {
  361. "id": "f1",
  362. "name": "Orca PLA",
  363. "content": {
  364. "type": "filament",
  365. "filament_type": ["PLA"],
  366. "default_filament_colour": ["#000000"],
  367. },
  368. },
  369. ]
  370. )
  371. svc_mock.close = AsyncMock()
  372. user = MagicMock(id=1)
  373. user.has_permission = MagicMock(return_value=True)
  374. with (
  375. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  376. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  377. ):
  378. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  379. assert status == "ok"
  380. assert [p.name for p in slots["printer"]] == ["Orca X1C"]
  381. assert [p.name for p in slots["process"]] == ["Orca 0.20mm"]
  382. filament = slots["filament"]
  383. assert [p.name for p in filament] == ["Orca PLA"]
  384. # Inline metadata extracted from the content blob (Orca's sync_pull
  385. # returns full content, so unlike Bambu Cloud we don't need a second
  386. # per-preset fetch to enrich filament_type / filament_colour).
  387. assert filament[0].filament_type == "PLA"
  388. assert filament[0].filament_colour == "#000000"
  389. @pytest.mark.asyncio
  390. async def test_extracts_compatible_printers_from_content(self):
  391. """#2628: Orca's sync_pull already carries the profile's own
  392. compatible-printer list. Surfacing it lets the SliceModal reject a
  393. profile built for another printer instead of falling back to reading
  394. the model out of the NAME — which fails outright for names that carry
  395. no model ("Overture PLA Matte @0.2")."""
  396. sp._orca_cloud_cache.clear()
  397. compat = ["Bambu Lab X1 Carbon 0.2 nozzle", "Bambu Lab P1S 0.2 nozzle"]
  398. svc_mock = MagicMock()
  399. svc_mock.list_profiles = AsyncMock(
  400. return_value=[
  401. {
  402. "id": "f1",
  403. "name": "Overture PLA Matte @0.2",
  404. "content": {
  405. "type": "filament",
  406. "filament_type": ["PLA"],
  407. "compatible_printers": compat,
  408. },
  409. },
  410. {
  411. "id": "p1",
  412. "name": "Orca 0.20mm",
  413. "content": {"type": "print", "compatible_printers": "Bambu Lab P2S 0.4 nozzle"},
  414. },
  415. {"id": "m1", "name": "Orca X1C", "content": {"type": "printer"}},
  416. ]
  417. )
  418. svc_mock.close = AsyncMock()
  419. user = MagicMock(id=1)
  420. user.has_permission = MagicMock(return_value=True)
  421. with (
  422. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  423. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  424. ):
  425. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  426. assert status == "ok"
  427. assert slots["filament"][0].compatible_printers == compat
  428. # A single-printer profile may store a bare string — normalised to a list.
  429. assert slots["process"][0].compatible_printers == ["Bambu Lab P2S 0.4 nozzle"]
  430. # Printer presets have nothing to be compatible with.
  431. assert slots["printer"][0].compatible_printers is None
  432. @pytest.mark.asyncio
  433. async def test_printer_preset_carries_the_preset_it_was_saved_from(self):
  434. """#3250: a printer preset saved under a name of its own is in no
  435. process's compatible_printers; the SliceModal needs its parent."""
  436. sp._orca_cloud_cache.clear()
  437. svc_mock = MagicMock()
  438. svc_mock.list_profiles = AsyncMock(
  439. return_value=[
  440. {
  441. "id": "m1",
  442. "name": "Bambu Lab H2D 0.4 nozzle - Skew",
  443. "content": {"type": "printer", "inherits": " Bambu Lab H2D 0.4 nozzle "},
  444. },
  445. {"id": "m2", "name": "Orca X1C", "content": {"type": "printer", "inherits": ""}},
  446. {"id": "m3", "name": "Odd", "content": {"type": "printer", "inherits": ["x"]}},
  447. ]
  448. )
  449. svc_mock.close = AsyncMock()
  450. user = MagicMock(id=1)
  451. user.has_permission = MagicMock(return_value=True)
  452. with (
  453. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  454. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  455. ):
  456. slots, _ = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  457. assert [p.inherits for p in slots["printer"]] == ["Bambu Lab H2D 0.4 nozzle", None, None]
  458. @pytest.mark.asyncio
  459. async def test_quoted_compatible_printers_are_unquoted(self):
  460. """#3380: Orca Cloud returns list settings INI-style — one string of
  461. quoted items joined by ``;``. Left as-is, the names never equal the
  462. selected printer preset and the SliceModal hides the profile."""
  463. sp._orca_cloud_cache.clear()
  464. svc_mock = MagicMock()
  465. svc_mock.list_profiles = AsyncMock(
  466. return_value=[
  467. {
  468. "id": "f1",
  469. "name": "SUNLU PLA Meta @Bambu Lab X1 Carbon 0.4 nozzle",
  470. "content": {
  471. "type": "filament",
  472. "filament_type": "PLA",
  473. "default_filament_colour": '""',
  474. "compatible_printers": '"Bambu Lab X1 Carbon 0.4 nozzle";"Bambu Lab H2C 0.4 nozzle"',
  475. },
  476. },
  477. {"id": "f2", "name": "Only quotes", "content": {"type": "filament", "compatible_printers": '""'}},
  478. {
  479. "id": "f3",
  480. "name": "Escaped",
  481. "content": {
  482. "type": "filament",
  483. "default_filament_colour": '"#FF0000"',
  484. "compatible_printers": '"Printer \\"A\\"";"Bambu Lab A1 0.4 nozzle"',
  485. },
  486. },
  487. {
  488. "id": "f4",
  489. "name": "Text after a closing quote",
  490. "content": {"type": "filament", "filament_type": "PLA", "compatible_printers": '"X1C" P1S'},
  491. },
  492. ]
  493. )
  494. svc_mock.close = AsyncMock()
  495. user = MagicMock(id=1)
  496. user.has_permission = MagicMock(return_value=True)
  497. with (
  498. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  499. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  500. ):
  501. slots, _status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  502. meta, only_quotes, escaped, bad = slots["filament"]
  503. assert meta.compatible_printers == ["Bambu Lab X1 Carbon 0.4 nozzle", "Bambu Lab H2C 0.4 nozzle"]
  504. assert meta.filament_type == "PLA"
  505. assert meta.filament_colour is None
  506. assert only_quotes.compatible_printers is None
  507. assert escaped.compatible_printers == ['Printer "A"', "Bambu Lab A1 0.4 nozzle"]
  508. assert escaped.filament_colour == "#FF0000"
  509. # One unparseable value reads as "no data" and the rest of the listing survives.
  510. assert bad.compatible_printers is None
  511. assert bad.filament_type == "PLA"
  512. @pytest.mark.asyncio
  513. async def test_filament_preset_carries_the_preset_it_was_saved_from(self):
  514. """#3403: a filament saved with only the keys the user changed states no
  515. material of its own; the enrich pass reads it off this parent."""
  516. sp._orca_cloud_cache.clear()
  517. svc_mock = MagicMock()
  518. svc_mock.list_profiles = AsyncMock(
  519. return_value=[
  520. {
  521. "id": "f1",
  522. "name": "My PETG-CF",
  523. "content": {"type": "filament", "inherits": "Generic PETG-CF @BBL X1C"},
  524. },
  525. {
  526. "id": "p1",
  527. "name": "Orca 0.20mm",
  528. "content": {"type": "print", "inherits": "0.20mm Standard @BBL X1C"},
  529. },
  530. ]
  531. )
  532. svc_mock.close = AsyncMock()
  533. user = MagicMock(id=1)
  534. user.has_permission = MagicMock(return_value=True)
  535. with (
  536. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  537. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  538. ):
  539. slots, _ = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  540. assert slots["filament"][0].inherits == "Generic PETG-CF @BBL X1C"
  541. assert slots["filament"][0].filament_type is None
  542. assert slots["process"][0].inherits is None
  543. @pytest.mark.asyncio
  544. async def test_missing_or_malformed_compatible_printers_stays_none(self):
  545. """No data must read as "unknown", never as "compatible with nothing" —
  546. the SliceModal falls back to the name matcher for those."""
  547. sp._orca_cloud_cache.clear()
  548. svc_mock = MagicMock()
  549. svc_mock.list_profiles = AsyncMock(
  550. return_value=[
  551. {"id": "f1", "name": "No list", "content": {"type": "filament"}},
  552. {"id": "f2", "name": "Empty list", "content": {"type": "filament", "compatible_printers": []}},
  553. {"id": "f3", "name": "Blanks", "content": {"type": "filament", "compatible_printers": ["", " "]}},
  554. {"id": "f4", "name": "Wrong type", "content": {"type": "filament", "compatible_printers": {"a": 1}}},
  555. ]
  556. )
  557. svc_mock.close = AsyncMock()
  558. user = MagicMock(id=1)
  559. user.has_permission = MagicMock(return_value=True)
  560. with (
  561. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  562. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)),
  563. ):
  564. slots, _status = await sp._fetch_orca_cloud_presets(MagicMock(), user)
  565. assert [p.compatible_printers for p in slots["filament"]] == [None, None, None, None]
  566. @pytest.mark.asyncio
  567. async def test_cache_hit_skips_orca_call(self):
  568. """A second call within TTL must reuse the cached slots and NOT
  569. hit the Orca service again — same TTL as Bambu Cloud (5 min)."""
  570. sp._orca_cloud_cache.clear()
  571. svc_mock = MagicMock()
  572. svc_mock.list_profiles = AsyncMock(return_value=[])
  573. svc_mock.close = AsyncMock()
  574. user = MagicMock(id=1)
  575. user.has_permission = MagicMock(return_value=True)
  576. with (
  577. patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))),
  578. patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)) as build,
  579. ):
  580. await sp._fetch_orca_cloud_presets(MagicMock(), user)
  581. await sp._fetch_orca_cloud_presets(MagicMock(), user)
  582. # Build is the cache miss signal — second call reused the cache.
  583. build.assert_awaited_once()
  584. class TestFetchCloudPresets:
  585. """`_fetch_cloud_presets` translates token state and cloud errors into
  586. the four ``cloud_status`` values the SliceModal banner consumes."""
  587. @pytest.mark.asyncio
  588. async def test_no_token_returns_not_authenticated(self):
  589. sp._cloud_cache.clear()
  590. with patch.object(sp, "get_stored_token", AsyncMock(return_value=(None, None, None))):
  591. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  592. assert status == "not_authenticated"
  593. assert slots == {"printer": [], "process": [], "filament": []}
  594. @pytest.mark.asyncio
  595. async def test_user_without_cloud_auth_returns_not_authenticated(self):
  596. """Defence-in-depth: a user lacking CLOUD_AUTH must NOT see cloud
  597. presets even if their User row carries a stale cloud_token from a
  598. previous permission state. Token lookup is skipped entirely."""
  599. sp._cloud_cache.clear()
  600. user = MagicMock(id=1)
  601. user.has_permission = MagicMock(return_value=False)
  602. with patch.object(sp, "get_stored_token", AsyncMock(return_value=("leftover-token", None, None))) as get_tok:
  603. slots, status = await sp._fetch_cloud_presets(MagicMock(), user)
  604. assert status == "not_authenticated"
  605. assert slots["printer"] == []
  606. # Token was never read — the perm check short-circuits ahead of it.
  607. get_tok.assert_not_called()
  608. @pytest.mark.asyncio
  609. async def test_auth_error_returns_expired(self):
  610. sp._cloud_cache.clear()
  611. cloud_mock = MagicMock()
  612. cloud_mock.set_token = MagicMock()
  613. cloud_mock.get_slicer_settings = AsyncMock(side_effect=sp.BambuCloudAuthError("expired"))
  614. cloud_mock.close = AsyncMock()
  615. with (
  616. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", "e@x", None))),
  617. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  618. ):
  619. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  620. assert status == "expired"
  621. assert slots["printer"] == []
  622. cloud_mock.close.assert_awaited_once()
  623. @pytest.mark.asyncio
  624. async def test_cloud_error_returns_unreachable(self):
  625. sp._cloud_cache.clear()
  626. cloud_mock = MagicMock()
  627. cloud_mock.set_token = MagicMock()
  628. cloud_mock.get_slicer_settings = AsyncMock(side_effect=sp.BambuCloudError("net down"))
  629. cloud_mock.close = AsyncMock()
  630. with (
  631. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  632. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  633. ):
  634. _slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  635. assert status == "unreachable"
  636. @pytest.mark.asyncio
  637. async def test_happy_path_shapes_private_then_public(self):
  638. """Cloud presets split into private (user-custom) + public (Bambu's
  639. stock cloud presets). Private should sort before public so a user's
  640. own customisations sit at the top of the dropdown."""
  641. sp._cloud_cache.clear()
  642. cloud_mock = MagicMock()
  643. cloud_mock.set_token = MagicMock()
  644. cloud_mock.get_slicer_settings = AsyncMock(
  645. return_value={
  646. "printer": {
  647. "private": [{"setting_id": "PFUprivate1", "name": "My X1C"}],
  648. "public": [{"setting_id": "PFUpublic1", "name": "Bambu X1C Stock"}],
  649. },
  650. "print": {"private": [], "public": []},
  651. "filament": {"private": [], "public": []},
  652. }
  653. )
  654. cloud_mock.close = AsyncMock()
  655. with (
  656. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  657. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  658. ):
  659. slots, status = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth())
  660. assert status == "ok"
  661. names = [p.name for p in slots["printer"]]
  662. assert names == ["My X1C", "Bambu X1C Stock"]
  663. # #3386: the public list is Bambu's stock presets — flagged so the
  664. # listing can drop the ones Standard also has.
  665. assert [p.stock for p in slots["printer"]] == [False, True]
  666. @pytest.mark.asyncio
  667. async def test_cache_hit_skips_cloud_call(self):
  668. """A second call within TTL must reuse the cached slots and NOT
  669. hit Bambu Cloud again."""
  670. sp._cloud_cache.clear()
  671. cloud_mock = MagicMock()
  672. cloud_mock.set_token = MagicMock()
  673. cloud_mock.get_slicer_settings = AsyncMock(
  674. return_value={
  675. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  676. "print": {"private": [], "public": []},
  677. "filament": {"private": [], "public": []},
  678. }
  679. )
  680. cloud_mock.close = AsyncMock()
  681. user = _user_with_cloud_auth(user_id=42)
  682. with (
  683. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  684. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  685. ):
  686. await sp._fetch_cloud_presets(MagicMock(), user)
  687. await sp._fetch_cloud_presets(MagicMock(), user)
  688. cloud_mock.get_slicer_settings.assert_awaited_once()
  689. @pytest.mark.asyncio
  690. async def test_cache_is_per_user(self):
  691. """User A's cached cloud presets must not surface for user B."""
  692. sp._cloud_cache.clear()
  693. def make_mock(name: str):
  694. m = MagicMock()
  695. m.set_token = MagicMock()
  696. m.get_slicer_settings = AsyncMock(
  697. return_value={
  698. "printer": {"private": [{"setting_id": f"id-{name}", "name": name}], "public": []},
  699. "print": {"private": [], "public": []},
  700. "filament": {"private": [], "public": []},
  701. }
  702. )
  703. m.close = AsyncMock()
  704. return m
  705. sequence = [make_mock("AliceX1C"), make_mock("BobX1C")]
  706. with (
  707. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  708. patch.object(sp, "BambuCloudService", side_effect=sequence),
  709. ):
  710. alice_slots, _ = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth(1))
  711. bob_slots, _ = await sp._fetch_cloud_presets(MagicMock(), _user_with_cloud_auth(2))
  712. assert alice_slots["printer"][0].name == "AliceX1C"
  713. assert bob_slots["printer"][0].name == "BobX1C"
  714. @pytest.mark.asyncio
  715. async def test_cache_invalidates_on_token_change(self):
  716. """A token change (logout + login, admin reset, region switch) must
  717. bypass the cache for that user — pinning a real-world auth bug
  718. where user re-login + cache-stuck-on-old-cloud-account would
  719. silently serve a different account's preset list for ~5 minutes."""
  720. sp._cloud_cache.clear()
  721. def make_mock(name: str):
  722. m = MagicMock()
  723. m.set_token = MagicMock()
  724. m.get_slicer_settings = AsyncMock(
  725. return_value={
  726. "printer": {"private": [{"setting_id": f"id-{name}", "name": name}], "public": []},
  727. "print": {"private": [], "public": []},
  728. "filament": {"private": [], "public": []},
  729. }
  730. )
  731. m.close = AsyncMock()
  732. return m
  733. # Same user_id, different token between calls — the second call must
  734. # NOT serve the first call's cached slots.
  735. services = [make_mock("OldAccountX1C"), make_mock("NewAccountX1C")]
  736. token_sequence = [("tok-old", None, None), ("tok-new", None, None)]
  737. user = _user_with_cloud_auth(user_id=7)
  738. with (
  739. patch.object(sp, "get_stored_token", AsyncMock(side_effect=token_sequence)),
  740. patch.object(sp, "BambuCloudService", side_effect=services),
  741. ):
  742. first, _ = await sp._fetch_cloud_presets(MagicMock(), user)
  743. second, _ = await sp._fetch_cloud_presets(MagicMock(), user)
  744. assert first["printer"][0].name == "OldAccountX1C"
  745. assert second["printer"][0].name == "NewAccountX1C"
  746. @pytest.mark.asyncio
  747. async def test_refresh_bypasses_cloud_cache(self):
  748. """``refresh=True`` must skip an otherwise-warm cache entry and hit
  749. Bambu Cloud again — wiring for the SliceModal's Refresh button so a
  750. user who deletes a cloud preset in Bambu Studio / Handy doesn't have
  751. to wait for the 5-minute TTL to expire (#1581)."""
  752. sp._cloud_cache.clear()
  753. cloud_mock = MagicMock()
  754. cloud_mock.set_token = MagicMock()
  755. cloud_mock.get_slicer_settings = AsyncMock(
  756. return_value={
  757. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  758. "print": {"private": [], "public": []},
  759. "filament": {"private": [], "public": []},
  760. }
  761. )
  762. cloud_mock.close = AsyncMock()
  763. user = _user_with_cloud_auth(user_id=99)
  764. with (
  765. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  766. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  767. ):
  768. await sp._fetch_cloud_presets(MagicMock(), user)
  769. # Without refresh, the second call hits cache (covered by
  770. # test_cache_hit_skips_cloud_call). With refresh=True it MUST
  771. # re-fetch.
  772. await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
  773. assert cloud_mock.get_slicer_settings.await_count == 2
  774. @pytest.mark.asyncio
  775. async def test_refresh_writes_back_to_cache(self):
  776. """A refresh call must still update the cache so a subsequent normal
  777. call doesn't re-hit the cloud immediately afterwards."""
  778. sp._cloud_cache.clear()
  779. cloud_mock = MagicMock()
  780. cloud_mock.set_token = MagicMock()
  781. cloud_mock.get_slicer_settings = AsyncMock(
  782. return_value={
  783. "printer": {"private": [{"setting_id": "id1", "name": "X1C"}], "public": []},
  784. "print": {"private": [], "public": []},
  785. "filament": {"private": [], "public": []},
  786. }
  787. )
  788. cloud_mock.close = AsyncMock()
  789. user = _user_with_cloud_auth(user_id=101)
  790. with (
  791. patch.object(sp, "get_stored_token", AsyncMock(return_value=("tok", None, None))),
  792. patch.object(sp, "BambuCloudService", return_value=cloud_mock),
  793. ):
  794. await sp._fetch_cloud_presets(MagicMock(), user, refresh=True)
  795. await sp._fetch_cloud_presets(MagicMock(), user)
  796. # Two calls — first refresh, second a normal cache hit.
  797. assert cloud_mock.get_slicer_settings.await_count == 1
  798. class TestStockDuplicates:
  799. """#3386: a stock Bambu Cloud preset Standard also has is listed once, under Bambu Cloud."""
  800. @staticmethod
  801. def _tiers():
  802. cloud = {
  803. "printer": [],
  804. "process": [],
  805. "filament": [
  806. UnifiedPreset(id="PFUS1", name="My PLA", source="cloud"),
  807. UnifiedPreset(id="GFSA00", name="Bambu PLA Basic @BBL X1C", source="cloud", stock=True),
  808. UnifiedPreset(id="GFSA99", name="Bambu PLA Brand New @BBL X1C", source="cloud", stock=True),
  809. ],
  810. }
  811. standard = {
  812. "printer": [],
  813. "process": [],
  814. "filament": [
  815. UnifiedPreset(id="std-basic", name="Bambu PLA Basic @BBL X1C", source="standard"),
  816. UnifiedPreset(id="std-mine", name="My PLA", source="standard"),
  817. UnifiedPreset(id="std-matte", name="Bambu PLA Matte @BBL X1C", source="standard"),
  818. ],
  819. }
  820. return cloud, standard
  821. def test_hides_the_standard_twin_of_a_stock_cloud_preset(self):
  822. cloud, standard = self._tiers()
  823. aliases = sp._stock_aliases(cloud, standard)
  824. # Only a stock preset hides its twin: the user's own "My PLA" doesn't.
  825. assert {k: v.model_dump() for k, v in aliases.items()} == {
  826. "standard:std-basic": {"source": "cloud", "id": "GFSA00"}
  827. }
  828. kept = sp._drop_aliased(standard, aliases)
  829. assert [p.id for p in kept["filament"]] == ["std-mine", "std-matte"]
  830. # The cloud tier is left whole, the stock copy newer than the bundle included.
  831. assert sp._drop_aliased(cloud, aliases) == cloud
  832. def test_signed_out_nothing_is_hidden(self):
  833. _cloud, standard = self._tiers()
  834. empty = {"printer": [], "process": [], "filament": []}
  835. assert sp._stock_aliases(empty, standard) == {}
  836. @pytest.mark.asyncio
  837. async def test_the_listing_drops_the_standard_twin(self):
  838. cloud, standard = self._tiers()
  839. empty = {"printer": [], "process": [], "filament": []}
  840. with (
  841. patch.object(sp, "_fetch_orca_cloud_presets", AsyncMock(return_value=(empty, "not_authenticated"))),
  842. patch.object(sp, "_fetch_cloud_presets", AsyncMock(return_value=(cloud, "ok"))),
  843. patch.object(sp, "_fetch_local_presets", AsyncMock(return_value=empty)),
  844. patch.object(sp, "_fetch_bundled_presets", AsyncMock(return_value=standard)),
  845. ):
  846. response = await sp.list_unified_presets(
  847. db=MagicMock(), current_user=None, api_key_cloud_owner=None, refresh=False
  848. )
  849. body = response.model_dump()
  850. assert [p["id"] for p in body["standard"]["filament"]] == ["std-mine", "std-matte"]
  851. assert [p["id"] for p in body["cloud"]["filament"]] == ["PFUS1", "GFSA00", "GFSA99"]
  852. assert body["stock_aliases"] == {"standard:std-basic": {"source": "cloud", "id": "GFSA00"}}
  853. @pytest.mark.asyncio
  854. async def test_slicing_from_stored_refs_uses_the_same_map(self):
  855. cloud, standard = self._tiers()
  856. with (
  857. patch.object(sp, "_fetch_cloud_presets", AsyncMock(return_value=(cloud, "ok"))),
  858. patch.object(sp, "_fetch_bundled_presets", AsyncMock(return_value=standard)),
  859. ):
  860. aliases = await sp.load_stock_aliases(MagicMock(), None)
  861. hidden = sp.listed_ref(aliases, PresetRef(source="standard", id="std-basic"))
  862. assert hidden.model_dump() == {"source": "cloud", "id": "GFSA00"}
  863. listed = PresetRef(source="standard", id="std-matte")
  864. assert sp.listed_ref(aliases, listed) is listed
  865. def test_stock_flag_stays_out_of_the_response(self):
  866. preset = UnifiedPreset(id="GFSA99", name="x", source="cloud", stock=True)
  867. assert "stock" not in preset.model_dump()
  868. class TestFetchBundledPresets:
  869. """Standard tier reaches out to the slicer-api sidecar; tolerate the
  870. sidecar being absent / unreachable so the modal still works."""
  871. @pytest.mark.asyncio
  872. async def test_no_sidecar_url_returns_empty(self):
  873. sp._bundled_cache = None
  874. with patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value=None)):
  875. slots = await sp._fetch_bundled_presets(MagicMock())
  876. assert slots == {"printer": [], "process": [], "filament": []}
  877. # No URL means no useful cache result either — second call should
  878. # try again (so users who configure a URL mid-session see results).
  879. assert sp._bundled_cache is None
  880. @pytest.mark.asyncio
  881. async def test_sidecar_error_returns_empty(self):
  882. sp._bundled_cache = None
  883. svc_mock = MagicMock()
  884. svc_mock.list_bundled_profiles = AsyncMock(side_effect=sp.SlicerApiError("boom"))
  885. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  886. svc_mock.__aexit__ = AsyncMock(return_value=False)
  887. with (
  888. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://nope")),
  889. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  890. ):
  891. slots = await sp._fetch_bundled_presets(MagicMock())
  892. assert slots == {"printer": [], "process": [], "filament": []}
  893. @pytest.mark.asyncio
  894. async def test_happy_path_shapes_response(self):
  895. sp._bundled_cache = None
  896. svc_mock = MagicMock()
  897. svc_mock.list_bundled_profiles = AsyncMock(
  898. return_value={
  899. "printer": [{"name": "Bambu X1C 0.4", "base_id": None}],
  900. "process": [{"name": "0.20mm Standard", "base_id": "fdm_process_common"}],
  901. "filament": [{"name": "Bambu PLA Basic", "base_id": "fdm_filament_pla"}],
  902. }
  903. )
  904. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  905. svc_mock.__aexit__ = AsyncMock(return_value=False)
  906. with (
  907. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  908. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  909. ):
  910. slots = await sp._fetch_bundled_presets(MagicMock())
  911. assert slots["printer"][0].name == "Bambu X1C 0.4"
  912. assert slots["printer"][0].source == "standard"
  913. # Bundled presets are addressed by name (the slicer's inheritance
  914. # walker resolves them by name), so id == name.
  915. assert slots["printer"][0].id == "Bambu X1C 0.4"
  916. @pytest.mark.asyncio
  917. async def test_cache_hit_skips_sidecar(self):
  918. """A second call within TTL must serve from the cached entry and not
  919. re-hit the sidecar HTTP."""
  920. sp._bundled_cache = (
  921. time.monotonic(),
  922. {
  923. "printer": [UnifiedPreset(id="Cached", name="Cached", source="standard")],
  924. "process": [],
  925. "filament": [],
  926. },
  927. )
  928. # If `SlicerApiService` is constructed at all we've missed the cache.
  929. with patch.object(sp, "SlicerApiService", side_effect=AssertionError("cache miss!")):
  930. slots = await sp._fetch_bundled_presets(MagicMock())
  931. assert slots["printer"][0].name == "Cached"
  932. @pytest.mark.asyncio
  933. async def test_refresh_bypasses_bundled_cache(self):
  934. """``refresh=True`` must re-hit the sidecar even when the in-process
  935. cache is warm — paired with the cloud-cache refresh, this is what
  936. powers the SliceModal's Refresh button (#1581)."""
  937. sp._bundled_cache = (
  938. time.monotonic(),
  939. {
  940. "printer": [UnifiedPreset(id="Stale", name="Stale", source="standard")],
  941. "process": [],
  942. "filament": [],
  943. },
  944. )
  945. svc_mock = MagicMock()
  946. svc_mock.list_bundled_profiles = AsyncMock(
  947. return_value={
  948. "printer": [{"name": "Fresh", "base_id": None}],
  949. "process": [],
  950. "filament": [],
  951. }
  952. )
  953. svc_mock.__aenter__ = AsyncMock(return_value=svc_mock)
  954. svc_mock.__aexit__ = AsyncMock(return_value=False)
  955. with (
  956. patch.object(sp, "_resolve_slicer_api_url", AsyncMock(return_value="http://ok")),
  957. patch.object(sp, "SlicerApiService", return_value=svc_mock),
  958. ):
  959. slots = await sp._fetch_bundled_presets(MagicMock(), refresh=True)
  960. svc_mock.list_bundled_profiles.assert_awaited_once()
  961. assert [p.name for p in slots["printer"]] == ["Fresh"]
  962. # The fresh result must also be written back to the cache so a
  963. # subsequent normal (non-refresh) call doesn't re-hit the sidecar.
  964. assert sp._bundled_cache is not None
  965. assert [p.name for p in sp._bundled_cache[1]["printer"]] == ["Fresh"]
  966. class TestResolveSlicerApiUrl:
  967. """`_resolve_slicer_api_url` must respect the user's `preferred_slicer`
  968. setting just like the slice route does. The bundled-listing fetch
  969. used to be hardcoded to OrcaSlicer's URL, which left the Standard
  970. tier permanently empty for BambuStudio installs."""
  971. @pytest.mark.asyncio
  972. async def test_bambu_studio_preference_uses_bambu_url(self):
  973. """When the user prefers Bambu Studio, the listing fetch must hit
  974. the bambu-studio-api sidecar (port 3001 by default), not orca's
  975. port 3003."""
  976. async def fake_get_setting(_db, key):
  977. return {
  978. "preferred_slicer": "bambu_studio",
  979. "bambu_studio_api_url": "http://bambu-studio-api:3000",
  980. }.get(key)
  981. with patch(
  982. "backend.app.api.routes.settings.get_setting",
  983. new=fake_get_setting,
  984. ):
  985. url = await sp._resolve_slicer_api_url(MagicMock())
  986. assert url == "http://bambu-studio-api:3000"
  987. @pytest.mark.asyncio
  988. async def test_orcaslicer_preference_uses_orca_url(self):
  989. async def fake_get_setting(_db, key):
  990. return {
  991. "preferred_slicer": "orcaslicer",
  992. "orcaslicer_api_url": "http://orca-slicer-api:3000",
  993. }.get(key)
  994. with patch(
  995. "backend.app.api.routes.settings.get_setting",
  996. new=fake_get_setting,
  997. ):
  998. url = await sp._resolve_slicer_api_url(MagicMock())
  999. assert url == "http://orca-slicer-api:3000"
  1000. @pytest.mark.asyncio
  1001. async def test_default_preference_is_bambu_studio(self):
  1002. """Empty preferred_slicer → bambu_studio (matches the slice route's
  1003. default at library.py:_run_slicer_with_fallback)."""
  1004. async def fake_get_setting(_db, key):
  1005. return {
  1006. # preferred_slicer not set
  1007. "bambu_studio_api_url": "http://bambu-default:3000",
  1008. }.get(key)
  1009. with patch(
  1010. "backend.app.api.routes.settings.get_setting",
  1011. new=fake_get_setting,
  1012. ):
  1013. url = await sp._resolve_slicer_api_url(MagicMock())
  1014. assert url == "http://bambu-default:3000"
  1015. @pytest.mark.asyncio
  1016. async def test_unknown_preference_returns_none(self):
  1017. """An unrecognised preferred_slicer value (e.g. set out-of-band by
  1018. a stale migration) returns None so the modal degrades to "no
  1019. Standard tier" rather than crashing — the slice route raises 400
  1020. in this case but the listing is informational, so be lenient."""
  1021. async def fake_get_setting(_db, key):
  1022. return {"preferred_slicer": "prusaslicer"}.get(key)
  1023. with patch(
  1024. "backend.app.api.routes.settings.get_setting",
  1025. new=fake_get_setting,
  1026. ):
  1027. url = await sp._resolve_slicer_api_url(MagicMock())
  1028. assert url is None
  1029. class TestParseCompatiblePrinters:
  1030. """``compatible_printers`` exposed for local process / filament presets so
  1031. the SliceModal can filter the dropdowns by the selected printer (#1325)."""
  1032. def test_parses_json_array(self):
  1033. raw = '["Bambu Lab X1 Carbon 0.4 nozzle", "Bambu Lab X1 0.4 nozzle"]'
  1034. assert sp._parse_compatible_printers(raw) == [
  1035. "Bambu Lab X1 Carbon 0.4 nozzle",
  1036. "Bambu Lab X1 0.4 nozzle",
  1037. ]
  1038. def test_none_and_empty_return_none(self):
  1039. assert sp._parse_compatible_printers(None) is None
  1040. assert sp._parse_compatible_printers("") is None
  1041. assert sp._parse_compatible_printers("[]") is None
  1042. def test_malformed_json_returns_none(self):
  1043. assert sp._parse_compatible_printers("not json") is None
  1044. # A JSON value that isn't an array is treated as absent, not an error.
  1045. assert sp._parse_compatible_printers('"a string"') is None
  1046. def test_drops_non_string_and_blank_entries(self):
  1047. assert sp._parse_compatible_printers('["X1C", 5, "", " ", "A1"]') == [
  1048. "X1C",
  1049. "A1",
  1050. ]
  1051. class TestListPrinterModels:
  1052. """``GET /slicer/printer-models`` exposes ``PRINTER_MODEL_MAP`` so the
  1053. frontend doesn't duplicate the Bambu model registry (#1325 follow-up)."""
  1054. def test_returns_canonical_printer_model_map(self):
  1055. from backend.app.utils.printer_models import PRINTER_MODEL_MAP
  1056. result = sp.list_printer_models()
  1057. # Same shape - mapping from "Bambu Lab <model>" to short code.
  1058. assert result == PRINTER_MODEL_MAP
  1059. # Spot-check a few entries: the SliceModal name-fallback (#1325)
  1060. # specifically depends on these resolving.
  1061. assert result["Bambu Lab X1 Carbon"] == "X1C"
  1062. assert result["Bambu Lab P2S"] == "P2S"
  1063. assert result["Bambu Lab A1 mini"] == "A1 Mini"
  1064. assert result["Bambu Lab H2D Pro"] == "H2D Pro"
  1065. def test_returns_a_copy_not_the_module_dict(self):
  1066. # A response handler must never hand out the live module-level dict —
  1067. # accidental mutation by middleware / serialisers would silently
  1068. # corrupt the registry for every subsequent request.
  1069. from backend.app.utils.printer_models import PRINTER_MODEL_MAP
  1070. result = sp.list_printer_models()
  1071. assert result is not PRINTER_MODEL_MAP
  1072. class TestNoUserWithAuthOn:
  1073. """The sign-in stored without a user is the auth-off install's, and may be
  1074. left over after auth was turned on. A caller with no user while auth is
  1075. on (an API key without Allow Cloud Access) must not list its presets."""
  1076. @pytest.mark.asyncio
  1077. async def test_bambu_cloud(self):
  1078. sp._cloud_cache.clear()
  1079. with (
  1080. patch.object(sp, "is_auth_enabled", AsyncMock(return_value=True)),
  1081. patch.object(sp, "get_stored_token", AsyncMock(return_value=("global-tok", None, None))) as get_tok,
  1082. ):
  1083. slots, status = await sp._fetch_cloud_presets(MagicMock(), None)
  1084. assert status == "not_authenticated"
  1085. assert slots == {"printer": [], "process": [], "filament": []}
  1086. get_tok.assert_not_called()
  1087. @pytest.mark.asyncio
  1088. async def test_orca_cloud(self):
  1089. sp._orca_cloud_cache.clear()
  1090. with (
  1091. patch.object(sp, "is_auth_enabled", AsyncMock(return_value=True)),
  1092. patch.object(sp, "_load_orca_credentials", AsyncMock()) as load,
  1093. ):
  1094. slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), None)
  1095. assert status == "not_authenticated"
  1096. assert slots == {"printer": [], "process": [], "filament": []}
  1097. load.assert_not_called()
  1098. class TestLocalPrinterInherits:
  1099. """#3250: local printer presets report the preset they were saved from."""
  1100. @pytest.mark.asyncio
  1101. async def test_local_printer_preset_carries_inherits(self, db_session):
  1102. from backend.app.models.local_preset import LocalPreset
  1103. db_session.add_all(
  1104. [
  1105. LocalPreset(
  1106. name="Bambu Lab H2D 0.4 nozzle - Skew",
  1107. preset_type="printer",
  1108. source="orcaslicer",
  1109. setting="{}",
  1110. inherits="Bambu Lab H2D 0.4 nozzle",
  1111. ),
  1112. LocalPreset(name="Plain", preset_type="printer", source="orcaslicer", setting="{}", inherits=" "),
  1113. LocalPreset(
  1114. name="0.20mm Mine",
  1115. preset_type="process",
  1116. source="orcaslicer",
  1117. setting="{}",
  1118. inherits="0.20mm Standard @BBL H2D",
  1119. ),
  1120. ]
  1121. )
  1122. await db_session.commit()
  1123. slots = await sp._fetch_local_presets(db_session)
  1124. by_name = {p.name: p for p in slots["printer"]}
  1125. assert by_name["Bambu Lab H2D 0.4 nozzle - Skew"].inherits == "Bambu Lab H2D 0.4 nozzle"
  1126. assert by_name["Plain"].inherits is None
  1127. # Only printer presets need it; a process is matched by its own list.
  1128. assert slots["process"][0].inherits is None