test_assign_move_one_slot_per_spool.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652
  1. """A move assigns a spool to a slot and takes it out of its other slots in one commit (#1574).
  2. The QR scan assigns the spool in the user's hand, which is in one slot at most.
  3. The scan flow used to unassign the old slot and assign the new one in separate
  4. requests, so a failure between them left the spool in no slot. With ``move``
  5. both assign routes do it in the transaction that writes the new slot:
  6. * the spool's other slots are cleared and the target's occupant replaced;
  7. * a failure changes nothing;
  8. * two moves of the same spool at once take turns and leave it in one slot,
  9. wherever it was before;
  10. * a slot on a printer outside the caller's scope stays assigned (#1727);
  11. * in Spoolman mode, a fallback tag the spool carries for a slot it leaves is
  12. cleared first, and a refusal from Spoolman stops the move; RFID and other
  13. tags stay; the slots it left and the one it went to are announced.
  14. Without ``move`` the routes behave as before.
  15. """
  16. import asyncio
  17. import json
  18. from unittest.mock import AsyncMock, MagicMock, patch
  19. import pytest
  20. from httpx import AsyncClient
  21. from sqlalchemy import event, select
  22. from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
  23. from backend.app.core.auth import get_printer_scope_if_auth_enabled
  24. from backend.app.core.database import Base, _set_sqlite_pragmas
  25. from backend.app.core.printer_scope import PrinterScope
  26. from backend.app.main import app
  27. from backend.app.models.spool import Spool
  28. from backend.app.models.spool_assignment import SpoolAssignment
  29. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  30. from backend.app.services.ams_assignment_lock import (
  31. ams_assignment_locks,
  32. get_ams_assignment_lock,
  33. get_spool_move_lock,
  34. spool_move_locks,
  35. )
  36. from backend.app.services.spoolman import SpoolmanUnavailableError
  37. from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
  38. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  39. @pytest.fixture(autouse=True)
  40. def _fresh_locks():
  41. """A contended lock binds to its test's event loop; printer and spool ids repeat."""
  42. ams_assignment_locks.clear()
  43. spool_move_locks.clear()
  44. yield
  45. ams_assignment_locks.clear()
  46. spool_move_locks.clear()
  47. async def _until_queued(lock: asyncio.Lock, requests: int) -> None:
  48. """Return once that many requests wait for the lock."""
  49. async with asyncio.timeout(5):
  50. while len(lock._waiters or ()) < requests:
  51. await asyncio.sleep(0.01)
  52. @pytest.fixture
  53. def scope_limited_to():
  54. """Run requests as a caller who can only see the given printers."""
  55. def _limit(*printer_ids: int) -> None:
  56. app.dependency_overrides[get_printer_scope_if_auth_enabled] = lambda: PrinterScope(
  57. printer_ids=frozenset(printer_ids)
  58. )
  59. yield _limit
  60. app.dependency_overrides.pop(get_printer_scope_if_auth_enabled, None)
  61. async def _commit_fails(self):
  62. raise RuntimeError("database went away")
  63. # ---------------------------------------------------------------------------
  64. # Built-in inventory: POST /inventory/assignments
  65. # ---------------------------------------------------------------------------
  66. @pytest.fixture
  67. def offline_printers():
  68. """No printer is connected, so the assign stores the row and skips MQTT."""
  69. ws = MagicMock()
  70. ws.broadcast = AsyncMock()
  71. with (
  72. patch("backend.app.services.printer_manager.printer_manager.get_status", MagicMock(return_value=None)),
  73. patch("backend.app.services.printer_manager.printer_manager.get_client", MagicMock(return_value=None)),
  74. patch("backend.app.api.routes.inventory.ws_manager", ws),
  75. ):
  76. yield ws
  77. async def _spool(db: AsyncSession, **kwargs) -> Spool:
  78. spool = Spool(material="PLA", label_weight=1000, weight_used=0, **kwargs)
  79. db.add(spool)
  80. await db.commit()
  81. await db.refresh(spool)
  82. return spool
  83. async def _assign_row(db: AsyncSession, spool_id: int, printer_id: int, ams_id: int, tray_id: int) -> None:
  84. db.add(SpoolAssignment(spool_id=spool_id, printer_id=printer_id, ams_id=ams_id, tray_id=tray_id))
  85. await db.commit()
  86. async def _slots(db: AsyncSession) -> set[tuple[int, int, int, int]]:
  87. """Every built-in assignment as (spool, printer, ams, tray), as the database has it now."""
  88. rows = (await db.execute(select(SpoolAssignment).execution_options(populate_existing=True))).scalars().all()
  89. return {(r.spool_id, r.printer_id, r.ams_id, r.tray_id) for r in rows}
  90. def _assign(spool_id: int, printer_id: int, ams_id: int, tray_id: int, **extra) -> dict:
  91. return {"spool_id": spool_id, "printer_id": printer_id, "ams_id": ams_id, "tray_id": tray_id, **extra}
  92. class TestBuiltInMove:
  93. async def test_move_takes_the_spool_out_of_its_old_slot(
  94. self, async_client: AsyncClient, db_session, printer_factory, offline_printers
  95. ):
  96. printer = await printer_factory()
  97. spool = await _spool(db_session)
  98. await _assign_row(db_session, spool.id, printer.id, 0, 1)
  99. response = await async_client.post(
  100. "/api/v1/inventory/assignments", json=_assign(spool.id, printer.id, 1, 0, move=True)
  101. )
  102. assert response.status_code == 200, response.text
  103. assert await _slots(db_session) == {(spool.id, printer.id, 1, 0)}
  104. async def test_move_takes_the_spool_out_of_another_printers_slot(
  105. self, async_client: AsyncClient, db_session, printer_factory, offline_printers
  106. ):
  107. here = await printer_factory(name="Here")
  108. there = await printer_factory(name="There")
  109. spool = await _spool(db_session)
  110. await _assign_row(db_session, spool.id, there.id, 2, 3)
  111. response = await async_client.post(
  112. "/api/v1/inventory/assignments", json=_assign(spool.id, here.id, 0, 0, move=True)
  113. )
  114. assert response.status_code == 200, response.text
  115. assert await _slots(db_session) == {(spool.id, here.id, 0, 0)}
  116. # Both slots changed, so both are announced.
  117. announced = {
  118. (c.args[0]["printer_id"], c.args[0]["ams_id"], c.args[0]["tray_id"])
  119. for c in offline_printers.broadcast.await_args_list
  120. if c.args[0]["type"] == "spool_assignment_changed"
  121. }
  122. assert announced == {(there.id, 2, 3), (here.id, 0, 0)}
  123. async def test_move_replaces_the_target_slots_spool(
  124. self, async_client: AsyncClient, db_session, printer_factory, offline_printers
  125. ):
  126. printer = await printer_factory()
  127. moving = await _spool(db_session)
  128. occupant = await _spool(db_session)
  129. await _assign_row(db_session, moving.id, printer.id, 0, 1)
  130. await _assign_row(db_session, occupant.id, printer.id, 0, 2)
  131. response = await async_client.post(
  132. "/api/v1/inventory/assignments", json=_assign(moving.id, printer.id, 0, 2, move=True)
  133. )
  134. assert response.status_code == 200, response.text
  135. assert await _slots(db_session) == {(moving.id, printer.id, 0, 2)}
  136. async def test_without_move_the_old_slot_stays(
  137. self, async_client: AsyncClient, db_session, printer_factory, offline_printers
  138. ):
  139. """A plain assign is per slot, as before: the "Show all spools" path relies on it."""
  140. printer = await printer_factory()
  141. spool = await _spool(db_session)
  142. await _assign_row(db_session, spool.id, printer.id, 0, 1)
  143. response = await async_client.post("/api/v1/inventory/assignments", json=_assign(spool.id, printer.id, 1, 0))
  144. assert response.status_code == 200, response.text
  145. assert await _slots(db_session) == {(spool.id, printer.id, 0, 1), (spool.id, printer.id, 1, 0)}
  146. async def test_a_slot_outside_the_callers_scope_stays(
  147. self, async_client: AsyncClient, db_session, printer_factory, offline_printers, scope_limited_to
  148. ):
  149. mine = await printer_factory(name="Mine")
  150. hidden = await printer_factory(name="Hidden")
  151. spool = await _spool(db_session)
  152. await _assign_row(db_session, spool.id, hidden.id, 0, 0)
  153. scope_limited_to(mine.id)
  154. response = await async_client.post(
  155. "/api/v1/inventory/assignments", json=_assign(spool.id, mine.id, 0, 1, move=True)
  156. )
  157. assert response.status_code == 200, response.text
  158. assert await _slots(db_session) == {(spool.id, hidden.id, 0, 0), (spool.id, mine.id, 0, 1)}
  159. async def test_a_failed_move_leaves_the_spool_where_it_was(
  160. self, async_client: AsyncClient, db_session, printer_factory, offline_printers
  161. ):
  162. printer = await printer_factory()
  163. spool = await _spool(db_session)
  164. await _assign_row(db_session, spool.id, printer.id, 0, 1)
  165. with patch.object(AsyncSession, "commit", _commit_fails), pytest.raises(RuntimeError):
  166. await async_client.post(
  167. "/api/v1/inventory/assignments", json=_assign(spool.id, printer.id, 1, 0, move=True)
  168. )
  169. assert await _slots(db_session) == {(spool.id, printer.id, 0, 1)}
  170. async def test_move_waits_for_the_lock_of_the_printer_it_leaves(
  171. self, async_client: AsyncClient, db_session, printer_factory, offline_printers
  172. ):
  173. """The old slot's row is written too, so its printer's lock is taken (SA-5)."""
  174. here = await printer_factory(name="Here")
  175. there = await printer_factory(name="There")
  176. spool = await _spool(db_session)
  177. await _assign_row(db_session, spool.id, there.id, 0, 0)
  178. lock = get_ams_assignment_lock(there.id)
  179. await lock.acquire()
  180. try:
  181. task = asyncio.ensure_future(
  182. async_client.post("/api/v1/inventory/assignments", json=_assign(spool.id, here.id, 0, 0, move=True))
  183. )
  184. await asyncio.sleep(0.3)
  185. assert not task.done(), "the move cleared the old slot without waiting for its printer's lock"
  186. finally:
  187. lock.release()
  188. response = await asyncio.wait_for(task, 10)
  189. assert response.status_code == 200, response.text
  190. async def test_a_slot_the_spool_gained_while_the_move_waited_is_cleared_too(
  191. self, async_client: AsyncClient, db_session, printer_factory, offline_printers
  192. ):
  193. """The printers to lock are read again once the locks are held: here a
  194. plain assign put the spool on a printer the move had not locked."""
  195. here = await printer_factory(name="Here")
  196. there = await printer_factory(name="There")
  197. spool = await _spool(db_session)
  198. lock = get_ams_assignment_lock(here.id)
  199. await lock.acquire()
  200. try:
  201. move = asyncio.ensure_future(
  202. async_client.post("/api/v1/inventory/assignments", json=_assign(spool.id, here.id, 0, 0, move=True))
  203. )
  204. await _until_queued(lock, 1)
  205. await _assign_row(db_session, spool.id, there.id, 1, 1)
  206. finally:
  207. lock.release()
  208. response = await asyncio.wait_for(move, 10)
  209. assert response.status_code == 200, response.text
  210. assert await _slots(db_session) == {(spool.id, here.id, 0, 0)}
  211. class TestBuiltInConcurrentMoves:
  212. @pytest.fixture
  213. async def test_engine(self, test_engine, tmp_path):
  214. """Two requests at once need a connection each, as the app's pool gives
  215. them. The suite's in-memory SQLite database is a single connection,
  216. shared by every session, and one request's commit there fails while the
  217. other's statement is open ("SQL statements in progress"). On SQLite
  218. these tests get a database file instead; PostgreSQL pools already."""
  219. if test_engine.dialect.name != "sqlite":
  220. yield test_engine
  221. return
  222. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'moves.db'}")
  223. event.listen(engine.sync_engine, "connect", _set_sqlite_pragmas)
  224. async with engine.begin() as conn:
  225. await conn.run_sync(Base.metadata.create_all)
  226. yield engine
  227. await engine.dispose()
  228. @pytest.mark.parametrize("starts_in_a_slot", [True, False], ids=["from-a-slot", "from-no-slot"])
  229. async def test_two_moves_of_the_same_spool_leave_it_in_one_slot(
  230. self, async_client: AsyncClient, db_session, printer_factory, offline_printers, starts_in_a_slot
  231. ):
  232. """#1606 review: the printers to lock were read before the locks were
  233. taken, so the move served second missed where the first put the spool.
  234. A spool in no slot was worse: each move locked only its own target's
  235. printer, and both assigned it. Moves of one spool now take turns."""
  236. start = await printer_factory(name="Start")
  237. here = await printer_factory(name="Here")
  238. there = await printer_factory(name="There")
  239. spool = await _spool(db_session)
  240. if starts_in_a_slot:
  241. await _assign_row(db_session, spool.id, start.id, 0, 0)
  242. lock = get_spool_move_lock("inventory", spool.id)
  243. await lock.acquire()
  244. try:
  245. moves = [
  246. asyncio.ensure_future(
  247. async_client.post("/api/v1/inventory/assignments", json=_assign(spool.id, pid, 0, 0, move=True))
  248. )
  249. for pid in (here.id, there.id)
  250. ]
  251. # Both have read where the spool is and wait for their turn.
  252. await _until_queued(lock, 2)
  253. finally:
  254. lock.release()
  255. responses = await asyncio.wait_for(asyncio.gather(*moves), 10)
  256. assert [r.status_code for r in responses] == [200, 200]
  257. slots = await _slots(db_session)
  258. assert len(slots) == 1, slots
  259. # ---------------------------------------------------------------------------
  260. # Spoolman: POST /spoolman/inventory/slot-assignments
  261. # ---------------------------------------------------------------------------
  262. SPOOLMAN_SPOOL_ID = 10
  263. def _spoolman_spool(tag: str | None = None) -> dict:
  264. return {
  265. "id": SPOOLMAN_SPOOL_ID,
  266. "filament": {"id": 1, "name": "PLA Basic", "material": "PLA", "color_hex": "FF0000", "weight": 1000},
  267. "remaining_weight": 800.0,
  268. "used_weight": 200.0,
  269. "location": None,
  270. "comment": None,
  271. "registered": "2024-01-01T00:00:00+00:00",
  272. "archived": False,
  273. "extra": {} if tag is None else {"tag": json.dumps(tag)},
  274. }
  275. @pytest.fixture
  276. async def spoolman_settings(db_session):
  277. from backend.app.models.settings import Settings
  278. db_session.add(Settings(key="spoolman_enabled", value="true"))
  279. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  280. await db_session.commit()
  281. @pytest.fixture
  282. def spoolman():
  283. client = MagicMock()
  284. client.base_url = "http://localhost:7912"
  285. client.health_check = AsyncMock(return_value=True)
  286. client.get_spool = AsyncMock(return_value=_spoolman_spool())
  287. # The target slot's stale-tag cleanup (#1457) lists the spools; none here.
  288. client.get_spools = AsyncMock(return_value=[])
  289. client.merge_spool_extra = AsyncMock(return_value={"id": SPOOLMAN_SPOOL_ID, "extra": {}})
  290. with (
  291. patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=client)),
  292. patch("backend.app.api.routes.spoolman_inventory.printer_manager.get_client", MagicMock(return_value=None)),
  293. ):
  294. yield client
  295. @pytest.fixture
  296. def spoolman_ws():
  297. """What the Spoolman routes announce over the websocket."""
  298. ws = MagicMock()
  299. ws.broadcast = AsyncMock()
  300. with patch("backend.app.api.routes.spoolman_inventory.ws_manager", ws):
  301. yield ws
  302. def _announced_slots(ws) -> set[tuple[int, int, int]]:
  303. return {
  304. (c.args[0]["printer_id"], c.args[0]["ams_id"], c.args[0]["tray_id"])
  305. for c in ws.broadcast.await_args_list
  306. if c.args[0].get("type") == "spool_assignment_changed"
  307. }
  308. async def _spoolman_row(db: AsyncSession, printer_id: int, ams_id: int, tray_id: int) -> None:
  309. db.add(
  310. SpoolmanSlotAssignment(
  311. printer_id=printer_id, ams_id=ams_id, tray_id=tray_id, spoolman_spool_id=SPOOLMAN_SPOOL_ID
  312. )
  313. )
  314. await db.commit()
  315. async def _spoolman_slots(db: AsyncSession) -> set[tuple[int, int, int, int]]:
  316. """Every Spoolman slot assignment as (spool, printer, ams, tray), as the database has it now."""
  317. rows = (await db.execute(select(SpoolmanSlotAssignment).execution_options(populate_existing=True))).scalars().all()
  318. return {(r.spoolman_spool_id, r.printer_id, r.ams_id, r.tray_id) for r in rows}
  319. def _spoolman_assign(printer_id: int, ams_id: int, tray_id: int, **extra) -> dict:
  320. return {
  321. "spoolman_spool_id": SPOOLMAN_SPOOL_ID,
  322. "printer_id": printer_id,
  323. "ams_id": ams_id,
  324. "tray_id": tray_id,
  325. **extra,
  326. }
  327. def _tag_writes(client) -> list[str]:
  328. """The extra.tag values written to the moved spool, in order."""
  329. return [
  330. json.loads(c.args[1]["tag"])
  331. for c in client.merge_spool_extra.await_args_list
  332. if c.args[0] == SPOOLMAN_SPOOL_ID and "tag" in c.args[1]
  333. ]
  334. class TestSpoolmanMove:
  335. async def test_move_takes_the_spool_out_of_its_old_slot(
  336. self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
  337. ):
  338. here = await printer_factory(name="Here")
  339. there = await printer_factory(name="There")
  340. await _spoolman_row(db_session, there.id, 1, 2)
  341. response = await async_client.post(
  342. "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(here.id, 0, 0, move=True)
  343. )
  344. assert response.status_code == 200, response.text
  345. assert await _spoolman_slots(db_session) == {(SPOOLMAN_SPOOL_ID, here.id, 0, 0)}
  346. assert _tag_writes(spoolman) == []
  347. async def test_without_move_the_old_slot_stays(
  348. self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
  349. ):
  350. printer = await printer_factory()
  351. await _spoolman_row(db_session, printer.id, 0, 1)
  352. response = await async_client.post(
  353. "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 0, 2)
  354. )
  355. assert response.status_code == 200, response.text
  356. assert await _spoolman_slots(db_session) == {
  357. (SPOOLMAN_SPOOL_ID, printer.id, 0, 1),
  358. (SPOOLMAN_SPOOL_ID, printer.id, 0, 2),
  359. }
  360. async def test_the_old_slots_fallback_tag_is_cleared(
  361. self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
  362. ):
  363. """Linked to a slot without RFID, the spool carries that slot's fallback tag.
  364. Left on it, the tag lookup would find the spool for its old slot again."""
  365. printer = await printer_factory()
  366. await _spoolman_row(db_session, printer.id, 0, 1)
  367. spoolman.get_spool.return_value = _spoolman_spool(
  368. tag=get_fallback_spool_tag_for_slot(printer.serial_number, 0, 1)
  369. )
  370. response = await async_client.post(
  371. "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 1, 0, move=True)
  372. )
  373. assert response.status_code == 200, response.text
  374. assert _tag_writes(spoolman) == [""]
  375. assert await _spoolman_slots(db_session) == {(SPOOLMAN_SPOOL_ID, printer.id, 1, 0)}
  376. @pytest.mark.parametrize(
  377. "tag",
  378. [
  379. pytest.param("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", id="rfid-tray-uuid"),
  380. pytest.param("05CC1E0F00000100", id="rfid-tag-uid"),
  381. ],
  382. )
  383. async def test_a_tag_of_the_spool_itself_stays(
  384. self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman, tag
  385. ):
  386. printer = await printer_factory()
  387. await _spoolman_row(db_session, printer.id, 0, 1)
  388. spoolman.get_spool.return_value = _spoolman_spool(tag=tag)
  389. response = await async_client.post(
  390. "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 1, 0, move=True)
  391. )
  392. assert response.status_code == 200, response.text
  393. assert _tag_writes(spoolman) == []
  394. async def test_the_fallback_tag_of_a_slot_it_does_not_leave_stays(
  395. self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
  396. ):
  397. """Only the slots the spool leaves are matched -- here its tag is the target's own."""
  398. printer = await printer_factory()
  399. await _spoolman_row(db_session, printer.id, 0, 1)
  400. spoolman.get_spool.return_value = _spoolman_spool(
  401. tag=get_fallback_spool_tag_for_slot(printer.serial_number, 1, 0)
  402. )
  403. response = await async_client.post(
  404. "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 1, 0, move=True)
  405. )
  406. assert response.status_code == 200, response.text
  407. assert _tag_writes(spoolman) == []
  408. async def test_a_refused_tag_cleanup_keeps_the_old_assignment(
  409. self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
  410. ):
  411. printer = await printer_factory()
  412. await _spoolman_row(db_session, printer.id, 0, 1)
  413. spoolman.get_spool.return_value = _spoolman_spool(
  414. tag=get_fallback_spool_tag_for_slot(printer.serial_number, 0, 1)
  415. )
  416. spoolman.merge_spool_extra.side_effect = SpoolmanUnavailableError("down")
  417. response = await async_client.post(
  418. "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 1, 0, move=True)
  419. )
  420. assert response.status_code == 503
  421. assert "not moved" in response.json()["detail"]
  422. assert await _spoolman_slots(db_session) == {(SPOOLMAN_SPOOL_ID, printer.id, 0, 1)}
  423. async def test_a_failed_save_puts_the_cleared_tag_back(
  424. self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
  425. ):
  426. printer = await printer_factory()
  427. await _spoolman_row(db_session, printer.id, 0, 1)
  428. old_tag = get_fallback_spool_tag_for_slot(printer.serial_number, 0, 1)
  429. spoolman.get_spool.return_value = _spoolman_spool(tag=old_tag)
  430. with patch.object(AsyncSession, "commit", _commit_fails):
  431. response = await async_client.post(
  432. "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 1, 0, move=True)
  433. )
  434. assert response.status_code == 500
  435. assert _tag_writes(spoolman) == ["", old_tag]
  436. assert await _spoolman_slots(db_session) == {(SPOOLMAN_SPOOL_ID, printer.id, 0, 1)}
  437. async def test_a_slot_outside_the_callers_scope_stays(
  438. self,
  439. async_client: AsyncClient,
  440. db_session,
  441. printer_factory,
  442. spoolman_settings,
  443. spoolman,
  444. scope_limited_to,
  445. ):
  446. mine = await printer_factory(name="Mine")
  447. hidden = await printer_factory(name="Hidden")
  448. await _spoolman_row(db_session, hidden.id, 0, 0)
  449. # Even its fallback tag: that slot is not the caller's to change.
  450. spoolman.get_spool.return_value = _spoolman_spool(
  451. tag=get_fallback_spool_tag_for_slot(hidden.serial_number, 0, 0)
  452. )
  453. scope_limited_to(mine.id)
  454. response = await async_client.post(
  455. "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(mine.id, 0, 1, move=True)
  456. )
  457. assert response.status_code == 200, response.text
  458. assert _tag_writes(spoolman) == []
  459. assert await _spoolman_slots(db_session) == {
  460. (SPOOLMAN_SPOOL_ID, hidden.id, 0, 0),
  461. (SPOOLMAN_SPOOL_ID, mine.id, 0, 1),
  462. }
  463. async def test_a_move_announces_the_slots_it_left_and_the_one_it_went_to(
  464. self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman, spoolman_ws
  465. ):
  466. """#1606 review: other tabs and the SpoolBuddy kiosk kept the spool in its old slot."""
  467. here = await printer_factory(name="Here")
  468. there = await printer_factory(name="There")
  469. await _spoolman_row(db_session, there.id, 1, 2)
  470. response = await async_client.post(
  471. "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(here.id, 0, 0, move=True)
  472. )
  473. assert response.status_code == 200, response.text
  474. assert _announced_slots(spoolman_ws) == {(there.id, 1, 2), (here.id, 0, 0)}
  475. async def test_a_plain_assign_announces_its_slot(
  476. self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman, spoolman_ws
  477. ):
  478. printer = await printer_factory()
  479. response = await async_client.post(
  480. "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(printer.id, 0, 3)
  481. )
  482. assert response.status_code == 200, response.text
  483. assert _announced_slots(spoolman_ws) == {(printer.id, 0, 3)}
  484. async def test_a_move_also_clears_a_slot_the_spool_gained_meanwhile(
  485. self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman, spoolman_ws
  486. ):
  487. """Between this move reading the spool's slots and deleting them,
  488. something else (a link, the AMS sync) gave it another slot: that one
  489. goes too."""
  490. from backend.app.api.routes import spoolman_inventory
  491. here = await printer_factory(name="Here")
  492. there = await printer_factory(name="There")
  493. await _spoolman_row(db_session, there.id, 0, 0)
  494. clear_tag = spoolman_inventory._clear_vacated_slot_fallback_tag
  495. async def another_slot_is_committed(*args, **kwargs):
  496. await _spoolman_row(db_session, there.id, 1, 1)
  497. return await clear_tag(*args, **kwargs)
  498. with patch.object(spoolman_inventory, "_clear_vacated_slot_fallback_tag", another_slot_is_committed):
  499. response = await async_client.post(
  500. "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(here.id, 0, 0, move=True)
  501. )
  502. assert response.status_code == 200, response.text
  503. assert await _spoolman_slots(db_session) == {(SPOOLMAN_SPOOL_ID, here.id, 0, 0)}
  504. assert _announced_slots(spoolman_ws) == {(there.id, 0, 0), (there.id, 1, 1), (here.id, 0, 0)}
  505. async def test_two_moves_of_the_same_spool_take_turns(
  506. self, async_client: AsyncClient, db_session, printer_factory, spoolman_settings, spoolman
  507. ):
  508. """#1606 review: on PostgreSQL a move's DELETE missed the slot another
  509. move of the spool inserted and committed after the DELETE began, and the
  510. spool ended in both. SQLite's single writer hid it."""
  511. start = await printer_factory(name="Start")
  512. here = await printer_factory(name="Here")
  513. there = await printer_factory(name="There")
  514. await _spoolman_row(db_session, start.id, 0, 0)
  515. lock = get_spool_move_lock("spoolman", SPOOLMAN_SPOOL_ID)
  516. await lock.acquire()
  517. try:
  518. moves = [
  519. asyncio.ensure_future(
  520. async_client.post(
  521. "/api/v1/spoolman/inventory/slot-assignments", json=_spoolman_assign(pid, 0, 0, move=True)
  522. )
  523. )
  524. for pid in (here.id, there.id)
  525. ]
  526. await _until_queued(lock, 2)
  527. finally:
  528. lock.release()
  529. responses = await asyncio.wait_for(asyncio.gather(*moves), 10)
  530. assert [r.status_code for r in responses] == [200, 200]
  531. slots = await _spoolman_slots(db_session)
  532. assert len(slots) == 1, slots