test_spoolman_slot_assignments.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  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. # #1457: assign route enumerates spools to clear stale fallback-tag links.
  61. # Default to empty so the cleanup is a no-op for tests that don't exercise it.
  62. client.get_spools = AsyncMock(return_value=[])
  63. client.merge_spool_extra = AsyncMock(return_value={"id": 0, "extra": {}})
  64. with patch(
  65. "backend.app.api.routes.spoolman_inventory._get_client",
  66. AsyncMock(return_value=client),
  67. ):
  68. yield client
  69. class TestAssignSpoolmanSlot:
  70. @pytest.mark.asyncio
  71. @pytest.mark.integration
  72. async def test_assign_inserts_local_row(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
  73. """POST /slot-assignments creates a row visible via the /all endpoint."""
  74. response = await async_client.post(
  75. "/api/v1/spoolman/inventory/slot-assignments",
  76. json={
  77. "spoolman_spool_id": 10,
  78. "printer_id": test_printer.id,
  79. "ams_id": 0,
  80. "tray_id": 0,
  81. },
  82. )
  83. assert response.status_code == 200
  84. all_resp = await async_client.get(
  85. "/api/v1/spoolman/inventory/slot-assignments/all",
  86. params={"printer_id": test_printer.id},
  87. )
  88. assert all_resp.status_code == 200
  89. rows = all_resp.json()
  90. assert len(rows) == 1
  91. assert rows[0]["spoolman_spool_id"] == 10
  92. assert rows[0]["ams_id"] == 0
  93. assert rows[0]["tray_id"] == 0
  94. @pytest.mark.asyncio
  95. @pytest.mark.integration
  96. async def test_assign_drops_a_held_unlink_on_the_slot(
  97. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  98. ):
  99. """#3186: a slot that read empty has its unlink held for a grace period.
  100. Linking a spool to it is fresher evidence, so the old clock must not
  101. carry over and delete the user's own link."""
  102. from backend.app.services import slot_unlink_grace
  103. hold_key = ("spoolman", 0, 0, 10)
  104. slot_unlink_grace.removal_confirmed(test_printer.id, hold_key)
  105. assert slot_unlink_grace.is_held(test_printer.id, hold_key)
  106. response = await async_client.post(
  107. "/api/v1/spoolman/inventory/slot-assignments",
  108. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  109. )
  110. assert response.status_code == 200
  111. assert not slot_unlink_grace.is_held(test_printer.id, hold_key)
  112. @pytest.mark.asyncio
  113. @pytest.mark.integration
  114. async def test_assign_accepts_ams_ht_id(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
  115. """#1274: AMS-HT units report ams_id 128+. The pre-fix ck_ams_id_range
  116. only allowed 0-7 / 255, so the upsert blew up with `CHECK constraint
  117. failed: ck_ams_id_range` and the user couldn't link any spool to the
  118. H2C/H2D AMS-HT slot. This guards the widened range from regressing.
  119. """
  120. response = await async_client.post(
  121. "/api/v1/spoolman/inventory/slot-assignments",
  122. json={
  123. "spoolman_spool_id": 51,
  124. "printer_id": test_printer.id,
  125. "ams_id": 128, # AMS-HT on the left nozzle (matches issue's failing INSERT)
  126. "tray_id": 0,
  127. },
  128. )
  129. assert response.status_code == 200, response.text
  130. all_resp = await async_client.get(
  131. "/api/v1/spoolman/inventory/slot-assignments/all",
  132. params={"printer_id": test_printer.id},
  133. )
  134. rows = all_resp.json()
  135. assert any(r["ams_id"] == 128 and r["spoolman_spool_id"] == 51 for r in rows)
  136. @pytest.mark.asyncio
  137. @pytest.mark.integration
  138. async def test_assign_does_not_call_update_spool(
  139. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  140. ):
  141. """POST /slot-assignments must NOT write to Spoolman's location field."""
  142. response = await async_client.post(
  143. "/api/v1/spoolman/inventory/slot-assignments",
  144. json={
  145. "spoolman_spool_id": 10,
  146. "printer_id": test_printer.id,
  147. "ams_id": 0,
  148. "tray_id": 0,
  149. },
  150. )
  151. assert response.status_code == 200
  152. mock_client.update_spool.assert_not_called()
  153. @pytest.mark.asyncio
  154. @pytest.mark.integration
  155. async def test_assign_returns_inventory_spool(
  156. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  157. ):
  158. """POST /slot-assignments response is mapped to InventorySpool format."""
  159. response = await async_client.post(
  160. "/api/v1/spoolman/inventory/slot-assignments",
  161. json={
  162. "spoolman_spool_id": 10,
  163. "printer_id": test_printer.id,
  164. "ams_id": 0,
  165. "tray_id": 0,
  166. },
  167. )
  168. assert response.status_code == 200
  169. body = response.json()
  170. assert body["id"] == 10
  171. assert body["material"] == "PLA"
  172. assert body["data_origin"] == "spoolman"
  173. @pytest.mark.asyncio
  174. @pytest.mark.integration
  175. async def test_assign_upserts_on_conflict(
  176. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  177. ):
  178. """POST /slot-assignments twice for the same slot replaces the old spool ID."""
  179. # First assign spool 99
  180. await async_client.post(
  181. "/api/v1/spoolman/inventory/slot-assignments",
  182. json={
  183. "spoolman_spool_id": 99,
  184. "printer_id": test_printer.id,
  185. "ams_id": 0,
  186. "tray_id": 0,
  187. },
  188. )
  189. # Re-assign spool 10 to the same slot
  190. response = await async_client.post(
  191. "/api/v1/spoolman/inventory/slot-assignments",
  192. json={
  193. "spoolman_spool_id": 10,
  194. "printer_id": test_printer.id,
  195. "ams_id": 0,
  196. "tray_id": 0,
  197. },
  198. )
  199. assert response.status_code == 200
  200. # The /all endpoint must report exactly one row for this slot with spool_id=10
  201. all_resp = await async_client.get(
  202. "/api/v1/spoolman/inventory/slot-assignments/all",
  203. params={"printer_id": test_printer.id},
  204. )
  205. assert all_resp.status_code == 200
  206. rows = all_resp.json()
  207. matched = [r for r in rows if r["ams_id"] == 0 and r["tray_id"] == 0]
  208. assert len(matched) == 1
  209. assert matched[0]["spoolman_spool_id"] == 10
  210. @pytest.mark.asyncio
  211. @pytest.mark.integration
  212. async def test_assign_printer_not_found(self, async_client: AsyncClient, slot_settings, mock_client):
  213. """POST /slot-assignments with unknown printer_id returns 404."""
  214. response = await async_client.post(
  215. "/api/v1/spoolman/inventory/slot-assignments",
  216. json={
  217. "spoolman_spool_id": 10,
  218. "printer_id": 99999,
  219. "ams_id": 0,
  220. "tray_id": 0,
  221. },
  222. )
  223. assert response.status_code == 404
  224. @pytest.mark.asyncio
  225. @pytest.mark.integration
  226. async def test_assign_invalid_spool_id(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
  227. """POST /slot-assignments with spool_id=0 returns 422 (gt=0 validation)."""
  228. response = await async_client.post(
  229. "/api/v1/spoolman/inventory/slot-assignments",
  230. json={
  231. "spoolman_spool_id": 0,
  232. "printer_id": test_printer.id,
  233. "ams_id": 0,
  234. "tray_id": 0,
  235. },
  236. )
  237. assert response.status_code == 422
  238. class TestUnassignSpoolmanSlot:
  239. @pytest.mark.asyncio
  240. @pytest.mark.integration
  241. async def test_unassign_deletes_local_row(
  242. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  243. ):
  244. """DELETE /slot-assignments/{id} removes the row so /all no longer lists it."""
  245. # First assign spool 10
  246. await async_client.post(
  247. "/api/v1/spoolman/inventory/slot-assignments",
  248. json={
  249. "spoolman_spool_id": 10,
  250. "printer_id": test_printer.id,
  251. "ams_id": 0,
  252. "tray_id": 0,
  253. },
  254. )
  255. # Then unassign
  256. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
  257. assert response.status_code == 200
  258. # The /all endpoint must now return an empty list for this printer
  259. all_resp = await async_client.get(
  260. "/api/v1/spoolman/inventory/slot-assignments/all",
  261. params={"printer_id": test_printer.id},
  262. )
  263. assert all_resp.status_code == 200
  264. assert all_resp.json() == []
  265. @pytest.mark.asyncio
  266. @pytest.mark.integration
  267. async def test_unassign_does_not_call_update_spool(self, async_client: AsyncClient, slot_settings, mock_client):
  268. """DELETE /slot-assignments/{id} must NOT touch Spoolman's location field."""
  269. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
  270. assert response.status_code == 200
  271. mock_client.update_spool.assert_not_called()
  272. @pytest.mark.asyncio
  273. @pytest.mark.integration
  274. async def test_unassign_returns_inventory_spool(self, async_client: AsyncClient, slot_settings, mock_client):
  275. """DELETE /slot-assignments/{id} returns the spool in InventorySpool format."""
  276. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
  277. assert response.status_code == 200
  278. body = response.json()
  279. assert body["id"] == 10
  280. assert body["data_origin"] == "spoolman"
  281. @pytest.mark.asyncio
  282. @pytest.mark.integration
  283. async def test_unassign_invalid_id(self, async_client: AsyncClient, slot_settings, mock_client):
  284. """DELETE /slot-assignments/0 returns 422 (gt=0 path validation)."""
  285. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/0")
  286. assert response.status_code == 422
  287. @pytest.mark.asyncio
  288. @pytest.mark.integration
  289. async def test_unassign_succeeds_when_spool_deleted_in_spoolman(
  290. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  291. ):
  292. """DELETE /slot-assignments/{id} returns 200 even when the spool no longer exists in Spoolman.
  293. The local row must be removed regardless — the caller should not see an error just
  294. because Spoolman has already discarded the spool.
  295. """
  296. from sqlalchemy import select
  297. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  298. from backend.app.services.spoolman import SpoolmanNotFoundError
  299. # Create the assignment first
  300. await async_client.post(
  301. "/api/v1/spoolman/inventory/slot-assignments",
  302. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  303. )
  304. # Spool 10 has since been deleted from Spoolman
  305. mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("spool 10 not found"))
  306. response = await async_client.delete("/api/v1/spoolman/inventory/slot-assignments/10")
  307. assert response.status_code == 200
  308. assert response.json().get("id") == 10
  309. # Local row must be gone
  310. result = await db_session.execute(
  311. select(SpoolmanSlotAssignment).where(
  312. SpoolmanSlotAssignment.printer_id == test_printer.id,
  313. SpoolmanSlotAssignment.ams_id == 0,
  314. SpoolmanSlotAssignment.tray_id == 0,
  315. )
  316. )
  317. assert result.scalar_one_or_none() is None
  318. class TestGetSpoolmanSlotAssignment:
  319. @pytest.mark.asyncio
  320. @pytest.mark.integration
  321. async def test_get_returns_matched_spool(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
  322. """GET /slot-assignments returns the spool whose ID is in the local table."""
  323. # First assign so the row exists
  324. await async_client.post(
  325. "/api/v1/spoolman/inventory/slot-assignments",
  326. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  327. )
  328. mock_client.get_spool.reset_mock()
  329. response = await async_client.get(
  330. "/api/v1/spoolman/inventory/slot-assignments",
  331. params={"printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  332. )
  333. assert response.status_code == 200
  334. body = response.json()
  335. assert body is not None
  336. assert body["id"] == 10
  337. mock_client.get_spool.assert_awaited_once_with(10)
  338. @pytest.mark.asyncio
  339. @pytest.mark.integration
  340. async def test_get_returns_null_when_no_assignment(
  341. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  342. ):
  343. """GET /slot-assignments returns null when no local row exists for the slot."""
  344. response = await async_client.get(
  345. "/api/v1/spoolman/inventory/slot-assignments",
  346. params={"printer_id": test_printer.id, "ams_id": 1, "tray_id": 0},
  347. )
  348. assert response.status_code == 200
  349. assert response.json() is None
  350. @pytest.mark.asyncio
  351. @pytest.mark.integration
  352. async def test_get_printer_not_found(self, async_client: AsyncClient, slot_settings, mock_client):
  353. """GET /slot-assignments with unknown printer_id returns 404."""
  354. response = await async_client.get(
  355. "/api/v1/spoolman/inventory/slot-assignments",
  356. params={"printer_id": 99999, "ams_id": 0, "tray_id": 0},
  357. )
  358. assert response.status_code == 404
  359. @pytest.mark.asyncio
  360. @pytest.mark.integration
  361. async def test_get_missing_params(self, async_client: AsyncClient, slot_settings, mock_client):
  362. """GET /slot-assignments without required params returns 422."""
  363. response = await async_client.get("/api/v1/spoolman/inventory/slot-assignments")
  364. assert response.status_code == 422
  365. @pytest.mark.asyncio
  366. @pytest.mark.integration
  367. async def test_get_returns_null_and_cleans_stale_when_spool_deleted_in_spoolman(
  368. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  369. ):
  370. """GET /slot-assignments returns null and removes the stale row when Spoolman returns 404."""
  371. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  372. from backend.app.services.spoolman import SpoolmanNotFoundError
  373. # Assign spool 10 first
  374. await async_client.post(
  375. "/api/v1/spoolman/inventory/slot-assignments",
  376. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  377. )
  378. # Simulate spool 10 being deleted from Spoolman (404 via SpoolmanNotFoundError)
  379. mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("spool 10 not found"))
  380. response = await async_client.get(
  381. "/api/v1/spoolman/inventory/slot-assignments",
  382. params={"printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  383. )
  384. assert response.status_code == 200
  385. assert response.json() is None
  386. # Stale row must have been removed
  387. await db_session.refresh(test_printer) # ensure session is fresh
  388. result = await db_session.execute(
  389. select(SpoolmanSlotAssignment).where(
  390. SpoolmanSlotAssignment.printer_id == test_printer.id,
  391. SpoolmanSlotAssignment.ams_id == 0,
  392. SpoolmanSlotAssignment.tray_id == 0,
  393. )
  394. )
  395. assert result.scalar_one_or_none() is None
  396. @pytest.mark.asyncio
  397. @pytest.mark.integration
  398. async def test_get_propagates_503_from_spoolman(
  399. self, async_client: AsyncClient, slot_settings, test_printer, mock_client
  400. ):
  401. """GET /slot-assignments propagates a 503 from Spoolman instead of silently returning null."""
  402. from backend.app.services.spoolman import SpoolmanUnavailableError
  403. # Assign spool 10 first so a local row exists
  404. await async_client.post(
  405. "/api/v1/spoolman/inventory/slot-assignments",
  406. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  407. )
  408. # Simulate Spoolman being unreachable
  409. mock_client.get_spool = AsyncMock(side_effect=SpoolmanUnavailableError("timeout"))
  410. response = await async_client.get(
  411. "/api/v1/spoolman/inventory/slot-assignments",
  412. params={"printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  413. )
  414. assert response.status_code == 503
  415. class TestGetAllSpoolmanSlotAssignments:
  416. @pytest.mark.asyncio
  417. @pytest.mark.integration
  418. async def test_get_all_returns_empty_list(self, async_client: AsyncClient, slot_settings, mock_client):
  419. """GET /slot-assignments/all returns [] when no assignments exist."""
  420. response = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
  421. assert response.status_code == 200
  422. assert response.json() == []
  423. @pytest.mark.asyncio
  424. @pytest.mark.integration
  425. async def test_get_all_returns_all_rows(self, async_client: AsyncClient, slot_settings, test_printer, mock_client):
  426. """GET /slot-assignments/all returns all existing assignments."""
  427. await async_client.post(
  428. "/api/v1/spoolman/inventory/slot-assignments",
  429. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  430. )
  431. await async_client.post(
  432. "/api/v1/spoolman/inventory/slot-assignments",
  433. json={"spoolman_spool_id": 20, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 1},
  434. )
  435. response = await async_client.get("/api/v1/spoolman/inventory/slot-assignments/all")
  436. assert response.status_code == 200
  437. body = response.json()
  438. assert len(body) == 2
  439. spool_ids = {r["spoolman_spool_id"] for r in body}
  440. assert spool_ids == {10, 20}
  441. @pytest.mark.asyncio
  442. @pytest.mark.integration
  443. async def test_get_all_filters_by_printer(
  444. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  445. ):
  446. """GET /slot-assignments/all?printer_id=X only returns that printer's rows."""
  447. from backend.app.models.printer import Printer
  448. # Create a second printer via DB directly (no Spoolman mock needed for printer creation)
  449. other = Printer(
  450. name="Other Printer",
  451. serial_number="SLOTTEST002",
  452. ip_address="192.168.1.101",
  453. access_code="87654321",
  454. )
  455. db_session.add(other)
  456. await db_session.commit()
  457. await db_session.refresh(other)
  458. # Assign via API for test_printer
  459. await async_client.post(
  460. "/api/v1/spoolman/inventory/slot-assignments",
  461. json={"spoolman_spool_id": 10, "printer_id": test_printer.id, "ams_id": 0, "tray_id": 0},
  462. )
  463. # Assign via API for other printer
  464. await async_client.post(
  465. "/api/v1/spoolman/inventory/slot-assignments",
  466. json={"spoolman_spool_id": 99, "printer_id": other.id, "ams_id": 0, "tray_id": 0},
  467. )
  468. response = await async_client.get(
  469. "/api/v1/spoolman/inventory/slot-assignments/all",
  470. params={"printer_id": test_printer.id},
  471. )
  472. assert response.status_code == 200
  473. body = response.json()
  474. assert len(body) == 1
  475. assert body[0]["spoolman_spool_id"] == 10
  476. assert body[0]["printer_id"] == test_printer.id
  477. class TestCascadeDeletePrinter:
  478. @pytest.mark.asyncio
  479. @pytest.mark.integration
  480. async def test_delete_printer_removes_slot_assignments(
  481. self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
  482. ):
  483. """DELETE /printers/{id} removes all slot assignments for that printer.
  484. SQLite does not enforce FK cascades automatically. The delete_printer
  485. endpoint must explicitly delete SpoolmanSlotAssignment rows so no
  486. orphaned rows survive after the printer record is gone.
  487. """
  488. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  489. # Assign two spools to different AMS slots on the test printer
  490. for tray_id, spool_id in [(0, 10), (1, 20)]:
  491. await async_client.post(
  492. "/api/v1/spoolman/inventory/slot-assignments",
  493. json={
  494. "spoolman_spool_id": spool_id,
  495. "printer_id": test_printer.id,
  496. "ams_id": 0,
  497. "tray_id": tray_id,
  498. },
  499. )
  500. # Verify both rows exist
  501. pre = await db_session.execute(
  502. select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
  503. )
  504. assert len(pre.scalars().all()) == 2
  505. # Delete the printer
  506. del_resp = await async_client.delete(f"/api/v1/printers/{test_printer.id}")
  507. assert del_resp.status_code == 200
  508. # All slot assignment rows for the deleted printer must be gone
  509. post = await db_session.execute(
  510. select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
  511. )
  512. assert post.scalars().all() == []
  513. class TestModeSwitchKeepsAssignments:
  514. """#2812 — the mode toggle no longer deletes anything.
  515. It used to empty the other mode's table on every switch, so opening the
  516. settings page to see what Spoolman mode did destroyed the built-in slot
  517. assignments, with no confirmation and no way back. Both tables are kept now
  518. and the readers that could be confused by a row in the inactive one ask
  519. which mode is active instead (``spoolman_owns_assignments``).
  520. """
  521. @pytest.mark.asyncio
  522. @pytest.mark.integration
  523. async def test_switch_to_internal_mode_keeps_spoolman_slot_assignments(
  524. self, async_client: AsyncClient, db_session, test_printer
  525. ):
  526. from backend.app.models.settings import Settings
  527. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  528. db_session.add(Settings(key="spoolman_enabled", value="true"))
  529. db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=0, tray_id=0, spoolman_spool_id=1))
  530. await db_session.commit()
  531. resp = await async_client.put("/api/v1/settings/spoolman", json={"spoolman_enabled": "false"})
  532. assert resp.status_code == 200
  533. rows = await db_session.execute(
  534. select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id)
  535. )
  536. assert len(rows.scalars().all()) == 1
  537. @pytest.mark.asyncio
  538. @pytest.mark.integration
  539. async def test_switch_to_spoolman_mode_keeps_builtin_assignments(
  540. self, async_client: AsyncClient, db_session, test_printer
  541. ):
  542. """The reported case: four toggles in 85 seconds took the reporter's
  543. built-in assignments and never gave them back."""
  544. from backend.app.models.settings import Settings
  545. from backend.app.models.spool import Spool
  546. from backend.app.models.spool_assignment import SpoolAssignment
  547. db_session.add(Settings(key="spoolman_enabled", value="false"))
  548. spool = Spool(material="PLA", rgba="000000FF")
  549. db_session.add(spool)
  550. await db_session.flush()
  551. db_session.add(SpoolAssignment(printer_id=test_printer.id, ams_id=0, tray_id=0, spool_id=spool.id))
  552. await db_session.commit()
  553. on = await async_client.put("/api/v1/settings/spoolman", json={"spoolman_enabled": "true"})
  554. assert on.status_code == 200
  555. off = await async_client.put("/api/v1/settings/spoolman", json={"spoolman_enabled": "false"})
  556. assert off.status_code == 200
  557. rows = await db_session.execute(select(SpoolAssignment).where(SpoolAssignment.printer_id == test_printer.id))
  558. assert len(rows.scalars().all()) == 1