test_spoolman_filament_patch.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. """Integration tests for PATCH /spoolman/inventory/filaments/{filament_id}.
  2. Covers:
  3. - Option A (keep_existing_spools=True): stamps old filament weight onto spools that currently inherit
  4. - Option B (keep_existing_spools=False): clears per-spool overrides in Spoolman so all inherit new value
  5. - Name-only patch: no get_all_spools call
  6. - Edge cases: disabled Spoolman, not found, invalid inputs
  7. - Spool-level tare priority in sync_spool_weight (spoolman_inventory) and update_spool_weight (spoolbuddy)
  8. """
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from httpx import AsyncClient
  12. SAMPLE_FILAMENT = {
  13. "id": 7,
  14. "name": "PLA Basic",
  15. "material": "PLA",
  16. "color_hex": "FF0000",
  17. "color_name": "Red",
  18. "weight": 1000,
  19. "spool_weight": 250.0,
  20. "vendor": {"id": 3, "name": "Bambu Lab"},
  21. }
  22. SAMPLE_SPOOL_WITH_FILAMENT_7 = {
  23. "id": 42,
  24. "spool_weight": None, # inheriting from filament
  25. "filament": {"id": 7, "name": "PLA Basic", "material": "PLA", "spool_weight": 250.0, "weight": 1000},
  26. "remaining_weight": 750.0,
  27. "used_weight": 250.0,
  28. "location": None,
  29. "comment": None,
  30. "archived": False,
  31. "extra": {},
  32. }
  33. SAMPLE_SPOOL_WITH_FILAMENT_99 = {
  34. "id": 55,
  35. "spool_weight": 196.0, # has its own spool-level override
  36. "filament": {"id": 99, "name": "PETG HF", "material": "PETG", "spool_weight": 196.0, "weight": 1000},
  37. "remaining_weight": 500.0,
  38. "used_weight": 500.0,
  39. "location": None,
  40. "comment": None,
  41. "archived": False,
  42. "extra": {},
  43. }
  44. SPOOL_WITH_NULL_FILAMENT = {
  45. "id": 77,
  46. "spool_weight": None,
  47. "filament": None,
  48. "remaining_weight": 100.0,
  49. "used_weight": 900.0,
  50. "location": None,
  51. "comment": None,
  52. "archived": False,
  53. "extra": {},
  54. }
  55. SAMPLE_SPOOL_7_WITH_OVERRIDE = {
  56. "id": 43,
  57. "spool_weight": 300.0, # has its own spool-level override
  58. "filament": {"id": 7, "name": "PLA Basic", "material": "PLA", "spool_weight": 250.0, "weight": 1000},
  59. "remaining_weight": 700.0,
  60. "used_weight": 300.0,
  61. "location": None,
  62. "comment": None,
  63. "archived": False,
  64. "extra": {},
  65. }
  66. @pytest.fixture
  67. async def spoolman_settings(db_session):
  68. from backend.app.models.settings import Settings
  69. db_session.add(Settings(key="spoolman_enabled", value="true"))
  70. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  71. await db_session.commit()
  72. def make_mock_client(filament=None, all_spools=None, patched_filament=None):
  73. mock_client = MagicMock()
  74. mock_client.base_url = "http://localhost:7912"
  75. mock_client.get_filament = AsyncMock(return_value=filament or SAMPLE_FILAMENT)
  76. mock_client.patch_filament = AsyncMock(return_value=patched_filament or SAMPLE_FILAMENT)
  77. mock_client.get_all_spools = AsyncMock(
  78. return_value=all_spools if all_spools is not None else [SAMPLE_SPOOL_WITH_FILAMENT_7]
  79. )
  80. mock_client.update_spool_full = AsyncMock(return_value={})
  81. return mock_client
  82. # ---------------------------------------------------------------------------
  83. # PATCH /filaments/{id} — core scenarios
  84. # ---------------------------------------------------------------------------
  85. class TestPatchFilamentOptionB:
  86. @pytest.mark.asyncio
  87. @pytest.mark.integration
  88. async def test_option_b_stamps_new_weight_on_all_affected_spools(
  89. self, async_client: AsyncClient, spoolman_settings
  90. ):
  91. """Option B: ALL affected spools (inheriting and overridden alike) get the new weight stamped."""
  92. mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7, SAMPLE_SPOOL_7_WITH_OVERRIDE])
  93. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  94. response = await async_client.patch(
  95. "/api/v1/spoolman/inventory/filaments/7",
  96. json={"spool_weight": 196.0, "keep_existing_spools": False},
  97. )
  98. assert response.status_code == 200
  99. mock_client.patch_filament.assert_called_once_with(7, {"spool_weight": 196.0})
  100. assert mock_client.update_spool_full.call_count == 2
  101. calls = {c.kwargs["spool_id"]: c.kwargs["spool_weight"] for c in mock_client.update_spool_full.call_args_list}
  102. assert calls[42] == pytest.approx(196.0)
  103. assert calls[43] == pytest.approx(196.0)
  104. @pytest.mark.asyncio
  105. @pytest.mark.integration
  106. async def test_option_b_stamps_inheriting_spool_with_new_weight(self, async_client: AsyncClient, spoolman_settings):
  107. """Option B: a spool inheriting (spool_weight=None) gets the new weight explicitly stamped."""
  108. mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7])
  109. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  110. response = await async_client.patch(
  111. "/api/v1/spoolman/inventory/filaments/7",
  112. json={"spool_weight": 196.0, "keep_existing_spools": False},
  113. )
  114. assert response.status_code == 200
  115. mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(196.0))
  116. @pytest.mark.asyncio
  117. @pytest.mark.integration
  118. async def test_option_b_only_stamps_affected_filament_spools(self, async_client: AsyncClient, spoolman_settings):
  119. """Option B for filament 7 must not touch spools belonging to other filament types."""
  120. mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7, SAMPLE_SPOOL_WITH_FILAMENT_99])
  121. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  122. response = await async_client.patch(
  123. "/api/v1/spoolman/inventory/filaments/7",
  124. json={"spool_weight": 196.0, "keep_existing_spools": False},
  125. )
  126. assert response.status_code == 200
  127. # Only spool 42 (filament 7) should be stamped; spool 55 (filament 99) must not be touched
  128. mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(196.0))
  129. class TestPatchFilamentOptionA:
  130. @pytest.mark.asyncio
  131. @pytest.mark.integration
  132. async def test_option_a_stamps_old_weight_on_inheriting_spools(self, async_client: AsyncClient, spoolman_settings):
  133. """Option A: spools inheriting from filament (spool_weight=None) get old weight stamped on them."""
  134. mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7])
  135. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  136. response = await async_client.patch(
  137. "/api/v1/spoolman/inventory/filaments/7",
  138. json={"spool_weight": 196.0, "keep_existing_spools": True},
  139. )
  140. assert response.status_code == 200
  141. # old_weight = SAMPLE_FILAMENT["spool_weight"] = 250.0
  142. mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(250.0))
  143. @pytest.mark.asyncio
  144. @pytest.mark.integration
  145. async def test_option_a_does_not_patch_spools_with_existing_override(
  146. self, async_client: AsyncClient, spoolman_settings
  147. ):
  148. """Option A: spools already having their own spool_weight are left unchanged."""
  149. mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_7_WITH_OVERRIDE])
  150. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  151. response = await async_client.patch(
  152. "/api/v1/spoolman/inventory/filaments/7",
  153. json={"spool_weight": 196.0, "keep_existing_spools": True},
  154. )
  155. assert response.status_code == 200
  156. mock_client.update_spool_full.assert_not_called()
  157. @pytest.mark.asyncio
  158. @pytest.mark.integration
  159. async def test_option_a_mixed_spools_stamps_only_inheriting(self, async_client: AsyncClient, spoolman_settings):
  160. """Option A: only inheriting spools (spool_weight=None) get old weight; overridden spools are skipped."""
  161. mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7, SAMPLE_SPOOL_7_WITH_OVERRIDE])
  162. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  163. response = await async_client.patch(
  164. "/api/v1/spoolman/inventory/filaments/7",
  165. json={"spool_weight": 196.0, "keep_existing_spools": True},
  166. )
  167. assert response.status_code == 200
  168. # Only spool 42 (inheriting) should be stamped; spool 43 (has override) must not be touched
  169. mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(250.0))
  170. @pytest.mark.asyncio
  171. @pytest.mark.integration
  172. async def test_option_a_zero_spools_no_error(self, async_client: AsyncClient, spoolman_settings):
  173. """Option A with zero spools for this filament: no error, no Spoolman calls."""
  174. mock_client = make_mock_client(all_spools=[])
  175. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  176. response = await async_client.patch(
  177. "/api/v1/spoolman/inventory/filaments/7",
  178. json={"spool_weight": 196.0, "keep_existing_spools": True},
  179. )
  180. assert response.status_code == 200
  181. mock_client.update_spool_full.assert_not_called()
  182. @pytest.mark.asyncio
  183. @pytest.mark.integration
  184. async def test_option_a_filament_no_old_weight_skips_stamping(self, async_client: AsyncClient, spoolman_settings):
  185. """Option A: if the filament has no old spool_weight, no stamping occurs."""
  186. mock_client = make_mock_client(filament={**SAMPLE_FILAMENT, "spool_weight": None})
  187. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  188. response = await async_client.patch(
  189. "/api/v1/spoolman/inventory/filaments/7",
  190. json={"spool_weight": 196.0, "keep_existing_spools": True},
  191. )
  192. assert response.status_code == 200
  193. mock_client.update_spool_full.assert_not_called()
  194. class TestPatchFilamentNameOnly:
  195. @pytest.mark.asyncio
  196. @pytest.mark.integration
  197. async def test_name_only_patch_no_get_all_spools(self, async_client: AsyncClient, db_session, spoolman_settings):
  198. """Patching name only must not call get_all_spools."""
  199. mock_client = make_mock_client()
  200. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  201. response = await async_client.patch(
  202. "/api/v1/spoolman/inventory/filaments/7",
  203. json={"name": "PLA Basic Renamed"},
  204. )
  205. assert response.status_code == 200
  206. mock_client.patch_filament.assert_called_once_with(7, {"name": "PLA Basic Renamed"})
  207. mock_client.get_all_spools.assert_not_called()
  208. class TestPatchFilamentEdgeCases:
  209. @pytest.mark.asyncio
  210. @pytest.mark.integration
  211. async def test_null_filament_on_spool_skipped(self, async_client: AsyncClient, db_session, spoolman_settings):
  212. """Spools with filament=null are skipped without error."""
  213. mock_client = make_mock_client(all_spools=[SPOOL_WITH_NULL_FILAMENT, SAMPLE_SPOOL_WITH_FILAMENT_7])
  214. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  215. response = await async_client.patch(
  216. "/api/v1/spoolman/inventory/filaments/7",
  217. json={"spool_weight": 196.0},
  218. )
  219. assert response.status_code == 200
  220. @pytest.mark.asyncio
  221. @pytest.mark.integration
  222. async def test_spool_weight_zero_is_valid(self, async_client: AsyncClient, db_session, spoolman_settings):
  223. """spool_weight=0 is valid (0g tare weight is legitimate)."""
  224. mock_client = make_mock_client()
  225. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  226. response = await async_client.patch(
  227. "/api/v1/spoolman/inventory/filaments/7",
  228. json={"spool_weight": 0},
  229. )
  230. assert response.status_code == 200
  231. mock_client.patch_filament.assert_called_once_with(7, {"spool_weight": 0})
  232. @pytest.mark.asyncio
  233. @pytest.mark.integration
  234. async def test_spool_weight_null_removes_weight(self, async_client: AsyncClient, db_session, spoolman_settings):
  235. """spool_weight=null is forwarded to Spoolman as None."""
  236. mock_client = make_mock_client()
  237. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  238. response = await async_client.patch(
  239. "/api/v1/spoolman/inventory/filaments/7",
  240. json={"spool_weight": None},
  241. )
  242. assert response.status_code == 200
  243. mock_client.patch_filament.assert_called_once_with(7, {"spool_weight": None})
  244. class TestPatchFilamentErrors:
  245. @pytest.mark.asyncio
  246. @pytest.mark.integration
  247. async def test_disabled_returns_400(self, async_client: AsyncClient, db_session):
  248. """When Spoolman is disabled, PATCH /filaments/{id} returns 400."""
  249. response = await async_client.patch(
  250. "/api/v1/spoolman/inventory/filaments/7",
  251. json={"spool_weight": 196.0},
  252. )
  253. assert response.status_code == 400
  254. @pytest.mark.asyncio
  255. @pytest.mark.integration
  256. async def test_not_found_returns_404(self, async_client: AsyncClient, db_session, spoolman_settings):
  257. """When get_filament raises SpoolmanNotFoundError, endpoint returns 404."""
  258. from backend.app.services.spoolman import SpoolmanNotFoundError
  259. mock_client = make_mock_client()
  260. mock_client.get_filament = AsyncMock(side_effect=SpoolmanNotFoundError("not found"))
  261. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  262. response = await async_client.patch(
  263. "/api/v1/spoolman/inventory/filaments/7",
  264. json={"spool_weight": 196.0},
  265. )
  266. assert response.status_code == 404
  267. @pytest.mark.asyncio
  268. @pytest.mark.integration
  269. async def test_invalid_id_returns_422(self, async_client: AsyncClient, db_session, spoolman_settings):
  270. """filament_id=0 fails Path validation (gt=0) with 422."""
  271. response = await async_client.patch(
  272. "/api/v1/spoolman/inventory/filaments/0",
  273. json={"spool_weight": 196.0},
  274. )
  275. assert response.status_code == 422
  276. @pytest.mark.asyncio
  277. @pytest.mark.integration
  278. async def test_negative_spool_weight_returns_422(self, async_client: AsyncClient, db_session, spoolman_settings):
  279. """spool_weight=-1 fails Pydantic validation (ge=0.0) with 422."""
  280. mock_client = make_mock_client()
  281. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  282. response = await async_client.patch(
  283. "/api/v1/spoolman/inventory/filaments/7",
  284. json={"spool_weight": -1},
  285. )
  286. assert response.status_code == 422
  287. # ---------------------------------------------------------------------------
  288. # Spool-level tare priority in sync_spool_weight (spoolman_inventory)
  289. # ---------------------------------------------------------------------------
  290. class TestSyncSpoolWeightPriority:
  291. @pytest.mark.asyncio
  292. @pytest.mark.integration
  293. async def test_spool_level_spool_weight_takes_priority(self, async_client: AsyncClient, spoolman_settings):
  294. """sync_spool_weight uses spool.spool_weight over filament.spool_weight for tare."""
  295. spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 100.0}
  296. mock_client = make_mock_client()
  297. mock_client.get_spool = AsyncMock(return_value=spool_data)
  298. mock_client.update_spool_full = AsyncMock(return_value=spool_data)
  299. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  300. response = await async_client.patch(
  301. "/api/v1/spoolman/inventory/spools/42/weight",
  302. json={"weight_grams": 600.0},
  303. )
  304. assert response.status_code == 200
  305. # remaining = 600 - 100 (spool-level tare) = 500
  306. update_call = mock_client.update_spool_full.call_args
  307. assert update_call.kwargs["remaining_weight"] == pytest.approx(500.0)
  308. @pytest.mark.asyncio
  309. @pytest.mark.integration
  310. async def test_filament_spool_weight_used_as_fallback(self, async_client: AsyncClient, spoolman_settings):
  311. """sync_spool_weight falls back to filament.spool_weight when spool.spool_weight is None."""
  312. spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7} # spool_weight=None → filament fallback 250.0
  313. mock_client = make_mock_client()
  314. mock_client.get_spool = AsyncMock(return_value=spool_data)
  315. mock_client.update_spool_full = AsyncMock(return_value=spool_data)
  316. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  317. response = await async_client.patch(
  318. "/api/v1/spoolman/inventory/spools/42/weight",
  319. json={"weight_grams": 600.0},
  320. )
  321. assert response.status_code == 200
  322. # remaining = 600 - 250 (filament.spool_weight) = 350
  323. update_call = mock_client.update_spool_full.call_args
  324. assert update_call.kwargs["remaining_weight"] == pytest.approx(350.0)
  325. @pytest.mark.asyncio
  326. @pytest.mark.integration
  327. async def test_spool_level_zero_not_treated_as_missing(self, async_client: AsyncClient, spoolman_settings):
  328. """spool.spool_weight=0 is a valid 0g tare, not treated as missing."""
  329. spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 0}
  330. mock_client = make_mock_client()
  331. mock_client.get_spool = AsyncMock(return_value=spool_data)
  332. mock_client.update_spool_full = AsyncMock(return_value=spool_data)
  333. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  334. response = await async_client.patch(
  335. "/api/v1/spoolman/inventory/spools/42/weight",
  336. json={"weight_grams": 600.0},
  337. )
  338. assert response.status_code == 200
  339. # remaining = 600 - 0 = 600 (not 600 - 250 fallback)
  340. update_call = mock_client.update_spool_full.call_args
  341. assert update_call.kwargs["remaining_weight"] == pytest.approx(600.0)
  342. @pytest.mark.asyncio
  343. @pytest.mark.integration
  344. async def test_both_levels_none_uses_250g_fallback(self, async_client: AsyncClient, spoolman_settings):
  345. """When both spool.spool_weight and filament.spool_weight are None, 250g fallback is used."""
  346. spool_data = {
  347. **SAMPLE_SPOOL_WITH_FILAMENT_7,
  348. "spool_weight": None,
  349. "filament": {**SAMPLE_SPOOL_WITH_FILAMENT_7["filament"], "spool_weight": None},
  350. }
  351. mock_client = make_mock_client()
  352. mock_client.get_spool = AsyncMock(return_value=spool_data)
  353. mock_client.update_spool_full = AsyncMock(return_value=spool_data)
  354. with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
  355. response = await async_client.patch(
  356. "/api/v1/spoolman/inventory/spools/42/weight",
  357. json={"weight_grams": 600.0},
  358. )
  359. assert response.status_code == 200
  360. # remaining = 600 - 250 (fallback) = 350
  361. update_call = mock_client.update_spool_full.call_args
  362. assert update_call.kwargs["remaining_weight"] == pytest.approx(350.0)
  363. # ---------------------------------------------------------------------------
  364. # Spool-level tare priority in update_spool_weight (spoolbuddy.py scale endpoint)
  365. # ---------------------------------------------------------------------------
  366. class TestUpdateSpoolWeightPriority:
  367. @pytest.mark.asyncio
  368. @pytest.mark.integration
  369. async def test_spool_level_spool_weight_takes_priority(self, async_client: AsyncClient, spoolman_settings):
  370. """update_spool_weight uses spool.spool_weight over filament.spool_weight for tare."""
  371. spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 100.0}
  372. mock_client = MagicMock()
  373. mock_client.get_spool = AsyncMock(return_value=spool_data)
  374. mock_client.update_spool = AsyncMock(return_value=None)
  375. with patch(
  376. "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
  377. AsyncMock(return_value=mock_client),
  378. ):
  379. response = await async_client.post(
  380. "/api/v1/spoolbuddy/scale/update-spool-weight",
  381. json={"spool_id": 42, "weight_grams": 600.0},
  382. )
  383. assert response.status_code == 200
  384. # remaining = 600 - 100 (spool-level tare) = 500
  385. mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(500.0))
  386. @pytest.mark.asyncio
  387. @pytest.mark.integration
  388. async def test_filament_spool_weight_used_as_fallback(self, async_client: AsyncClient, spoolman_settings):
  389. """update_spool_weight falls back to filament.spool_weight when spool.spool_weight is None."""
  390. spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7} # spool_weight=None → filament fallback 250.0
  391. mock_client = MagicMock()
  392. mock_client.get_spool = AsyncMock(return_value=spool_data)
  393. mock_client.update_spool = AsyncMock(return_value=None)
  394. with patch(
  395. "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
  396. AsyncMock(return_value=mock_client),
  397. ):
  398. response = await async_client.post(
  399. "/api/v1/spoolbuddy/scale/update-spool-weight",
  400. json={"spool_id": 42, "weight_grams": 600.0},
  401. )
  402. assert response.status_code == 200
  403. # remaining = 600 - 250 (filament.spool_weight) = 350
  404. mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(350.0))
  405. @pytest.mark.asyncio
  406. @pytest.mark.integration
  407. async def test_spool_level_zero_not_treated_as_missing(self, async_client: AsyncClient, spoolman_settings):
  408. """spool.spool_weight=0 is a valid 0g tare, not treated as missing."""
  409. spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 0}
  410. mock_client = MagicMock()
  411. mock_client.get_spool = AsyncMock(return_value=spool_data)
  412. mock_client.update_spool = AsyncMock(return_value=None)
  413. with patch(
  414. "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
  415. AsyncMock(return_value=mock_client),
  416. ):
  417. response = await async_client.post(
  418. "/api/v1/spoolbuddy/scale/update-spool-weight",
  419. json={"spool_id": 42, "weight_grams": 600.0},
  420. )
  421. assert response.status_code == 200
  422. # remaining = 600 - 0 = 600 (not 600 - 250 fallback)
  423. mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(600.0))
  424. @pytest.mark.asyncio
  425. @pytest.mark.integration
  426. async def test_both_levels_none_uses_250g_fallback_and_warns(self, async_client: AsyncClient, spoolman_settings):
  427. """When both spool.spool_weight and filament.spool_weight are None, 250g fallback is used with a warning."""
  428. spool_data = {
  429. **SAMPLE_SPOOL_WITH_FILAMENT_7,
  430. "spool_weight": None,
  431. "filament": {**SAMPLE_SPOOL_WITH_FILAMENT_7["filament"], "spool_weight": None},
  432. }
  433. mock_client = MagicMock()
  434. mock_client.get_spool = AsyncMock(return_value=spool_data)
  435. mock_client.update_spool = AsyncMock(return_value=None)
  436. with patch(
  437. "backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
  438. AsyncMock(return_value=mock_client),
  439. ):
  440. response = await async_client.post(
  441. "/api/v1/spoolbuddy/scale/update-spool-weight",
  442. json={"spool_id": 42, "weight_grams": 600.0},
  443. )
  444. assert response.status_code == 200
  445. # remaining = 600 - 250 (fallback) = 350
  446. mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(350.0))
  447. assert response.json().get("warnings")