test_preset_resolver.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. """Tests for the source-aware preset resolver used by the slice route."""
  2. from __future__ import annotations
  3. import json
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. import pytest
  6. from fastapi import HTTPException
  7. from backend.app.schemas.slicer import PresetRef
  8. from backend.app.services import preset_resolver
  9. # --- standard tier --------------------------------------------------------
  10. def test_standard_emits_inherits_stub():
  11. """Standard tier returns a JSON stub the sidecar's resolver can flatten
  12. against `BUNDLED_PROFILES_PATH/<category>/<name>.json`. No content
  13. round-trip needed — the sidecar reads the bundled JSON itself."""
  14. out = preset_resolver._resolve_standard(
  15. PresetRef(source="standard", id="Bambu Lab X1 Carbon 0.4 nozzle"),
  16. slot="printer",
  17. )
  18. payload = json.loads(out)
  19. assert payload == {
  20. "name": "Bambu Lab X1 Carbon 0.4 nozzle",
  21. "inherits": "Bambu Lab X1 Carbon 0.4 nozzle",
  22. # `from: "system"` so the sidecar's compatibility check doesn't
  23. # treat this as a User-authored profile and reject it against
  24. # system filament/process pairs.
  25. "from": "system",
  26. # `type` is required by the CLI's --load-settings parser. Without
  27. # it the CLI silently exits with rc=-5 ("input preset file is
  28. # invalid"), causing every 3MF slice to fall back to embedded
  29. # settings. See preset_resolver._SLOT_TO_PROFILE_TYPE.
  30. "type": "machine",
  31. }
  32. def test_standard_emits_correct_type_per_slot():
  33. """Each slot maps to the right `type` value the CLI parser expects:
  34. printer → machine, process → process, filament → filament. Missing or
  35. wrong type causes the CLI to silently exit with rc=-5."""
  36. for slot, expected_type in (("printer", "machine"), ("process", "process"), ("filament", "filament")):
  37. out = preset_resolver._resolve_standard(
  38. PresetRef(source="standard", id="anything"),
  39. slot=slot,
  40. )
  41. assert json.loads(out)["type"] == expected_type, slot
  42. def test_standard_rejects_unknown_slot():
  43. with pytest.raises(HTTPException) as exc:
  44. preset_resolver._resolve_standard(PresetRef(source="standard", id="anything"), slot="bogus")
  45. assert exc.value.status_code == 400
  46. # --- local tier -----------------------------------------------------------
  47. @pytest.mark.asyncio
  48. async def test_local_returns_setting_blob():
  49. db = MagicMock()
  50. preset = MagicMock()
  51. preset.preset_type = "filament"
  52. preset.setting = '{"name": "PLA Basic"}'
  53. db.get = AsyncMock(return_value=preset)
  54. out = await preset_resolver._resolve_local(db, PresetRef(source="local", id="42"), slot="filament")
  55. assert out == '{"name": "PLA Basic"}'
  56. db.get.assert_awaited_once()
  57. @pytest.mark.asyncio
  58. async def test_local_rejects_non_integer_id():
  59. db = MagicMock()
  60. db.get = AsyncMock()
  61. with pytest.raises(HTTPException) as exc:
  62. await preset_resolver._resolve_local(db, PresetRef(source="local", id="not-a-number"), slot="filament")
  63. assert exc.value.status_code == 400
  64. db.get.assert_not_awaited()
  65. @pytest.mark.asyncio
  66. async def test_local_rejects_wrong_preset_type():
  67. """A `local` ref pointing at a process preset for the filament slot
  68. must fail — same guard the legacy slice path had."""
  69. db = MagicMock()
  70. preset = MagicMock()
  71. preset.preset_type = "process"
  72. db.get = AsyncMock(return_value=preset)
  73. with pytest.raises(HTTPException) as exc:
  74. await preset_resolver._resolve_local(db, PresetRef(source="local", id="1"), slot="filament")
  75. assert exc.value.status_code == 400
  76. assert "preset_type='filament'" in exc.value.detail
  77. # --- cloud tier -----------------------------------------------------------
  78. @pytest.mark.asyncio
  79. async def test_cloud_blocks_user_without_cloud_auth():
  80. """Defence-in-depth: a user holding LIBRARY_UPLOAD but not CLOUD_AUTH
  81. cannot slice with cloud presets even if their User row carries a
  82. leftover cloud_token from a previous permission state."""
  83. db = MagicMock()
  84. user = MagicMock()
  85. user.has_permission = MagicMock(return_value=False)
  86. with pytest.raises(HTTPException) as exc:
  87. await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  88. assert exc.value.status_code == 403
  89. @pytest.mark.asyncio
  90. async def test_cloud_400_when_no_token_stored():
  91. db = MagicMock()
  92. user = MagicMock()
  93. user.has_permission = MagicMock(return_value=True)
  94. with (
  95. patch.object(
  96. preset_resolver,
  97. "get_stored_token",
  98. AsyncMock(return_value=(None, None, None)),
  99. ),
  100. pytest.raises(HTTPException) as exc,
  101. ):
  102. await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  103. assert exc.value.status_code == 400
  104. assert "Sign in" in exc.value.detail
  105. @pytest.mark.asyncio
  106. async def test_cloud_unwraps_setting_envelope():
  107. """Bambu Cloud's `get_setting_detail` returns the preset wrapped under
  108. `.setting`; the sidecar wants the inner content, not the envelope."""
  109. db = MagicMock()
  110. user = MagicMock()
  111. user.has_permission = MagicMock(return_value=True)
  112. cloud_mock = MagicMock()
  113. cloud_mock.set_token = MagicMock()
  114. cloud_mock.get_setting_detail = AsyncMock(
  115. return_value={
  116. "setting_id": "PFU123",
  117. "name": "X1C Custom",
  118. "setting": {"name": "X1C Custom", "nozzle_diameter": [0.4]},
  119. }
  120. )
  121. cloud_mock.close = AsyncMock()
  122. with (
  123. patch.object(
  124. preset_resolver,
  125. "get_stored_token",
  126. AsyncMock(return_value=("tok", "e@x", "global")),
  127. ),
  128. patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
  129. ):
  130. out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  131. payload = json.loads(out)
  132. assert payload == {"name": "X1C Custom", "nozzle_diameter": [0.4]}
  133. cloud_mock.close.assert_awaited_once()
  134. @pytest.mark.asyncio
  135. async def test_cloud_falls_back_to_top_level_when_no_envelope():
  136. """If a cloud response doesn't nest under `.setting` (rare but seen on
  137. some endpoints), forward the whole payload rather than failing — the
  138. sidecar will reject malformed content cleanly."""
  139. db = MagicMock()
  140. user = MagicMock()
  141. user.has_permission = MagicMock(return_value=True)
  142. cloud_mock = MagicMock()
  143. cloud_mock.set_token = MagicMock()
  144. cloud_mock.get_setting_detail = AsyncMock(return_value={"name": "X1C Custom", "nozzle_diameter": [0.4]})
  145. cloud_mock.close = AsyncMock()
  146. with (
  147. patch.object(
  148. preset_resolver,
  149. "get_stored_token",
  150. AsyncMock(return_value=("tok", None, "global")),
  151. ),
  152. patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
  153. ):
  154. out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  155. payload = json.loads(out)
  156. assert "name" in payload
  157. @pytest.mark.asyncio
  158. async def test_cloud_auth_error_returns_401():
  159. db = MagicMock()
  160. user = MagicMock()
  161. user.has_permission = MagicMock(return_value=True)
  162. cloud_mock = MagicMock()
  163. cloud_mock.set_token = MagicMock()
  164. cloud_mock.get_setting_detail = AsyncMock(side_effect=preset_resolver.BambuCloudAuthError("expired"))
  165. cloud_mock.close = AsyncMock()
  166. with (
  167. patch.object(
  168. preset_resolver,
  169. "get_stored_token",
  170. AsyncMock(return_value=("tok", None, "global")),
  171. ),
  172. patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
  173. pytest.raises(HTTPException) as exc,
  174. ):
  175. await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  176. assert exc.value.status_code == 401
  177. # --- top-level dispatcher -------------------------------------------------
  178. @pytest.mark.asyncio
  179. async def test_resolve_preset_ref_dispatches_by_source():
  180. """The public entrypoint just routes to the right tier-specific
  181. helper. Verify each branch is selected correctly."""
  182. db = MagicMock()
  183. user = MagicMock()
  184. user.has_permission = MagicMock(return_value=True)
  185. preset = MagicMock()
  186. preset.preset_type = "printer"
  187. preset.setting = '{"local": true}'
  188. db.get = AsyncMock(return_value=preset)
  189. # local
  190. out = await preset_resolver.resolve_preset_ref(db, user, PresetRef(source="local", id="1"), slot="printer")
  191. assert out == '{"local": true}'
  192. # standard
  193. out = await preset_resolver.resolve_preset_ref(
  194. db, user, PresetRef(source="standard", id="Some Bundled Name"), slot="printer"
  195. )
  196. assert json.loads(out)["inherits"] == "Some Bundled Name"