test_spoolman_slot_assignments.py 24 KB

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