test_spoolman_ams_sync.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  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. async def test_happy_path_synced_count(
  85. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  86. ):
  87. """POST /sync-ams-weights syncs one spool, returns synced=1, skipped=0."""
  88. spoolman_client = _make_spoolman_client()
  89. printer_state = _make_printer_state(remain=75)
  90. with (
  91. patch(
  92. "backend.app.api.routes.spoolman_inventory._get_client",
  93. AsyncMock(return_value=spoolman_client),
  94. ),
  95. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  96. ):
  97. pm_mock.get_status = MagicMock(return_value=printer_state)
  98. response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  99. assert response.status_code == 200
  100. body = response.json()
  101. assert body["synced"] == 1
  102. assert body["skipped"] == 0
  103. @pytest.mark.asyncio
  104. @pytest.mark.integration
  105. async def test_weight_calculated_correctly(
  106. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  107. ):
  108. """Remaining weight = round(label_weight * remain / 100, 1)."""
  109. spoolman_client = _make_spoolman_client()
  110. printer_state = _make_printer_state(remain=75)
  111. with (
  112. patch(
  113. "backend.app.api.routes.spoolman_inventory._get_client",
  114. AsyncMock(return_value=spoolman_client),
  115. ),
  116. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  117. ):
  118. pm_mock.get_status = MagicMock(return_value=printer_state)
  119. await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  120. spoolman_client.update_spool_full.assert_called_once_with(42, remaining_weight=750.0)
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_printer_offline_skipped(
  124. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  125. ):
  126. """Spools whose printer is offline are counted as skipped, not synced."""
  127. spoolman_client = _make_spoolman_client()
  128. with (
  129. patch(
  130. "backend.app.api.routes.spoolman_inventory._get_client",
  131. AsyncMock(return_value=spoolman_client),
  132. ),
  133. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  134. ):
  135. pm_mock.get_status = MagicMock(return_value=None)
  136. response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  137. assert response.status_code == 200
  138. body = response.json()
  139. assert body["synced"] == 0
  140. assert body["skipped"] == 1
  141. spoolman_client.update_spool_full.assert_not_called()
  142. @pytest.mark.asyncio
  143. @pytest.mark.integration
  144. async def test_update_spool_full_error_counted_as_skipped(
  145. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  146. ):
  147. """update_spool_full raising HTTPException counts as skipped, not synced."""
  148. from fastapi import HTTPException
  149. spoolman_client = _make_spoolman_client()
  150. spoolman_client.update_spool_full = AsyncMock(side_effect=HTTPException(status_code=503))
  151. printer_state = _make_printer_state(remain=50)
  152. with (
  153. patch(
  154. "backend.app.api.routes.spoolman_inventory._get_client",
  155. AsyncMock(return_value=spoolman_client),
  156. ),
  157. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  158. ):
  159. pm_mock.get_status = MagicMock(return_value=printer_state)
  160. response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  161. assert response.status_code == 200
  162. body = response.json()
  163. assert body["synced"] == 0
  164. assert body["skipped"] == 1
  165. @pytest.mark.asyncio
  166. @pytest.mark.integration
  167. async def test_invalid_remain_value_skipped(
  168. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  169. ):
  170. """Non-numeric remain value in AMS data is counted as skipped."""
  171. spoolman_client = _make_spoolman_client()
  172. printer_state = _make_printer_state(remain="notanumber")
  173. with (
  174. patch(
  175. "backend.app.api.routes.spoolman_inventory._get_client",
  176. AsyncMock(return_value=spoolman_client),
  177. ),
  178. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  179. ):
  180. pm_mock.get_status = MagicMock(return_value=printer_state)
  181. response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  182. assert response.status_code == 200
  183. body = response.json()
  184. assert body["synced"] == 0
  185. assert body["skipped"] == 1
  186. spoolman_client.update_spool_full.assert_not_called()
  187. @pytest.mark.asyncio
  188. @pytest.mark.integration
  189. async def test_spool_missing_from_spoolman_skipped(
  190. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  191. ):
  192. """Spools not present in Spoolman are counted as skipped."""
  193. spoolman_client = _make_spoolman_client(spools=[]) # empty — spool 42 is gone
  194. printer_state = _make_printer_state(remain=50)
  195. with (
  196. patch(
  197. "backend.app.api.routes.spoolman_inventory._get_client",
  198. AsyncMock(return_value=spoolman_client),
  199. ),
  200. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  201. ):
  202. pm_mock.get_status = MagicMock(return_value=printer_state)
  203. response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  204. assert response.status_code == 200
  205. body = response.json()
  206. assert body["synced"] == 0
  207. assert body["skipped"] == 1
  208. # ---------------------------------------------------------------------------
  209. # F6: AMS sync edge cases
  210. # ---------------------------------------------------------------------------
  211. class TestSyncAmsEdgeCases:
  212. """F6: Edge cases for remain values and AMS data format."""
  213. @pytest.mark.asyncio
  214. @pytest.mark.integration
  215. async def test_null_remain_skipped(self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment):
  216. """remain=null → tray skipped, synced=0 skipped=1."""
  217. spoolman_client = _make_spoolman_client()
  218. printer_state = _make_printer_state(remain=None)
  219. with (
  220. patch(
  221. "backend.app.api.routes.spoolman_inventory._get_client",
  222. AsyncMock(return_value=spoolman_client),
  223. ),
  224. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  225. ):
  226. pm_mock.get_status = MagicMock(return_value=printer_state)
  227. resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  228. assert resp.status_code == 200
  229. body = resp.json()
  230. assert body["synced"] == 0
  231. assert body["skipped"] == 1
  232. spoolman_client.update_spool_full.assert_not_called()
  233. @pytest.mark.asyncio
  234. @pytest.mark.integration
  235. async def test_remain_above_100_skipped(
  236. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  237. ):
  238. """remain=101 → out-of-range, tray skipped."""
  239. spoolman_client = _make_spoolman_client()
  240. printer_state = _make_printer_state(remain=101)
  241. with (
  242. patch(
  243. "backend.app.api.routes.spoolman_inventory._get_client",
  244. AsyncMock(return_value=spoolman_client),
  245. ),
  246. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  247. ):
  248. pm_mock.get_status = MagicMock(return_value=printer_state)
  249. resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  250. assert resp.status_code == 200
  251. body = resp.json()
  252. assert body["synced"] == 0
  253. assert body["skipped"] == 1
  254. @pytest.mark.asyncio
  255. @pytest.mark.integration
  256. async def test_negative_remain_skipped(
  257. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  258. ):
  259. """remain=-1 → out-of-range, tray skipped."""
  260. spoolman_client = _make_spoolman_client()
  261. printer_state = _make_printer_state(remain=-1)
  262. with (
  263. patch(
  264. "backend.app.api.routes.spoolman_inventory._get_client",
  265. AsyncMock(return_value=spoolman_client),
  266. ),
  267. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  268. ):
  269. pm_mock.get_status = MagicMock(return_value=printer_state)
  270. resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  271. assert resp.status_code == 200
  272. body = resp.json()
  273. assert body["synced"] == 0
  274. assert body["skipped"] == 1
  275. @pytest.mark.asyncio
  276. @pytest.mark.integration
  277. async def test_dict_wrapped_ams_format(
  278. self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
  279. ):
  280. """Double-nested ams format: raw_data['ams'] is a dict with 'ams' key → list."""
  281. spoolman_client = _make_spoolman_client()
  282. state = MagicMock()
  283. state.raw_data = {
  284. "ams": {
  285. "ams": [
  286. {
  287. "id": 0,
  288. "tray": [{"id": 0, "remain": 75}],
  289. }
  290. ]
  291. }
  292. }
  293. with (
  294. patch(
  295. "backend.app.api.routes.spoolman_inventory._get_client",
  296. AsyncMock(return_value=spoolman_client),
  297. ),
  298. patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
  299. ):
  300. pm_mock.get_status = MagicMock(return_value=state)
  301. resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
  302. assert resp.status_code == 200
  303. body = resp.json()
  304. assert body["synced"] == 1
  305. assert body["skipped"] == 0