test_spoolman_slot_assignments.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  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_accepts_ams_ht_id(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
  93. """#1274: AMS-HT units report ams_id 128+. The pre-fix ck_ams_id_range
  94. only allowed 0-7 / 255, so the upsert blew up with `CHECK constraint
  95. failed: ck_ams_id_range` and the user couldn't link any spool to the
  96. H2C/H2D AMS-HT slot. This guards the widened range from regressing.
  97. """
  98. response = await async_client.post(
  99. "/api/v1/spoolman/inventory/slot-assignments",
  100. json={
  101. "spoolman_spool_id": 51,
  102. "printer_id": test_printer.id,
  103. "ams_id": 128, # AMS-HT on the left nozzle (matches issue's failing INSERT)
  104. "tray_id": 0,
  105. },
  106. )
  107. assert response.status_code == 200, response.text
  108. all_resp = await async_client.get(
  109. "/api/v1/spoolman/inventory/slot-assignments/all",
  110. params={"printer_id": test_printer.id},
  111. )
  112. rows = all_resp.json()
  113. assert any(r["ams_id"] == 128 and r["spoolman_spool_id"] == 51 for r in rows)
  114. @pytest.mark.asyncio
  115. @pytest.mark.integration
  116. async def test_assign_does_not_call_update_spool(
  117. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  118. ):
  119. """POST /slot-assignments must NOT write to Spoolman's location field."""
  120. response = await async_client.post(
  121. "/api/v1/spoolman/inventory/slot-assignments",
  122. json={
  123. "spoolman_spool_id": 10,
  124. "printer_id": test_printer.id,
  125. "ams_id": 0,
  126. "tray_id": 0,
  127. },
  128. )
  129. assert response.status_code == 200
  130. mock_client.update_spool.assert_not_called()
  131. @pytest.mark.asyncio
  132. @pytest.mark.integration
  133. async def test_assign_returns_inventory_spool(
  134. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  135. ):
  136. """POST /slot-assignments response is mapped to InventorySpool format."""
  137. response = await async_client.post(
  138. "/api/v1/spoolman/inventory/slot-assignments",
  139. json={
  140. "spoolman_spool_id": 10,
  141. "printer_id": test_printer.id,
  142. "ams_id": 0,
  143. "tray_id": 0,
  144. },
  145. )
  146. assert response.status_code == 200
  147. body = response.json()
  148. assert body["id"] == 10
  149. assert body["material"] == "PLA"
  150. assert body["data_origin"] == "spoolman"
  151. @pytest.mark.asyncio
  152. @pytest.mark.integration
  153. async def test_assign_upserts_on_conflict(
  154. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  155. ):
  156. """POST /slot-assignments twice for the same slot replaces the old spool ID."""
  157. # First assign spool 99
  158. await async_client.post(
  159. "/api/v1/spoolman/inventory/slot-assignments",
  160. json={
  161. "spoolman_spool_id": 99,
  162. "printer_id": test_printer.id,
  163. "ams_id": 0,
  164. "tray_id": 0,
  165. },
  166. )
  167. # Re-assign spool 10 to the same slot
  168. response = await async_client.post(
  169. "/api/v1/spoolman/inventory/slot-assignments",
  170. json={
  171. "spoolman_spool_id": 10,
  172. "printer_id": test_printer.id,
  173. "ams_id": 0,
  174. "tray_id": 0,
  175. },
  176. )
  177. assert response.status_code == 200
  178. # The /all endpoint must report exactly one row for this slot with spool_id=10
  179. all_resp = await async_client.get(
  180. "/api/v1/spoolman/inventory/slot-assignments/all",
  181. params={"printer_id": test_printer.id},
  182. )
  183. assert all_resp.status_code == 200
  184. rows = all_resp.json()
  185. matched = [r for r in rows if r["ams_id"] == 0 and r["tray_id"] == 0]
  186. assert len(matched) == 1
  187. assert matched[0]["spoolman_spool_id"] == 10
  188. @pytest.mark.asyncio
  189. @pytest.mark.integration
  190. async def test_assign_printer_not_found(self, async_client: AsyncClient, slot_settings, mock_client):
  191. """POST /slot-assignments with unknown printer_id returns 404."""
  192. response = await async_client.post(
  193. "/api/v1/spoolman/inventory/slot-assignments",
  194. json={
  195. "spoolman_spool_id": 10,
  196. "printer_id": 99999,
  197. "ams_id": 0,
  198. "tray_id": 0,
  199. },
  200. )
  201. assert response.status_code == 404
  202. @pytest.mark.asyncio
  203. @pytest.mark.integration
  204. async def test_assign_invalid_spool_id(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
  205. """POST /slot-assignments with spool_id=0 returns 422 (gt=0 validation)."""
  206. response = await async_client.post(
  207. "/api/v1/spoolman/inventory/slot-assignments",
  208. json={
  209. "spoolman_spool_id": 0,
  210. "printer_id": test_printer.id,
  211. "ams_id": 0,
  212. "tray_id": 0,
  213. },
  214. )
  215. assert response.status_code == 422
  216. class TestUnassignSpoolmanSlot:
  217. @pytest.mark.asyncio
  218. @pytest.mark.integration
  219. async def test_unassign_deletes_local_row(
  220. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  221. ):
  222. """DELETE /slot-assignments/{id} removes the row so /all no longer lists it."""
  223. # First assign spool 10
  224. await async_client.post(
  225. "/api/v1/spoolman/inventory/slot-assignments",
  226. json={
  227. "spoolman_spool_id": 10,
  228. "printer_id": test_printer.id,
  229. "ams_id": 0,
  230. "tray_id": 0,
  231. },
  232. )
  233. # Then unassign
  234. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
  235. assert response.status_code == 200
  236. # The /all endpoint must now return an empty list for this printer
  237. all_resp = await async_client.get(
  238. "/api/v1/spoolman/inventory/slot-assignments/all",
  239. params={"printer_id": test_printer.id},
  240. )
  241. assert all_resp.status_code == 200
  242. assert all_resp.json() == []
  243. @pytest.mark.asyncio
  244. @pytest.mark.integration
  245. async def test_unassign_does_not_call_update_spool(self, async_client: AsyncClient, slot_settings, mock_client):
  246. """DELETE /slot-assignments/{id} must NOT touch Spoolman's location field."""
  247. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
  248. assert response.status_code == 200
  249. mock_client.update_spool.assert_not_called()
  250. @pytest.mark.asyncio
  251. @pytest.mark.integration
  252. async def test_unassign_returns_inventory_spool(self, async_client: AsyncClient, slot_settings, mock_client):
  253. """DELETE /slot-assignments/{id} returns the spool in InventorySpool format."""
  254. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
  255. assert response.status_code == 200
  256. body = response.json()
  257. assert body["id"] == 10
  258. assert body["data_origin"] == "spoolman"
  259. @pytest.mark.asyncio
  260. @pytest.mark.integration
  261. async def test_unassign_invalid_id(self, async_client: AsyncClient, slot_settings, mock_client):
  262. """DELETE /slot-assignments/0 returns 422 (gt=0 path validation)."""
  263. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/0")
  264. assert response.status_code == 422
  265. @pytest.mark.asyncio
  266. @pytest.mark.integration
  267. async def test_unassign_succeeds_when_spool_deleted_in_spoolman(
  268. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  269. ):
  270. """DELETE /slot-assignments/{id} returns 200 even when the spool no longer exists in Spoolman.
  271. The local row must be removed regardless — the caller should not see an error just
  272. because Spoolman has already discarded the spool.
  273. """
  274. from sqlalchemy import select
  275. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  276. from backend.app.services.spoolman import SpoolmanNotFoundError
  277. # Create the assignment first
  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. # Spool 10 has since been deleted from Spoolman
  283. mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("spool 10 not found"))
  284. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
  285. assert response.status_code == 200
  286. assert response.json().get("id") == 10
  287. # Local row must be gone
  288. result = await db_session.execute(
  289. select(SpoolmanSlotAssignment).where(
  290. SpoolmanSlotAssignment.printer_id == test_printer.id,
  291. SpoolmanSlotAssignment.ams_id == 0,
  292. SpoolmanSlotAssignment.tray_id == 0,
  293. )
  294. )
  295. assert result.scalar_one_or_none() is None
  296. class TestGetSpoolmanSlotAssignment:
  297. @pytest.mark.asyncio
  298. @pytest.mark.integration
  299. async def test_get_returns_matched_spool(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
  300. """GET /slot-assignments returns the spool whose ID is in the local table."""
  301. # First assign so the row exists
  302. await async_client.post(
  303. "/api/v1/spoolman/inventory/slot-assignments",
  304. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  305. )
  306. mock_client.get_spool.reset_mock()
  307. response = await async_client.get(
  308. "/api/v1/spoolman/inventory/slot-assignments",
  309. params={"printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  310. )
  311. assert response.status_code == 200
  312. body = response.json()
  313. assert body is not None
  314. assert body["id"] == 10
  315. mock_client.get_spool.assert_awaited_once_with(10)
  316. @pytest.mark.asyncio
  317. @pytest.mark.integration
  318. async def test_get_returns_null_when_no_assignment(
  319. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  320. ):
  321. """GET /slot-assignments returns null when no local row exists for the slot."""
  322. response = await async_client.get(
  323. "/api/v1/spoolman/inventory/slot-assignments",
  324. params={"printer_id": test_printer.id, "ams_id": 1, "tray_id": 0},
  325. )
  326. assert response.status_code == 200
  327. assert response.json() is None
  328. @pytest.mark.asyncio
  329. @pytest.mark.integration
  330. async def test_get_printer_not_found(self, async_client: AsyncClient, slot_settings, mock_client):
  331. """GET /slot-assignments with unknown printer_id returns 404."""
  332. response = await async_client.get(
  333. "/api/v1/spoolman/inventory/slot-assignments",
  334. params={"printer_id": 99999, "ams_id": 0, "tray_id": 0},
  335. )
  336. assert response.status_code == 404
  337. @pytest.mark.asyncio
  338. @pytest.mark.integration
  339. async def test_get_missing_params(self, async_client: AsyncClient, slot_settings, mock_client):
  340. """GET /slot-assignments without required params returns 422."""
  341. response = await async_client.get("/api/v1/spoolman/inventory/slot-assignments")
  342. assert response.status_code == 422
  343. @pytest.mark.asyncio
  344. @pytest.mark.integration
  345. async def test_get_returns_null_and_cleans_stale_when_spool_deleted_in_spoolman(
  346. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  347. ):
  348. """GET /slot-assignments returns null and removes the stale row when Spoolman returns 404."""
  349. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  350. from backend.app.services.spoolman import SpoolmanNotFoundError
  351. # Assign spool 10 first
  352. await async_client.post(
  353. "/api/v1/spoolman/inventory/slot-assignments",
  354. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  355. )
  356. # Simulate spool 10 being deleted from Spoolman (404 via SpoolmanNotFoundError)
  357. mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("spool 10 not found"))
  358. response = await async_client.get(
  359. "/api/v1/spoolman/inventory/slot-assignments",
  360. params={"printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  361. )
  362. assert response.status_code == 200
  363. assert response.json() is None
  364. # Stale row must have been removed
  365. await db_session.refresh(test_printer) # ensure session is fresh
  366. result = await db_session.execute(
  367. select(SpoolmanSlotAssignment).where(
  368. SpoolmanSlotAssignment.printer_id == test_printer.id,
  369. SpoolmanSlotAssignment.ams_id == 0,
  370. SpoolmanSlotAssignment.tray_id == 0,
  371. )
  372. )
  373. assert result.scalar_one_or_none() is None
  374. @pytest.mark.asyncio
  375. @pytest.mark.integration
  376. async def test_get_propagates_503_from_spoolman(
  377. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  378. ):
  379. """GET /slot-assignments propagates a 503 from Spoolman instead of silently returning null."""
  380. from backend.app.services.spoolman import SpoolmanUnavailableError
  381. # Assign spool 10 first so a local row exists
  382. await async_client.post(
  383. "/api/v1/spoolman/inventory/slot-assignments",
  384. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  385. )
  386. # Simulate Spoolman being unreachable
  387. mock_client.get_spool = AsyncMock(side_effect=SpoolmanUnavailableError("timeout"))
  388. response = await async_client.get(
  389. "/api/v1/spoolman/inventory/slot-assignments",
  390. params={"printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  391. )
  392. assert response.status_code == 503
  393. class TestGetAllSpoolmanSlotAssignments:
  394. @pytest.mark.asyncio
  395. @pytest.mark.integration
  396. async def test_get_all_returns_empty_list(self, async_client: AsyncClient, slot_settings, mock_client):
  397. """GET /slot-assignments/all returns [] when no assignments exist."""
  398. response = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
  399. assert response.status_code == 200
  400. assert response.json() == []
  401. @pytest.mark.asyncio
  402. @pytest.mark.integration
  403. async def test_get_all_returns_all_rows(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
  404. """GET /slot-assignments/all returns all existing assignments."""
  405. await async_client.post(
  406. "/api/v1/spoolman/inventory/slot-assignments",
  407. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  408. )
  409. await async_client.post(
  410. "/api/v1/spoolman/inventory/slot-assignments",
  411. json={"spoolman_spool_id": 20, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 1},
  412. )
  413. response = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
  414. assert response.status_code == 200
  415. body = response.json()
  416. assert len(body) == 2
  417. spool_ids = {r["spoolman_spool_id"] for r in body}
  418. assert spool_ids == {10, 20}
  419. @pytest.mark.asyncio
  420. @pytest.mark.integration
  421. async def test_get_all_filters_by_printer(
  422. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  423. ):
  424. """GET /slot-assignments/all?printer_id=X only returns that printer's rows."""
  425. from backend.app.models.printer import Printer
  426. # Create a second printer via DB directly (no Spoolman mock needed for printer creation)
  427. other = Printer(
  428. name="Other Printer",
  429. serial_number="SLOTTEST002",
  430. ip_address="192.168.1.101",
  431. access_code="87654321",
  432. )
  433. db_session.add(other)
  434. await db_session.commit()
  435. await db_session.refresh(other)
  436. # Assign via API for test_printer
  437. await async_client.post(
  438. "/api/v1/spoolman/inventory/slot-assignments",
  439. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  440. )
  441. # Assign via API for other printer
  442. await async_client.post(
  443. "/api/v1/spoolman/inventory/slot-assignments",
  444. json={"spoolman_spool_id": 99, "printer_id": other.id, "ams_id": 0, "tray_id": 0},
  445. )
  446. response = await async_client.get(
  447. "/api/v1/spoolman/inventory/slot-assignments/all",
  448. params={"printer_id": test_printer.id},
  449. )
  450. assert response.status_code == 200
  451. body = response.json()
  452. assert len(body) == 1
  453. assert body[0]["spoolman_spool_id"] == 10
  454. assert body[0]["printer_id"] == test_printer.id
  455. class TestCascadeDeletePrinter:
  456. @pytest.mark.asyncio
  457. @pytest.mark.integration
  458. async def test_delete_printer_removes_slot_assignments(
  459. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  460. ):
  461. """DELETE /printers/{id} removes all slot assignments for that printer.
  462. SQLite does not enforce FK cascades automatically. The delete_printer
  463. endpoint must explicitly delete SpoolmanSlotAssignment rows so no
  464. orphaned rows survive after the printer record is gone.
  465. """
  466. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  467. # Assign two spools to different AMS slots on the test printer
  468. for tray_id, spool_id in [(0, 10), (1, 20)]:
  469. await async_client.post(
  470. "/api/v1/spoolman/inventory/slot-assignments",
  471. json={
  472. "spoolman_spool_id": spool_id,
  473. "printer_id": test_printer.id,
  474. "ams_id": 0,
  475. "tray_id": tray_id,
  476. },
  477. )
  478. # Verify both rows exist
  479. pre = await db_session.execute(
  480. select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
  481. )
  482. assert len(pre.scalars().all()) == 2
  483. # Delete the printer
  484. del_resp = await async_client.delete(f"/api/v1/printers/{test_printer.id}")
  485. assert del_resp.status_code == 200
  486. # All slot assignment rows for the deleted printer must be gone
  487. post = await db_session.execute(
  488. select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
  489. )
  490. assert post.scalars().all() == []