test_spoolman_slot_assignments.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. """Integration tests for Spoolman slot-assignment endpoints.
  2. Tests for:
  3. POST /api/v1/spoolman/inventory/slot-assignments
  4. DELETE /api/v1/spoolman/inventory/slot-assignments/{spoolman_spool_id}
  5. GET /api/v1/spoolman/inventory/slot-assignments?printer_id=&ams_id=&tray_id=
  6. GET /api/v1/spoolman/inventory/slot-assignments/all[?printer_id=]
  7. Slot assignments are now stored in the local ``spoolman_slot_assignments`` table.
  8. Spoolman's ``spool.location`` field is NOT touched by any of these endpoints.
  9. """
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from httpx import AsyncClient
  13. from sqlalchemy import select
  14. SAMPLE_SPOOL = {
  15. "id": 10,
  16. "filament": {
  17. "id": 1,
  18. "name": "PLA Basic",
  19. "material": "PLA",
  20. "color_hex": "FF0000",
  21. "weight": 1000,
  22. "vendor": {"id": 1, "name": "Test Brand"},
  23. },
  24. "remaining_weight": 800.0,
  25. "used_weight": 200.0,
  26. "location": None,
  27. "comment": None,
  28. "first_used": None,
  29. "last_used": None,
  30. "registered": "2024-01-01T00:00:00+00:00",
  31. "archived": False,
  32. "price": None,
  33. "extra": {},
  34. }
  35. @pytest.fixture
  36. async def slot_settings(db_session):
  37. from backend.app.models.settings import Settings
  38. db_session.add(Settings(key="spoolman_enabled", value="true"))
  39. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  40. await db_session.commit()
  41. @pytest.fixture
  42. async def test_printer(db_session):
  43. from backend.app.models.printer import Printer
  44. printer = Printer(
  45. name="Test Printer",
  46. serial_number="SLOTTEST001",
  47. ip_address="192.168.1.100",
  48. access_code="12345678",
  49. )
  50. db_session.add(printer)
  51. await db_session.commit()
  52. await db_session.refresh(printer)
  53. return printer
  54. @pytest.fixture
  55. def mock_client():
  56. client = MagicMock()
  57. client.base_url = "http://localhost:7912"
  58. client.health_check = AsyncMock(return_value=True)
  59. client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
  60. with patch(
  61. "backend.app.api.routes.spoolman_inventory._get_client",
  62. AsyncMock(return_value=client),
  63. ):
  64. yield client
  65. class TestAssignSpoolmanSlot:
  66. @pytest.mark.asyncio
  67. @pytest.mark.integration
  68. async def test_assign_inserts_local_row(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
  69. """POST /slot-assignments creates a row visible via the /all endpoint."""
  70. response = await async_client.post(
  71. "/api/v1/spoolman/inventory/slot-assignments",
  72. json={
  73. "spoolman_spool_id": 10,
  74. "printer_id": test_printer.id,
  75. "ams_id": 0,
  76. "tray_id": 0,
  77. },
  78. )
  79. assert response.status_code == 200
  80. all_resp = await async_client.get(
  81. "/api/v1/spoolman/inventory/slot-assignments/all",
  82. params={"printer_id": test_printer.id},
  83. )
  84. assert all_resp.status_code == 200
  85. rows = all_resp.json()
  86. assert len(rows) == 1
  87. assert rows[0]["spoolman_spool_id"] == 10
  88. assert rows[0]["ams_id"] == 0
  89. assert rows[0]["tray_id"] == 0
  90. @pytest.mark.asyncio
  91. @pytest.mark.integration
  92. async def test_assign_does_not_call_update_spool(
  93. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  94. ):
  95. """POST /slot-assignments must NOT write to Spoolman's location field."""
  96. response = await async_client.post(
  97. "/api/v1/spoolman/inventory/slot-assignments",
  98. json={
  99. "spoolman_spool_id": 10,
  100. "printer_id": test_printer.id,
  101. "ams_id": 0,
  102. "tray_id": 0,
  103. },
  104. )
  105. assert response.status_code == 200
  106. mock_client.update_spool.assert_not_called()
  107. @pytest.mark.asyncio
  108. @pytest.mark.integration
  109. async def test_assign_returns_inventory_spool(
  110. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  111. ):
  112. """POST /slot-assignments response is mapped to InventorySpool format."""
  113. response = await async_client.post(
  114. "/api/v1/spoolman/inventory/slot-assignments",
  115. json={
  116. "spoolman_spool_id": 10,
  117. "printer_id": test_printer.id,
  118. "ams_id": 0,
  119. "tray_id": 0,
  120. },
  121. )
  122. assert response.status_code == 200
  123. body = response.json()
  124. assert body["id"] == 10
  125. assert body["material"] == "PLA"
  126. assert body["data_origin"] == "spoolman"
  127. @pytest.mark.asyncio
  128. @pytest.mark.integration
  129. async def test_assign_upserts_on_conflict(
  130. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  131. ):
  132. """POST /slot-assignments twice for the same slot replaces the old spool ID."""
  133. # First assign spool 99
  134. await async_client.post(
  135. "/api/v1/spoolman/inventory/slot-assignments",
  136. json={
  137. "spoolman_spool_id": 99,
  138. "printer_id": test_printer.id,
  139. "ams_id": 0,
  140. "tray_id": 0,
  141. },
  142. )
  143. # Re-assign spool 10 to the same slot
  144. response = await async_client.post(
  145. "/api/v1/spoolman/inventory/slot-assignments",
  146. json={
  147. "spoolman_spool_id": 10,
  148. "printer_id": test_printer.id,
  149. "ams_id": 0,
  150. "tray_id": 0,
  151. },
  152. )
  153. assert response.status_code == 200
  154. # The /all endpoint must report exactly one row for this slot with spool_id=10
  155. all_resp = await async_client.get(
  156. "/api/v1/spoolman/inventory/slot-assignments/all",
  157. params={"printer_id": test_printer.id},
  158. )
  159. assert all_resp.status_code == 200
  160. rows = all_resp.json()
  161. matched = [r for r in rows if r["ams_id"] == 0 and r["tray_id"] == 0]
  162. assert len(matched) == 1
  163. assert matched[0]["spoolman_spool_id"] == 10
  164. @pytest.mark.asyncio
  165. @pytest.mark.integration
  166. async def test_assign_printer_not_found(self, async_client: AsyncClient, slot_settings, mock_client):
  167. """POST /slot-assignments with unknown printer_id returns 404."""
  168. response = await async_client.post(
  169. "/api/v1/spoolman/inventory/slot-assignments",
  170. json={
  171. "spoolman_spool_id": 10,
  172. "printer_id": 99999,
  173. "ams_id": 0,
  174. "tray_id": 0,
  175. },
  176. )
  177. assert response.status_code == 404
  178. @pytest.mark.asyncio
  179. @pytest.mark.integration
  180. async def test_assign_invalid_spool_id(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
  181. """POST /slot-assignments with spool_id=0 returns 422 (gt=0 validation)."""
  182. response = await async_client.post(
  183. "/api/v1/spoolman/inventory/slot-assignments",
  184. json={
  185. "spoolman_spool_id": 0,
  186. "printer_id": test_printer.id,
  187. "ams_id": 0,
  188. "tray_id": 0,
  189. },
  190. )
  191. assert response.status_code == 422
  192. class TestUnassignSpoolmanSlot:
  193. @pytest.mark.asyncio
  194. @pytest.mark.integration
  195. async def test_unassign_deletes_local_row(
  196. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  197. ):
  198. """DELETE /slot-assignments/{id} removes the row so /all no longer lists it."""
  199. # First assign spool 10
  200. await async_client.post(
  201. "/api/v1/spoolman/inventory/slot-assignments",
  202. json={
  203. "spoolman_spool_id": 10,
  204. "printer_id": test_printer.id,
  205. "ams_id": 0,
  206. "tray_id": 0,
  207. },
  208. )
  209. # Then unassign
  210. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
  211. assert response.status_code == 200
  212. # The /all endpoint must now return an empty list for this printer
  213. all_resp = await async_client.get(
  214. "/api/v1/spoolman/inventory/slot-assignments/all",
  215. params={"printer_id": test_printer.id},
  216. )
  217. assert all_resp.status_code == 200
  218. assert all_resp.json() == []
  219. @pytest.mark.asyncio
  220. @pytest.mark.integration
  221. async def test_unassign_does_not_call_update_spool(self, async_client: AsyncClient, slot_settings, mock_client):
  222. """DELETE /slot-assignments/{id} must NOT touch Spoolman's location field."""
  223. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
  224. assert response.status_code == 200
  225. mock_client.update_spool.assert_not_called()
  226. @pytest.mark.asyncio
  227. @pytest.mark.integration
  228. async def test_unassign_returns_inventory_spool(self, async_client: AsyncClient, slot_settings, mock_client):
  229. """DELETE /slot-assignments/{id} returns the spool in InventorySpool format."""
  230. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
  231. assert response.status_code == 200
  232. body = response.json()
  233. assert body["id"] == 10
  234. assert body["data_origin"] == "spoolman"
  235. @pytest.mark.asyncio
  236. @pytest.mark.integration
  237. async def test_unassign_invalid_id(self, async_client: AsyncClient, slot_settings, mock_client):
  238. """DELETE /slot-assignments/0 returns 422 (gt=0 path validation)."""
  239. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/0")
  240. assert response.status_code == 422
  241. @pytest.mark.asyncio
  242. @pytest.mark.integration
  243. async def test_unassign_succeeds_when_spool_deleted_in_spoolman(
  244. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  245. ):
  246. """DELETE /slot-assignments/{id} returns 200 even when the spool no longer exists in Spoolman.
  247. The local row must be removed regardless — the caller should not see an error just
  248. because Spoolman has already discarded the spool.
  249. """
  250. from sqlalchemy import select
  251. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  252. from backend.app.services.spoolman import SpoolmanNotFoundError
  253. # Create the assignment first
  254. await async_client.post(
  255. "/api/v1/spoolman/inventory/slot-assignments",
  256. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  257. )
  258. # Spool 10 has since been deleted from Spoolman
  259. mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("spool 10 not found"))
  260. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
  261. assert response.status_code == 200
  262. assert response.json().get("id") == 10
  263. # Local row must be gone
  264. result = await db_session.execute(
  265. select(SpoolmanSlotAssignment).where(
  266. SpoolmanSlotAssignment.printer_id == test_printer.id,
  267. SpoolmanSlotAssignment.ams_id == 0,
  268. SpoolmanSlotAssignment.tray_id == 0,
  269. )
  270. )
  271. assert result.scalar_one_or_none() is None
  272. class TestGetSpoolmanSlotAssignment:
  273. @pytest.mark.asyncio
  274. @pytest.mark.integration
  275. async def test_get_returns_matched_spool(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
  276. """GET /slot-assignments returns the spool whose ID is in the local table."""
  277. # First assign so the row exists
  278. await async_client.post(
  279. "/api/v1/spoolman/inventory/slot-assignments",
  280. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  281. )
  282. mock_client.get_spool.reset_mock()
  283. response = await async_client.get(
  284. "/api/v1/spoolman/inventory/slot-assignments",
  285. params={"printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  286. )
  287. assert response.status_code == 200
  288. body = response.json()
  289. assert body is not None
  290. assert body["id"] == 10
  291. mock_client.get_spool.assert_awaited_once_with(10)
  292. @pytest.mark.asyncio
  293. @pytest.mark.integration
  294. async def test_get_returns_null_when_no_assignment(
  295. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  296. ):
  297. """GET /slot-assignments returns null when no local row exists for the slot."""
  298. response = await async_client.get(
  299. "/api/v1/spoolman/inventory/slot-assignments",
  300. params={"printer_id": test_printer.id, "ams_id": 1, "tray_id": 0},
  301. )
  302. assert response.status_code == 200
  303. assert response.json() is None
  304. @pytest.mark.asyncio
  305. @pytest.mark.integration
  306. async def test_get_printer_not_found(self, async_client: AsyncClient, slot_settings, mock_client):
  307. """GET /slot-assignments with unknown printer_id returns 404."""
  308. response = await async_client.get(
  309. "/api/v1/spoolman/inventory/slot-assignments",
  310. params={"printer_id": 99999, "ams_id": 0, "tray_id": 0},
  311. )
  312. assert response.status_code == 404
  313. @pytest.mark.asyncio
  314. @pytest.mark.integration
  315. async def test_get_missing_params(self, async_client: AsyncClient, slot_settings, mock_client):
  316. """GET /slot-assignments without required params returns 422."""
  317. response = await async_client.get("/api/v1/spoolman/inventory/slot-assignments")
  318. assert response.status_code == 422
  319. @pytest.mark.asyncio
  320. @pytest.mark.integration
  321. async def test_get_returns_null_and_cleans_stale_when_spool_deleted_in_spoolman(
  322. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  323. ):
  324. """GET /slot-assignments returns null and removes the stale row when Spoolman returns 404."""
  325. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  326. from backend.app.services.spoolman import SpoolmanNotFoundError
  327. # Assign spool 10 first
  328. await async_client.post(
  329. "/api/v1/spoolman/inventory/slot-assignments",
  330. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  331. )
  332. # Simulate spool 10 being deleted from Spoolman (404 via SpoolmanNotFoundError)
  333. mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("spool 10 not found"))
  334. response = await async_client.get(
  335. "/api/v1/spoolman/inventory/slot-assignments",
  336. params={"printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  337. )
  338. assert response.status_code == 200
  339. assert response.json() is None
  340. # Stale row must have been removed
  341. await db_session.refresh(test_printer) # ensure session is fresh
  342. result = await db_session.execute(
  343. select(SpoolmanSlotAssignment).where(
  344. SpoolmanSlotAssignment.printer_id == test_printer.id,
  345. SpoolmanSlotAssignment.ams_id == 0,
  346. SpoolmanSlotAssignment.tray_id == 0,
  347. )
  348. )
  349. assert result.scalar_one_or_none() is None
  350. @pytest.mark.asyncio
  351. @pytest.mark.integration
  352. async def test_get_propagates_503_from_spoolman(
  353. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  354. ):
  355. """GET /slot-assignments propagates a 503 from Spoolman instead of silently returning null."""
  356. from backend.app.services.spoolman import SpoolmanUnavailableError
  357. # Assign spool 10 first so a local row exists
  358. await async_client.post(
  359. "/api/v1/spoolman/inventory/slot-assignments",
  360. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  361. )
  362. # Simulate Spoolman being unreachable
  363. mock_client.get_spool = AsyncMock(side_effect=SpoolmanUnavailableError("timeout"))
  364. response = await async_client.get(
  365. "/api/v1/spoolman/inventory/slot-assignments",
  366. params={"printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  367. )
  368. assert response.status_code == 503
  369. class TestGetAllSpoolmanSlotAssignments:
  370. @pytest.mark.asyncio
  371. @pytest.mark.integration
  372. async def test_get_all_returns_empty_list(self, async_client: AsyncClient, slot_settings, mock_client):
  373. """GET /slot-assignments/all returns [] when no assignments exist."""
  374. response = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
  375. assert response.status_code == 200
  376. assert response.json() == []
  377. @pytest.mark.asyncio
  378. @pytest.mark.integration
  379. async def test_get_all_returns_all_rows(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
  380. """GET /slot-assignments/all returns all existing assignments."""
  381. await async_client.post(
  382. "/api/v1/spoolman/inventory/slot-assignments",
  383. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  384. )
  385. await async_client.post(
  386. "/api/v1/spoolman/inventory/slot-assignments",
  387. json={"spoolman_spool_id": 20, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 1},
  388. )
  389. response = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
  390. assert response.status_code == 200
  391. body = response.json()
  392. assert len(body) == 2
  393. spool_ids = {r["spoolman_spool_id"] for r in body}
  394. assert spool_ids == {10, 20}
  395. @pytest.mark.asyncio
  396. @pytest.mark.integration
  397. async def test_get_all_filters_by_printer(
  398. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  399. ):
  400. """GET /slot-assignments/all?printer_id=X only returns that printer's rows."""
  401. from backend.app.models.printer import Printer
  402. # Create a second printer via DB directly (no Spoolman mock needed for printer creation)
  403. other = Printer(
  404. name="Other Printer",
  405. serial_number="SLOTTEST002",
  406. ip_address="192.168.1.101",
  407. access_code="87654321",
  408. )
  409. db_session.add(other)
  410. await db_session.commit()
  411. await db_session.refresh(other)
  412. # Assign via API for test_printer
  413. await async_client.post(
  414. "/api/v1/spoolman/inventory/slot-assignments",
  415. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  416. )
  417. # Assign via API for other printer
  418. await async_client.post(
  419. "/api/v1/spoolman/inventory/slot-assignments",
  420. json={"spoolman_spool_id": 99, "printer_id": other.id, "ams_id": 0, "tray_id": 0},
  421. )
  422. response = await async_client.get(
  423. "/api/v1/spoolman/inventory/slot-assignments/all",
  424. params={"printer_id": test_printer.id},
  425. )
  426. assert response.status_code == 200
  427. body = response.json()
  428. assert len(body) == 1
  429. assert body[0]["spoolman_spool_id"] == 10
  430. assert body[0]["printer_id"] == test_printer.id
  431. class TestCascadeDeletePrinter:
  432. @pytest.mark.asyncio
  433. @pytest.mark.integration
  434. async def test_delete_printer_removes_slot_assignments(
  435. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  436. ):
  437. """DELETE /printers/{id} removes all slot assignments for that printer.
  438. SQLite does not enforce FK cascades automatically. The delete_printer
  439. endpoint must explicitly delete SpoolmanSlotAssignment rows so no
  440. orphaned rows survive after the printer record is gone.
  441. """
  442. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  443. # Assign two spools to different AMS slots on the test printer
  444. for tray_id, spool_id in [(0, 10), (1, 20)]:
  445. await async_client.post(
  446. "/api/v1/spoolman/inventory/slot-assignments",
  447. json={
  448. "spoolman_spool_id": spool_id,
  449. "printer_id": test_printer.id,
  450. "ams_id": 0,
  451. "tray_id": tray_id,
  452. },
  453. )
  454. # Verify both rows exist
  455. pre = await db_session.execute(
  456. select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
  457. )
  458. assert len(pre.scalars().all()) == 2
  459. # Delete the printer
  460. del_resp = await async_client.delete(f"/api/v1/printers/{test_printer.id}")
  461. assert del_resp.status_code == 200
  462. # All slot assignment rows for the deleted printer must be gone
  463. post = await db_session.execute(
  464. select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
  465. )
  466. assert post.scalars().all() == []