test_spoolman_ams_sync.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. """Integration tests for POST /api/v1/spoolman/inventory/sync-ams-weights.
  2. Covers:
  3. - happy path: synced count incremented, update_spool_full called with correct weight
  4. - printer offline: assignment skipped
  5. - spool missing from Spoolman: assignment skipped
  6. - invalid remain value: assignment skipped
  7. """
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from httpx import AsyncClient
  11. SAMPLE_SPOOL = {
  12. "id": 42,
  13. "filament": {
  14. "id": 1,
  15. "name": "PLA Basic",
  16. "material": "PLA",
  17. "weight": 1000,
  18. "color_hex": "FF0000",
  19. "vendor": {"id": 1, "name": "BrandX"},
  20. },
  21. "remaining_weight": 800.0,
  22. "used_weight": 200.0,
  23. "location": None,
  24. "comment": None,
  25. "first_used": None,
  26. "last_used": None,
  27. "registered": "2024-01-01T00:00:00+00:00",
  28. "archived": False,
  29. "price": None,
  30. "extra": {},
  31. }
  32. @pytest.fixture
  33. async def sync_settings(db_session):
  34. from backend.app.models.settings import Settings
  35. db_session.add(Settings(key="spoolman_enabled", value="true"))
  36. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  37. await db_session.commit()
  38. @pytest.fixture
  39. async def test_printer(db_session):
  40. from backend.app.models.printer import Printer
  41. printer = Printer(
  42. name="Sync Printer",
  43. serial_number="SYNCTEST001",
  44. ip_address="192.168.1.50",
  45. access_code="12345678",
  46. )
  47. db_session.add(printer)
  48. await db_session.commit()
  49. await db_session.refresh(printer)
  50. return printer
  51. @pytest.fixture
  52. async def slot_assignment(db_session, test_printer):
  53. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  54. assignment = SpoolmanSlotAssignment(
  55. printer_id=test_printer.id,
  56. ams_id=0,
  57. tray_id=0,
  58. spoolman_spool_id=42,
  59. )
  60. db_session.add(assignment)
  61. await db_session.commit()
  62. return assignment
  63. def _make_spoolman_client(spools=None):
  64. client = MagicMock()
  65. client.base_url = "http://localhost:7912"
  66. client.health_check = AsyncMock(return_value=True)
  67. client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOL] if spools is None else spools)
  68. client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOL)
  69. return client
  70. def _make_printer_state(remain=75):
  71. state = MagicMock()
  72. state.raw_data = {
  73. "ams": [
  74. {
  75. "id": 0,
  76. "tray": [{"id": 0, "remain": remain}],
  77. }
  78. ]
  79. }
  80. return state
  81. class TestSyncSpoolmanAmsWeights:
  82. @pytest.mark.asyncio
  83. @pytest.mark.integration
  84. @pytest.mark.parametrize("gcode_state", ["PREPARE", "RUNNING", "PAUSE"])
  85. async def test_a_printing_printers_spool_is_left_alone(
  86. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment, gcode_state
  87. ):
  88. """Its usage is charged when the print completes; a remaining weight
  89. set from remain% halfway through would be charged again on top."""
  90. spoolman_client = _make_spoolman_client()
  91. printer_state = _make_printer_state(remain=75)
  92. printer_state.state = gcode_state
  93. with (
  94. patch(
  95. "backend.app.api.routes.spoolman_inventory._get_client",
  96. AsyncMock(return_value=spoolman_client),
  97. ),
  98. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  99. ):
  100. pm_mock.get_status = MagicMock(return_value=printer_state)
  101. response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  102. assert response.status_code == 200
  103. assert (response.json()["synced"], response.json()["printing"]) == (0, 1)
  104. spoolman_client.update_spool_full.assert_not_called()
  105. @pytest.mark.asyncio
  106. @pytest.mark.integration
  107. async def test_happy_path_synced_count(
  108. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  109. ):
  110. """POST /sync-ams-weights syncs one spool, returns synced=1, skipped=0."""
  111. spoolman_client = _make_spoolman_client()
  112. printer_state = _make_printer_state(remain=75)
  113. with (
  114. patch(
  115. "backend.app.api.routes.spoolman_inventory._get_client",
  116. AsyncMock(return_value=spoolman_client),
  117. ),
  118. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  119. ):
  120. pm_mock.get_status = MagicMock(return_value=printer_state)
  121. response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  122. assert response.status_code == 200
  123. body = response.json()
  124. assert body["synced"] == 1
  125. assert body["skipped"] == 0
  126. @pytest.mark.asyncio
  127. @pytest.mark.integration
  128. async def test_weight_calculated_correctly(
  129. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  130. ):
  131. """Remaining weight = round(label_weight * remain / 100, 1)."""
  132. spoolman_client = _make_spoolman_client()
  133. printer_state = _make_printer_state(remain=75)
  134. with (
  135. patch(
  136. "backend.app.api.routes.spoolman_inventory._get_client",
  137. AsyncMock(return_value=spoolman_client),
  138. ),
  139. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  140. ):
  141. pm_mock.get_status = MagicMock(return_value=printer_state)
  142. await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  143. spoolman_client.update_spool_full.assert_called_once_with(42, remaining_weight=750.0)
  144. @pytest.mark.asyncio
  145. @pytest.mark.integration
  146. async def test_printer_offline_skipped(
  147. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  148. ):
  149. """Spools whose printer is offline are counted as skipped, not synced."""
  150. spoolman_client = _make_spoolman_client()
  151. with (
  152. patch(
  153. "backend.app.api.routes.spoolman_inventory._get_client",
  154. AsyncMock(return_value=spoolman_client),
  155. ),
  156. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  157. ):
  158. pm_mock.get_status = MagicMock(return_value=None)
  159. response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  160. assert response.status_code == 200
  161. body = response.json()
  162. assert body["synced"] == 0
  163. assert body["skipped"] == 1
  164. spoolman_client.update_spool_full.assert_not_called()
  165. @pytest.mark.asyncio
  166. @pytest.mark.integration
  167. async def test_update_spool_full_error_counted_as_skipped(
  168. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  169. ):
  170. """update_spool_full raising HTTPException counts as skipped, not synced."""
  171. from fastapi import HTTPException
  172. spoolman_client = _make_spoolman_client()
  173. spoolman_client.update_spool_full = AsyncMock(side_effect=HTTPException(status_code=503))
  174. printer_state = _make_printer_state(remain=50)
  175. with (
  176. patch(
  177. "backend.app.api.routes.spoolman_inventory._get_client",
  178. AsyncMock(return_value=spoolman_client),
  179. ),
  180. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  181. ):
  182. pm_mock.get_status = MagicMock(return_value=printer_state)
  183. response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  184. assert response.status_code == 200
  185. body = response.json()
  186. assert body["synced"] == 0
  187. assert body["skipped"] == 1
  188. @pytest.mark.asyncio
  189. @pytest.mark.integration
  190. async def test_invalid_remain_value_skipped(
  191. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  192. ):
  193. """Non-numeric remain value in AMS data is counted as skipped."""
  194. spoolman_client = _make_spoolman_client()
  195. printer_state = _make_printer_state(remain="notanumber")
  196. with (
  197. patch(
  198. "backend.app.api.routes.spoolman_inventory._get_client",
  199. AsyncMock(return_value=spoolman_client),
  200. ),
  201. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  202. ):
  203. pm_mock.get_status = MagicMock(return_value=printer_state)
  204. response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  205. assert response.status_code == 200
  206. body = response.json()
  207. assert body["synced"] == 0
  208. assert body["skipped"] == 1
  209. spoolman_client.update_spool_full.assert_not_called()
  210. @pytest.mark.asyncio
  211. @pytest.mark.integration
  212. async def test_spool_missing_from_spoolman_skipped(
  213. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  214. ):
  215. """Spools not present in Spoolman are counted as skipped."""
  216. spoolman_client = _make_spoolman_client(spools=[]) # empty — spool 42 is gone
  217. printer_state = _make_printer_state(remain=50)
  218. with (
  219. patch(
  220. "backend.app.api.routes.spoolman_inventory._get_client",
  221. AsyncMock(return_value=spoolman_client),
  222. ),
  223. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  224. ):
  225. pm_mock.get_status = MagicMock(return_value=printer_state)
  226. response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  227. assert response.status_code == 200
  228. body = response.json()
  229. assert body["synced"] == 0
  230. assert body["skipped"] == 1
  231. # ---------------------------------------------------------------------------
  232. # F6: AMS sync edge cases
  233. # ---------------------------------------------------------------------------
  234. class TestSyncAmsEdgeCases:
  235. """F6: Edge cases for remain values and AMS data format."""
  236. @pytest.mark.asyncio
  237. @pytest.mark.integration
  238. async def test_null_remain_skipped(self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment):
  239. """remain=null → tray skipped, synced=0 skipped=1."""
  240. spoolman_client = _make_spoolman_client()
  241. printer_state = _make_printer_state(remain=None)
  242. with (
  243. patch(
  244. "backend.app.api.routes.spoolman_inventory._get_client",
  245. AsyncMock(return_value=spoolman_client),
  246. ),
  247. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  248. ):
  249. pm_mock.get_status = MagicMock(return_value=printer_state)
  250. resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  251. assert resp.status_code == 200
  252. body = resp.json()
  253. assert body["synced"] == 0
  254. assert body["skipped"] == 1
  255. spoolman_client.update_spool_full.assert_not_called()
  256. @pytest.mark.asyncio
  257. @pytest.mark.integration
  258. async def test_remain_above_100_skipped(
  259. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  260. ):
  261. """remain=101 → out-of-range, tray skipped."""
  262. spoolman_client = _make_spoolman_client()
  263. printer_state = _make_printer_state(remain=101)
  264. with (
  265. patch(
  266. "backend.app.api.routes.spoolman_inventory._get_client",
  267. AsyncMock(return_value=spoolman_client),
  268. ),
  269. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  270. ):
  271. pm_mock.get_status = MagicMock(return_value=printer_state)
  272. resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  273. assert resp.status_code == 200
  274. body = resp.json()
  275. assert body["synced"] == 0
  276. assert body["skipped"] == 1
  277. @pytest.mark.asyncio
  278. @pytest.mark.integration
  279. async def test_negative_remain_skipped(
  280. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  281. ):
  282. """remain=-1 → out-of-range, tray skipped."""
  283. spoolman_client = _make_spoolman_client()
  284. printer_state = _make_printer_state(remain=-1)
  285. with (
  286. patch(
  287. "backend.app.api.routes.spoolman_inventory._get_client",
  288. AsyncMock(return_value=spoolman_client),
  289. ),
  290. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  291. ):
  292. pm_mock.get_status = MagicMock(return_value=printer_state)
  293. resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  294. assert resp.status_code == 200
  295. body = resp.json()
  296. assert body["synced"] == 0
  297. assert body["skipped"] == 1
  298. @pytest.mark.asyncio
  299. @pytest.mark.integration
  300. async def test_dict_wrapped_ams_format(
  301. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  302. ):
  303. """Double-nested ams format: raw_data['ams'] is a dict with 'ams' key → list."""
  304. spoolman_client = _make_spoolman_client()
  305. state = MagicMock()
  306. state.raw_data = {
  307. "ams": {
  308. "ams": [
  309. {
  310. "id": 0,
  311. "tray": [{"id": 0, "remain": 75}],
  312. }
  313. ]
  314. }
  315. }
  316. with (
  317. patch(
  318. "backend.app.api.routes.spoolman_inventory._get_client",
  319. AsyncMock(return_value=spoolman_client),
  320. ),
  321. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  322. ):
  323. pm_mock.get_status = MagicMock(return_value=state)
  324. resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  325. assert resp.status_code == 200
  326. body = resp.json()
  327. assert body["synced"] == 1
  328. assert body["skipped"] == 0