test_preset_resolver.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  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. }
  27. def test_standard_rejects_unknown_slot():
  28. with pytest.raises(HTTPException) as exc:
  29. preset_resolver._resolve_standard(PresetRef(source="standard", id="anything"), slot="bogus")
  30. assert exc.value.status_code == 400
  31. # --- local tier -----------------------------------------------------------
  32. @pytest.mark.asyncio
  33. async def test_local_returns_setting_blob():
  34. db = MagicMock()
  35. preset = MagicMock()
  36. preset.preset_type = "filament"
  37. preset.setting = '{"name": "PLA Basic"}'
  38. db.get = AsyncMock(return_value=preset)
  39. out = await preset_resolver._resolve_local(db, PresetRef(source="local", id="42"), slot="filament")
  40. assert out == '{"name": "PLA Basic"}'
  41. db.get.assert_awaited_once()
  42. @pytest.mark.asyncio
  43. async def test_local_rejects_non_integer_id():
  44. db = MagicMock()
  45. db.get = AsyncMock()
  46. with pytest.raises(HTTPException) as exc:
  47. await preset_resolver._resolve_local(db, PresetRef(source="local", id="not-a-number"), slot="filament")
  48. assert exc.value.status_code == 400
  49. db.get.assert_not_awaited()
  50. @pytest.mark.asyncio
  51. async def test_local_rejects_wrong_preset_type():
  52. """A `local` ref pointing at a process preset for the filament slot
  53. must fail — same guard the legacy slice path had."""
  54. db = MagicMock()
  55. preset = MagicMock()
  56. preset.preset_type = "process"
  57. db.get = AsyncMock(return_value=preset)
  58. with pytest.raises(HTTPException) as exc:
  59. await preset_resolver._resolve_local(db, PresetRef(source="local", id="1"), slot="filament")
  60. assert exc.value.status_code == 400
  61. assert "preset_type='filament'" in exc.value.detail
  62. # --- cloud tier -----------------------------------------------------------
  63. @pytest.mark.asyncio
  64. async def test_cloud_blocks_user_without_cloud_auth():
  65. """Defence-in-depth: a user holding LIBRARY_UPLOAD but not CLOUD_AUTH
  66. cannot slice with cloud presets even if their User row carries a
  67. leftover cloud_token from a previous permission state."""
  68. db = MagicMock()
  69. user = MagicMock()
  70. user.has_permission = MagicMock(return_value=False)
  71. with pytest.raises(HTTPException) as exc:
  72. await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  73. assert exc.value.status_code == 403
  74. @pytest.mark.asyncio
  75. async def test_cloud_400_when_no_token_stored():
  76. db = MagicMock()
  77. user = MagicMock()
  78. user.has_permission = MagicMock(return_value=True)
  79. with (
  80. patch.object(
  81. preset_resolver,
  82. "get_stored_token",
  83. AsyncMock(return_value=(None, None, None)),
  84. ),
  85. pytest.raises(HTTPException) as exc,
  86. ):
  87. await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  88. assert exc.value.status_code == 400
  89. assert "Sign in" in exc.value.detail
  90. @pytest.mark.asyncio
  91. async def test_cloud_unwraps_setting_envelope():
  92. """Bambu Cloud's `get_setting_detail` returns the preset wrapped under
  93. `.setting`; the sidecar wants the inner content, not the envelope."""
  94. db = MagicMock()
  95. user = MagicMock()
  96. user.has_permission = MagicMock(return_value=True)
  97. cloud_mock = MagicMock()
  98. cloud_mock.set_token = MagicMock()
  99. cloud_mock.get_setting_detail = AsyncMock(
  100. return_value={
  101. "setting_id": "PFU123",
  102. "name": "X1C Custom",
  103. "setting": {"name": "X1C Custom", "nozzle_diameter": [0.4]},
  104. }
  105. )
  106. cloud_mock.close = AsyncMock()
  107. with (
  108. patch.object(
  109. preset_resolver,
  110. "get_stored_token",
  111. AsyncMock(return_value=("tok", "e@x", "global")),
  112. ),
  113. patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
  114. ):
  115. out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  116. payload = json.loads(out)
  117. assert payload == {"name": "X1C Custom", "nozzle_diameter": [0.4]}
  118. cloud_mock.close.assert_awaited_once()
  119. @pytest.mark.asyncio
  120. async def test_cloud_falls_back_to_top_level_when_no_envelope():
  121. """If a cloud response doesn't nest under `.setting` (rare but seen on
  122. some endpoints), forward the whole payload rather than failing — the
  123. sidecar will reject malformed content cleanly."""
  124. db = MagicMock()
  125. user = MagicMock()
  126. user.has_permission = MagicMock(return_value=True)
  127. cloud_mock = MagicMock()
  128. cloud_mock.set_token = MagicMock()
  129. cloud_mock.get_setting_detail = AsyncMock(return_value={"name": "X1C Custom", "nozzle_diameter": [0.4]})
  130. cloud_mock.close = AsyncMock()
  131. with (
  132. patch.object(
  133. preset_resolver,
  134. "get_stored_token",
  135. AsyncMock(return_value=("tok", None, "global")),
  136. ),
  137. patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
  138. ):
  139. out = await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  140. payload = json.loads(out)
  141. assert "name" in payload
  142. @pytest.mark.asyncio
  143. async def test_cloud_auth_error_returns_401():
  144. db = MagicMock()
  145. user = MagicMock()
  146. user.has_permission = MagicMock(return_value=True)
  147. cloud_mock = MagicMock()
  148. cloud_mock.set_token = MagicMock()
  149. cloud_mock.get_setting_detail = AsyncMock(side_effect=preset_resolver.BambuCloudAuthError("expired"))
  150. cloud_mock.close = AsyncMock()
  151. with (
  152. patch.object(
  153. preset_resolver,
  154. "get_stored_token",
  155. AsyncMock(return_value=("tok", None, "global")),
  156. ),
  157. patch.object(preset_resolver, "BambuCloudService", return_value=cloud_mock),
  158. pytest.raises(HTTPException) as exc,
  159. ):
  160. await preset_resolver._resolve_cloud(db, user, PresetRef(source="cloud", id="PFU123"), slot="printer")
  161. assert exc.value.status_code == 401
  162. # --- top-level dispatcher -------------------------------------------------
  163. @pytest.mark.asyncio
  164. async def test_resolve_preset_ref_dispatches_by_source():
  165. """The public entrypoint just routes to the right tier-specific
  166. helper. Verify each branch is selected correctly."""
  167. db = MagicMock()
  168. user = MagicMock()
  169. user.has_permission = MagicMock(return_value=True)
  170. preset = MagicMock()
  171. preset.preset_type = "printer"
  172. preset.setting = '{"local": true}'
  173. db.get = AsyncMock(return_value=preset)
  174. # local
  175. out = await preset_resolver.resolve_preset_ref(db, user, PresetRef(source="local", id="1"), slot="printer")
  176. assert out == '{"local": true}'
  177. # standard
  178. out = await preset_resolver.resolve_preset_ref(
  179. db, user, PresetRef(source="standard", id="Some Bundled Name"), slot="printer"
  180. )
  181. assert json.loads(out)["inherits"] == "Some Bundled Name"