test_print_batch_orders.py 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105
  1. """Integration tests for batch orders — per-plate targets and staged dispatch (#342).
  2. The behaviour these lock down that the pre-#342 batch could not express: a
  3. failed or cancelled run does not satisfy a target, so the order keeps saying it
  4. owes a print until one actually completes.
  5. """
  6. from datetime import datetime
  7. import pytest
  8. from httpx import AsyncClient
  9. @pytest.fixture
  10. async def printer_factory(db_session):
  11. _counter = [0]
  12. async def _create_printer(**kwargs):
  13. from backend.app.models.printer import Printer
  14. _counter[0] += 1
  15. counter = _counter[0]
  16. defaults = {
  17. "name": f"Batch Printer {counter}",
  18. "ip_address": f"192.168.9.{100 + counter}",
  19. "serial_number": f"BATCHSERIAL{counter:04d}",
  20. "access_code": "12345678",
  21. "model": "X1C",
  22. }
  23. defaults.update(kwargs)
  24. printer = Printer(**defaults)
  25. db_session.add(printer)
  26. await db_session.commit()
  27. await db_session.refresh(printer)
  28. return printer
  29. return _create_printer
  30. @pytest.fixture
  31. async def archive_factory(db_session):
  32. _counter = [0]
  33. async def _create_archive(**kwargs):
  34. from backend.app.models.archive import PrintArchive
  35. _counter[0] += 1
  36. counter = _counter[0]
  37. defaults = {
  38. "filename": f"batch_order_{counter}.3mf",
  39. "print_name": f"Batch Order {counter}",
  40. "file_path": f"/tmp/batch_order_{counter}.3mf", # nosec B108
  41. "file_size": 2048,
  42. "content_hash": f"batchhash{counter:08d}",
  43. "status": "completed",
  44. }
  45. defaults.update(kwargs)
  46. archive = PrintArchive(**defaults)
  47. db_session.add(archive)
  48. await db_session.commit()
  49. await db_session.refresh(archive)
  50. return archive
  51. return _create_archive
  52. async def _create_order(async_client: AsyncClient, archive_id: int, plates: list[dict], **extra):
  53. payload = {"name": "Test Order", "archive_id": archive_id, "plates": plates}
  54. payload.update(extra)
  55. response = await async_client.post("/api/v1/queue/batches", json=payload)
  56. assert response.status_code == 200, response.text
  57. return response.json()
  58. async def _queue_item(async_client: AsyncClient, printer_id: int, archive_id: int, batch_id: int, **extra):
  59. payload = {"printer_id": printer_id, "archive_id": archive_id, "batch_id": batch_id}
  60. payload.update(extra)
  61. response = await async_client.post("/api/v1/queue/", json=payload)
  62. assert response.status_code == 200, response.text
  63. return response.json()
  64. async def _set_status(db_session, item_id: int, status: str):
  65. from backend.app.models.print_queue import PrintQueueItem
  66. item = await db_session.get(PrintQueueItem, item_id)
  67. item.status = status
  68. await db_session.commit()
  69. @pytest.mark.asyncio
  70. @pytest.mark.integration
  71. class TestBatchOrderTargets:
  72. async def test_order_reports_per_plate_targets(self, async_client, archive_factory):
  73. """The reporter's own example: plate 1 once, plate 2 twice, plate 3 three times."""
  74. archive = await archive_factory()
  75. order = await _create_order(
  76. async_client,
  77. archive.id,
  78. [
  79. {"plate_id": 1, "plate_name": "Base", "quantity_target": 1, "sort_order": 0},
  80. {"plate_id": 2, "quantity_target": 2, "sort_order": 1},
  81. {"plate_id": 3, "quantity_target": 3, "sort_order": 2},
  82. ],
  83. )
  84. assert order["has_targets"] is True
  85. assert order["target_count"] == 6
  86. assert order["remaining_count"] == 6
  87. assert [p["plate_id"] for p in order["plates"]] == [1, 2, 3]
  88. assert [p["quantity_target"] for p in order["plates"]] == [1, 2, 3]
  89. assert order["plates"][0]["plate_name"] == "Base"
  90. # Nothing dispatched yet, so nothing has been consumed.
  91. assert all(p["dispatched"] == 0 for p in order["plates"])
  92. async def test_zero_target_plate_is_allowed(self, async_client, archive_factory):
  93. """ "Plate 3 not required" keeps its row so it can be raised later."""
  94. archive = await archive_factory()
  95. order = await _create_order(
  96. async_client,
  97. archive.id,
  98. [{"plate_id": 1, "quantity_target": 2}, {"plate_id": 2, "quantity_target": 0}],
  99. )
  100. assert order["target_count"] == 2
  101. plate_two = next(p for p in order["plates"] if p["plate_id"] == 2)
  102. assert plate_two["quantity_target"] == 0
  103. assert plate_two["remaining"] == 0
  104. async def test_order_requesting_nothing_is_rejected(self, async_client, archive_factory):
  105. archive = await archive_factory()
  106. response = await async_client.post(
  107. "/api/v1/queue/batches",
  108. json={
  109. "name": "Empty",
  110. "archive_id": archive.id,
  111. "plates": [{"plate_id": 1, "quantity_target": 0}],
  112. },
  113. )
  114. assert response.status_code == 400
  115. assert "at least one print" in response.json()["detail"]
  116. async def test_duplicate_plate_is_rejected(self, async_client, archive_factory):
  117. archive = await archive_factory()
  118. response = await async_client.post(
  119. "/api/v1/queue/batches",
  120. json={
  121. "name": "Dupes",
  122. "archive_id": archive.id,
  123. "plates": [{"plate_id": 1, "quantity_target": 1}, {"plate_id": 1, "quantity_target": 2}],
  124. },
  125. )
  126. assert response.status_code == 400
  127. assert "Duplicate plate" in response.json()["detail"]
  128. async def test_duplicate_whole_file_plate_is_rejected(self, async_client, archive_factory):
  129. """NULL plate_id slips past the DB unique constraint, so the route must catch it."""
  130. archive = await archive_factory()
  131. response = await async_client.post(
  132. "/api/v1/queue/batches",
  133. json={
  134. "name": "Dupes",
  135. "archive_id": archive.id,
  136. "plates": [{"quantity_target": 1}, {"quantity_target": 2}],
  137. },
  138. )
  139. assert response.status_code == 400
  140. assert "whole file" in response.json()["detail"]
  141. @pytest.mark.asyncio
  142. @pytest.mark.integration
  143. class TestBatchOrderProgress:
  144. async def test_failed_run_leaves_the_work_owed(self, async_client, printer_factory, archive_factory, db_session):
  145. """The whole point of storing targets: a burned print is still owed."""
  146. printer = await printer_factory()
  147. archive = await archive_factory()
  148. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
  149. first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  150. second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  151. await _set_status(db_session, first["id"], "completed")
  152. await _set_status(db_session, second["id"], "failed")
  153. response = await async_client.get(f"/api/v1/queue/batches/{order['id']}")
  154. result = response.json()
  155. assert result["completed_count"] == 1
  156. assert result["failed_count"] == 1
  157. # One completed, one burned — the order still owes a print.
  158. assert result["remaining_count"] == 1
  159. assert result["status"] == "active"
  160. async def test_cancelled_run_also_leaves_the_work_owed(
  161. self, async_client, printer_factory, archive_factory, db_session
  162. ):
  163. printer = await printer_factory()
  164. archive = await archive_factory()
  165. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
  166. item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  167. await _set_status(db_session, item["id"], "cancelled")
  168. result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
  169. assert result["cancelled_count"] == 1
  170. assert result["remaining_count"] == 1
  171. async def test_pending_and_printing_consume_the_target(
  172. self, async_client, printer_factory, archive_factory, db_session
  173. ):
  174. """In-flight work must not be re-dispatched — that would double-print."""
  175. printer = await printer_factory()
  176. archive = await archive_factory()
  177. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
  178. first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  179. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  180. await _set_status(db_session, first["id"], "printing")
  181. result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
  182. assert result["printing_count"] == 1
  183. assert result["pending_count"] == 1
  184. assert result["remaining_count"] == 0
  185. async def test_legacy_batch_without_targets_owes_nothing(self, async_client, printer_factory, archive_factory):
  186. """Batches created before #342 keep working and report has_targets=false."""
  187. printer = await printer_factory()
  188. archive = await archive_factory()
  189. response = await async_client.post(
  190. "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 3}
  191. )
  192. batch_id = response.json()["batch_id"]
  193. result = (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()
  194. assert result["has_targets"] is False
  195. assert result["pending_count"] == 3
  196. assert result["remaining_count"] == 0
  197. assert result["target_count"] == 3
  198. @pytest.mark.asyncio
  199. @pytest.mark.integration
  200. class TestBatchOrderCompletion:
  201. async def test_status_flips_to_completed_when_targets_met(
  202. self, async_client, printer_factory, archive_factory, db_session
  203. ):
  204. printer = await printer_factory()
  205. archive = await archive_factory()
  206. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
  207. item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  208. await _set_status(db_session, item["id"], "completed")
  209. # Reading the order re-evaluates it; the PATCH path and the print
  210. # completion hook do the same.
  211. patched = await async_client.patch(f"/api/v1/queue/batches/{order['id']}", json={})
  212. assert patched.status_code == 200
  213. assert patched.json()["status"] == "completed"
  214. assert patched.json()["completed_at"] is not None
  215. async def test_raising_a_target_reopens_a_completed_order(
  216. self, async_client, printer_factory, archive_factory, db_session
  217. ):
  218. printer = await printer_factory()
  219. archive = await archive_factory()
  220. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
  221. item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  222. await _set_status(db_session, item["id"], "completed")
  223. assert (await async_client.patch(f"/api/v1/queue/batches/{order['id']}", json={})).json()[
  224. "status"
  225. ] == "completed"
  226. reopened = await async_client.patch(
  227. f"/api/v1/queue/batches/{order['id']}",
  228. json={"plates": [{"plate_id": 1, "quantity_target": 3}]},
  229. )
  230. assert reopened.status_code == 200
  231. assert reopened.json()["status"] == "active"
  232. assert reopened.json()["completed_at"] is None
  233. assert reopened.json()["remaining_count"] == 2
  234. async def test_legacy_batch_with_everything_cancelled_reads_as_cancelled(
  235. self, async_client, printer_factory, archive_factory, db_session
  236. ):
  237. """Cancelling every item of a grouping finishes it, but produces nothing.
  238. "Completed" would be a lie — its derived target is zero — and leaving it
  239. active would strand it on the Batches tab forever. Cancelled is what it
  240. is, and is what the batch-level Cancel action would have set.
  241. """
  242. printer = await printer_factory()
  243. archive = await archive_factory()
  244. response = await async_client.post(
  245. "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
  246. )
  247. batch_id = response.json()["batch_id"]
  248. from sqlalchemy import select
  249. from backend.app.models.print_queue import PrintQueueItem
  250. items = (
  251. (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id)))
  252. .scalars()
  253. .all()
  254. )
  255. for item in items:
  256. item.status = "cancelled"
  257. await db_session.commit()
  258. patched = await async_client.patch(f"/api/v1/queue/batches/{batch_id}", json={})
  259. assert patched.status_code == 200
  260. assert patched.json()["status"] == "cancelled"
  261. assert patched.json()["completed_at"] is None
  262. async def test_an_order_with_every_run_cancelled_still_owes_them(
  263. self, async_client, printer_factory, archive_factory, db_session
  264. ):
  265. """The grouping rule must not leak into orders.
  266. An order states its intent independently of its runs, so cancelling
  267. them all leaves it owing the work and offering to re-queue.
  268. """
  269. printer = await printer_factory()
  270. archive = await archive_factory()
  271. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
  272. first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  273. second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  274. await _set_status(db_session, first["id"], "cancelled")
  275. await _set_status(db_session, second["id"], "cancelled")
  276. patched = (await async_client.patch(f"/api/v1/queue/batches/{order['id']}", json={})).json()
  277. assert patched["status"] == "active"
  278. assert patched["remaining_count"] == 2
  279. async def test_cancelled_order_is_never_resurrected(
  280. self, async_client, printer_factory, archive_factory, db_session
  281. ):
  282. printer = await printer_factory()
  283. archive = await archive_factory()
  284. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
  285. item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  286. await async_client.delete(f"/api/v1/queue/batches/{order['id']}")
  287. await _set_status(db_session, item["id"], "completed")
  288. result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
  289. assert result["status"] == "cancelled"
  290. @pytest.mark.asyncio
  291. @pytest.mark.integration
  292. class TestBatchBacklog:
  293. """The Batches tab must not open on months of stale rows.
  294. `completed` only became a reachable status with #342, so every batch
  295. created since the feature shipped is still `active` however long ago its
  296. last run finished.
  297. """
  298. async def test_startup_backfill_closes_finished_batches(
  299. self, async_client, printer_factory, archive_factory, db_session
  300. ):
  301. from backend.app.services.print_batch import backfill_batch_statuses
  302. printer = await printer_factory()
  303. archive = await archive_factory()
  304. response = await async_client.post(
  305. "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
  306. )
  307. batch_id = response.json()["batch_id"]
  308. from sqlalchemy import select
  309. from backend.app.models.print_queue import PrintQueueItem
  310. items = (
  311. (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id)))
  312. .scalars()
  313. .all()
  314. )
  315. for item in items:
  316. item.status = "completed"
  317. await db_session.commit()
  318. assert (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()["status"] == "active"
  319. changed = await backfill_batch_statuses(db_session)
  320. assert changed >= 1
  321. assert (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()["status"] == "completed"
  322. async def test_backfill_leaves_in_flight_batches_alone(
  323. self, async_client, printer_factory, archive_factory, db_session
  324. ):
  325. from backend.app.services.print_batch import backfill_batch_statuses
  326. printer = await printer_factory()
  327. archive = await archive_factory()
  328. response = await async_client.post(
  329. "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
  330. )
  331. batch_id = response.json()["batch_id"]
  332. await backfill_batch_statuses(db_session)
  333. assert (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()["status"] == "active"
  334. async def test_backfill_is_idempotent(self, async_client, printer_factory, archive_factory, db_session):
  335. from backend.app.services.print_batch import backfill_batch_statuses
  336. printer = await printer_factory()
  337. archive = await archive_factory()
  338. response = await async_client.post(
  339. "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 1}
  340. )
  341. item_id = response.json()["id"]
  342. await _set_status(db_session, item_id, "completed")
  343. order = await _create_order(async_client, archive.id, [{"plate_id": 9, "quantity_target": 1}])
  344. first = await backfill_batch_statuses(db_session)
  345. second = await backfill_batch_statuses(db_session)
  346. assert second == 0, "a second pass must have nothing left to change"
  347. assert first >= 0
  348. # The untouched order owes work and stays active across both passes.
  349. assert (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()["status"] == "active"
  350. async def test_empty_shell_batches_are_not_listed(self, async_client, db_session):
  351. """A grouping whose items went with their archive has nothing to show."""
  352. from backend.app.models.print_batch import PrintBatch
  353. shell = PrintBatch(name="Orphaned grouping", quantity=1, status="active")
  354. db_session.add(shell)
  355. await db_session.commit()
  356. await db_session.refresh(shell)
  357. listed = (await async_client.get("/api/v1/queue/batches")).json()
  358. assert all(b["id"] != shell.id for b in listed)
  359. # Still addressable directly — only the list hides it.
  360. assert (await async_client.get(f"/api/v1/queue/batches/{shell.id}")).status_code == 200
  361. async def test_a_new_order_is_listed_before_its_first_dispatch(self, async_client, archive_factory):
  362. """Targets are enough to be worth showing — that is what it owes."""
  363. archive = await archive_factory()
  364. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
  365. listed = (await async_client.get("/api/v1/queue/batches")).json()
  366. assert any(b["id"] == order["id"] for b in listed)
  367. @pytest.mark.asyncio
  368. @pytest.mark.integration
  369. class TestBatchOrderDispatch:
  370. async def test_dispatch_clones_the_print_configuration(
  371. self, async_client, printer_factory, archive_factory, db_session
  372. ):
  373. printer = await printer_factory()
  374. archive = await archive_factory()
  375. order = await _create_order(async_client, archive.id, [{"plate_id": 2, "quantity_target": 3}])
  376. source = await _queue_item(
  377. async_client,
  378. printer.id,
  379. archive.id,
  380. order["id"],
  381. plate_id=2,
  382. timelapse=True,
  383. use_ams=False,
  384. bed_levelling="off",
  385. ams_mapping=[3, -1],
  386. )
  387. response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
  388. assert response.status_code == 200
  389. assert response.json()["remaining_count"] == 0
  390. assert response.json()["pending_count"] == 3
  391. from sqlalchemy import select
  392. from backend.app.models.print_queue import PrintQueueItem
  393. rows = (
  394. (
  395. await db_session.execute(
  396. select(PrintQueueItem).where(PrintQueueItem.batch_id == order["id"]).order_by(PrintQueueItem.id)
  397. )
  398. )
  399. .scalars()
  400. .all()
  401. )
  402. assert len(rows) == 3
  403. clones = [r for r in rows if r.id != source["id"]]
  404. for clone in clones:
  405. assert clone.plate_id == 2
  406. assert clone.printer_id == printer.id
  407. assert clone.timelapse is True
  408. assert clone.use_ams is False
  409. assert clone.bed_levelling == "off"
  410. assert clone.ams_mapping == "[3, -1]"
  411. assert clone.status == "pending"
  412. # Lifecycle state is not copied.
  413. assert clone.started_at is None
  414. assert clone.completed_at is None
  415. assert clone.dispatch_attempts == 0
  416. # Never replayed onto a clone: it would delete the source file out
  417. # from under the rest of the order.
  418. assert clone.cleanup_library_after_dispatch is False
  419. async def test_clones_land_at_the_end_of_the_queue(
  420. self, async_client, printer_factory, archive_factory, db_session
  421. ):
  422. """Positions are one sequence across every printer (#3200).
  423. The queue page shows and reorders pending items as one list and the
  424. scheduler dispatches in that order, so clones go after everything
  425. already queued, and the whole queue stays free of gaps and duplicates.
  426. """
  427. printer_a = await printer_factory()
  428. printer_b = await printer_factory()
  429. archive = await archive_factory()
  430. order = await _create_order(
  431. async_client,
  432. archive.id,
  433. [{"plate_id": 1, "quantity_target": 3}, {"plate_id": 2, "quantity_target": 2}],
  434. )
  435. for _ in range(5):
  436. await async_client.post("/api/v1/queue/", json={"printer_id": printer_b.id, "archive_id": archive.id})
  437. await _queue_item(async_client, printer_a.id, archive.id, order["id"], plate_id=1)
  438. await _queue_item(async_client, printer_b.id, archive.id, order["id"], plate_id=2)
  439. from sqlalchemy import select
  440. from backend.app.models.print_queue import PrintQueueItem
  441. async def pending():
  442. return (
  443. (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.status == "pending")))
  444. .scalars()
  445. .all()
  446. )
  447. before_ids = {r.id for r in await pending()}
  448. last_before = max(r.position for r in await pending())
  449. response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
  450. assert response.status_code == 200
  451. rows = await pending()
  452. positions = sorted(r.position for r in rows)
  453. assert positions == list(range(1, len(rows) + 1)), "queue positions have a gap or a duplicate"
  454. clones = [r for r in rows if r.id not in before_ids]
  455. assert len(clones) == 3
  456. assert all(r.position > last_before for r in clones)
  457. async def test_clone_differs_from_its_source_only_in_lifecycle_state(
  458. self, async_client, printer_factory, archive_factory, db_session
  459. ):
  460. """Guard for future columns.
  461. A clone must carry every *setting* of the item it copies and reset
  462. every piece of *lifecycle* state. Adding a new setting column to
  463. PrintQueueItem without listing it in CLONED_SETTING_COLUMNS would make
  464. the second run of a plate behave differently from the first — silently,
  465. and on real hardware. This fails when that happens.
  466. """
  467. from sqlalchemy import inspect, select
  468. from backend.app.models.print_queue import PrintQueueItem
  469. printer = await printer_factory()
  470. archive = await archive_factory()
  471. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
  472. source_id = (
  473. await _queue_item(
  474. async_client,
  475. printer.id,
  476. archive.id,
  477. order["id"],
  478. plate_id=1,
  479. timelapse=True,
  480. use_ams=False,
  481. bed_levelling="off",
  482. flow_cali="on",
  483. vibration_cali=False,
  484. layer_inspect=True,
  485. gcode_injection=True,
  486. auto_off_after=True,
  487. require_previous_success=True,
  488. )
  489. )["id"]
  490. # Dirty the source with scheduler state that must not be inherited.
  491. source = await db_session.get(PrintQueueItem, source_id)
  492. source.dispatch_attempts = 3
  493. source.been_jumped = True
  494. source.gate_acknowledged = True
  495. source.filament_short = True
  496. source.waiting_reason = "no idle printer"
  497. source.error_message = "previous failure"
  498. source.scheduled_time = datetime(2026, 1, 1, 12, 0, 0)
  499. await db_session.commit()
  500. assert (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).status_code == 200
  501. clone = (
  502. (
  503. await db_session.execute(
  504. select(PrintQueueItem)
  505. .where(PrintQueueItem.batch_id == order["id"])
  506. .where(PrintQueueItem.id != source_id)
  507. )
  508. )
  509. .scalars()
  510. .one()
  511. )
  512. # Every column that is neither identity, ordering, nor deliberately reset
  513. # must match the source exactly.
  514. reset_on_clone = {
  515. "status",
  516. "waiting_reason",
  517. "been_jumped",
  518. "dispatch_attempts",
  519. "dispatching_at",
  520. "gate_acknowledged",
  521. "filament_short",
  522. "error_message",
  523. "started_at",
  524. "completed_at",
  525. "scheduled_time",
  526. "cleanup_library_after_dispatch",
  527. }
  528. identity = {"id", "created_at", "position"}
  529. await db_session.refresh(source)
  530. for column in (c.key for c in inspect(PrintQueueItem).mapper.column_attrs):
  531. if column in identity or column in reset_on_clone:
  532. continue
  533. assert getattr(clone, column) == getattr(source, column), (
  534. f"{column} was not carried onto the clone — a new setting column probably needs adding to "
  535. "CLONED_SETTING_COLUMNS"
  536. )
  537. assert clone.status == "pending"
  538. assert clone.dispatch_attempts == 0
  539. assert clone.been_jumped is False
  540. assert clone.gate_acknowledged is False
  541. assert clone.filament_short is False
  542. assert clone.waiting_reason is None
  543. assert clone.error_message is None
  544. assert clone.started_at is None and clone.completed_at is None
  545. # "Queue the rest now" must not replay a moment chosen for a different print.
  546. assert clone.scheduled_time is None
  547. # Would delete the source file out from under the rest of the order.
  548. assert clone.cleanup_library_after_dispatch is False
  549. async def test_dispatch_respects_limit(self, async_client, printer_factory, archive_factory):
  550. printer = await printer_factory()
  551. archive = await archive_factory()
  552. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 10}])
  553. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  554. result = (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={"limit": 4})).json()
  555. assert result["pending_count"] == 5 # the original plus four
  556. assert result["remaining_count"] == 5
  557. async def test_dispatch_can_target_a_single_plate(self, async_client, printer_factory, archive_factory):
  558. printer = await printer_factory()
  559. archive = await archive_factory()
  560. order = await _create_order(
  561. async_client,
  562. archive.id,
  563. [{"plate_id": 1, "quantity_target": 2}, {"plate_id": 2, "quantity_target": 2}],
  564. )
  565. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  566. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=2)
  567. result = (
  568. await async_client.post(
  569. f"/api/v1/queue/batches/{order['id']}/dispatch",
  570. json={"plate_id": 2, "only_plate": True},
  571. )
  572. ).json()
  573. plate_one = next(p for p in result["plates"] if p["plate_id"] == 1)
  574. plate_two = next(p for p in result["plates"] if p["plate_id"] == 2)
  575. assert plate_one["remaining"] == 1
  576. assert plate_two["remaining"] == 0
  577. async def test_dispatch_without_a_reference_item_is_rejected(self, async_client, archive_factory):
  578. """Nothing to clone means no configuration to copy — say so, don't guess."""
  579. archive = await archive_factory()
  580. order = await _create_order(async_client, archive.id, [{"plate_id": 4, "quantity_target": 2}])
  581. response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
  582. assert response.status_code == 400
  583. assert "no queued or finished run" in response.json()["detail"]
  584. async def test_one_stranded_plate_does_not_block_the_others(self, async_client, printer_factory, archive_factory):
  585. """A plate with nothing to clone is skipped, not an abort for the whole order (#2960)."""
  586. printer = await printer_factory()
  587. archive = await archive_factory()
  588. order = await _create_order(
  589. async_client,
  590. archive.id,
  591. [{"plate_id": 1, "quantity_target": 2}, {"plate_id": 2, "quantity_target": 2}],
  592. )
  593. # Plate 2 never gets an item, so only plate 1 can be cloned.
  594. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  595. response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
  596. assert response.status_code == 200, response.text
  597. result = response.json()
  598. plate_one = next(p for p in result["plates"] if p["plate_id"] == 1)
  599. plate_two = next(p for p in result["plates"] if p["plate_id"] == 2)
  600. assert plate_one["remaining"] == 0
  601. assert plate_two["remaining"] == 2
  602. # The order still owes plate 2 but reports that none of it is queueable.
  603. assert result["remaining_count"] == 2
  604. assert result["dispatchable_count"] == 0
  605. assert plate_one["can_dispatch"] is False # nothing left owing
  606. assert plate_two["can_dispatch"] is False # owing, but nothing to clone
  607. async def test_dispatching_a_stranded_plate_by_name_still_fails_loudly(
  608. self, async_client, printer_factory, archive_factory
  609. ):
  610. """An explicit single-plate request must not silently do nothing."""
  611. printer = await printer_factory()
  612. archive = await archive_factory()
  613. order = await _create_order(
  614. async_client,
  615. archive.id,
  616. [
  617. {"plate_id": 1, "quantity_target": 1},
  618. {"plate_id": 2, "plate_name": "Side rail.stl", "quantity_target": 1},
  619. ],
  620. )
  621. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  622. response = await async_client.post(
  623. f"/api/v1/queue/batches/{order['id']}/dispatch",
  624. json={"plate_id": 2, "only_plate": True},
  625. )
  626. assert response.status_code == 400
  627. # Named by the label the Batches tab shows, not by a bare index.
  628. assert "Side rail.stl" in response.json()["detail"]
  629. async def test_can_dispatch_is_true_while_a_source_survives(
  630. self, async_client, printer_factory, archive_factory, db_session
  631. ):
  632. """A cancelled run is still a clone source — the plate stays queueable."""
  633. printer = await printer_factory()
  634. archive = await archive_factory()
  635. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
  636. item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  637. await _set_status(db_session, item["id"], "cancelled")
  638. result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
  639. assert result["remaining_count"] == 2
  640. assert result["dispatchable_count"] == 2
  641. assert result["plates"][0]["can_dispatch"] is True
  642. async def test_dispatch_on_legacy_batch_is_a_noop(self, async_client, printer_factory, archive_factory):
  643. printer = await printer_factory()
  644. archive = await archive_factory()
  645. response = await async_client.post(
  646. "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
  647. )
  648. batch_id = response.json()["batch_id"]
  649. result = (await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={})).json()
  650. assert result["pending_count"] == 2
  651. async def test_cannot_dispatch_a_cancelled_order(self, async_client, printer_factory, archive_factory):
  652. printer = await printer_factory()
  653. archive = await archive_factory()
  654. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
  655. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  656. await async_client.delete(f"/api/v1/queue/batches/{order['id']}")
  657. response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
  658. assert response.status_code == 400
  659. assert "cancelled" in response.json()["detail"]
  660. async def test_redispatch_after_failure_replaces_the_burned_run(
  661. self, async_client, printer_factory, archive_factory, db_session
  662. ):
  663. """End to end: 2 wanted, 1 completes, 1 fails, dispatch queues the replacement."""
  664. printer = await printer_factory()
  665. archive = await archive_factory()
  666. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
  667. first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  668. second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  669. await _set_status(db_session, first["id"], "completed")
  670. await _set_status(db_session, second["id"], "failed")
  671. result = (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).json()
  672. assert result["pending_count"] == 1
  673. assert result["remaining_count"] == 0
  674. assert result["status"] == "active"
  675. @pytest.mark.asyncio
  676. @pytest.mark.integration
  677. class TestBatchOrderHeader:
  678. async def test_header_fields_round_trip(self, async_client, archive_factory):
  679. archive = await archive_factory()
  680. order = await _create_order(
  681. async_client,
  682. archive.id,
  683. [{"plate_id": 1, "quantity_target": 1}],
  684. due_date="2026-09-01T12:00:00",
  685. notes="Rush job",
  686. )
  687. assert order["notes"] == "Rush job"
  688. assert order["due_date"].startswith("2026-09-01T12:00:00")
  689. patched = (
  690. await async_client.patch(
  691. f"/api/v1/queue/batches/{order['id']}", json={"name": "Renamed", "notes": "Updated"}
  692. )
  693. ).json()
  694. assert patched["name"] == "Renamed"
  695. assert patched["notes"] == "Updated"
  696. async def test_unknown_project_is_rejected(self, async_client, archive_factory):
  697. archive = await archive_factory()
  698. response = await async_client.post(
  699. "/api/v1/queue/batches",
  700. json={
  701. "name": "Order",
  702. "archive_id": archive.id,
  703. "project_id": 999999,
  704. "plates": [{"plate_id": 1, "quantity_target": 1}],
  705. },
  706. )
  707. assert response.status_code == 404
  708. async def test_patch_replaces_the_target_set(self, async_client, archive_factory):
  709. """A plate omitted from the payload has its target row removed."""
  710. archive = await archive_factory()
  711. order = await _create_order(
  712. async_client,
  713. archive.id,
  714. [{"plate_id": 1, "quantity_target": 1}, {"plate_id": 2, "quantity_target": 1}],
  715. )
  716. patched = (
  717. await async_client.patch(
  718. f"/api/v1/queue/batches/{order['id']}",
  719. json={"plates": [{"plate_id": 1, "quantity_target": 5}]},
  720. )
  721. ).json()
  722. assert [p["plate_id"] for p in patched["plates"]] == [1]
  723. assert patched["target_count"] == 5
  724. @pytest.mark.asyncio
  725. @pytest.mark.integration
  726. class TestBatchOrderCost:
  727. async def test_cost_rolls_up_from_logged_runs(self, async_client, printer_factory, archive_factory, db_session):
  728. """Cost is attributed through queue_item_id, not guessed from the archive."""
  729. from backend.app.models.print_log import PrintLogEntry
  730. printer = await printer_factory()
  731. archive = await archive_factory()
  732. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 4}])
  733. first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  734. second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  735. await _set_status(db_session, first["id"], "completed")
  736. await _set_status(db_session, second["id"], "completed")
  737. db_session.add(
  738. PrintLogEntry(
  739. archive_id=archive.id,
  740. queue_item_id=first["id"],
  741. status="completed",
  742. cost=2.0,
  743. energy_cost=0.5,
  744. filament_used_grams=40.0,
  745. )
  746. )
  747. db_session.add(
  748. PrintLogEntry(
  749. archive_id=archive.id,
  750. queue_item_id=second["id"],
  751. status="completed",
  752. cost=3.0,
  753. energy_cost=0.5,
  754. filament_used_grams=60.0,
  755. )
  756. )
  757. # A run of the same archive that has nothing to do with this order.
  758. db_session.add(PrintLogEntry(archive_id=archive.id, queue_item_id=None, status="completed", cost=99.0))
  759. await db_session.commit()
  760. result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
  761. assert result["actual_cost"] == pytest.approx(6.0)
  762. assert result["filament_used_grams"] == pytest.approx(100.0)
  763. # Two completed at 3.00 each, two still owed.
  764. assert result["estimated_remaining_cost"] == pytest.approx(6.0)
  765. async def test_cost_is_unknown_not_zero_before_the_first_run(self, async_client, printer_factory, archive_factory):
  766. printer = await printer_factory()
  767. archive = await archive_factory()
  768. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
  769. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  770. result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
  771. assert result["actual_cost"] is None
  772. assert result["estimated_remaining_cost"] is None
  773. @pytest.mark.asyncio
  774. @pytest.mark.integration
  775. class TestOrderSourcePreservation:
  776. """Deleting an order's last run for a plate must not strand it (#2960).
  777. Dispatch produces what an order still owes by cloning an existing queue
  778. item. Hard-deleting the last one left the order reporting work outstanding
  779. with nothing able to produce it, and no way to close it out either.
  780. """
  781. async def test_last_run_for_a_plate_is_cancelled_not_deleted(
  782. self, async_client, printer_factory, archive_factory, db_session
  783. ):
  784. from backend.app.models.print_queue import PrintQueueItem
  785. printer = await printer_factory()
  786. archive = await archive_factory()
  787. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
  788. item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  789. response = await async_client.delete(f"/api/v1/queue/{item['id']}")
  790. assert response.status_code == 200
  791. assert response.json()["deleted"] is False
  792. db_session.expire_all()
  793. survivor = await db_session.get(PrintQueueItem, item["id"])
  794. assert survivor is not None
  795. assert survivor.status == "cancelled"
  796. async def test_the_order_can_still_be_dispatched_afterwards(self, async_client, printer_factory, archive_factory):
  797. """The whole point: #2960's stuck card, end to end."""
  798. printer = await printer_factory()
  799. archive = await archive_factory()
  800. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
  801. item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  802. await async_client.delete(f"/api/v1/queue/{item['id']}")
  803. # A cancelled run does not consume a target, so the order owes both.
  804. state = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
  805. assert state["remaining_count"] == 2
  806. assert state["dispatchable_count"] == 2
  807. result = (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).json()
  808. assert result["pending_count"] == 2
  809. assert result["remaining_count"] == 0
  810. async def test_a_run_with_a_sibling_is_really_deleted(
  811. self, async_client, printer_factory, archive_factory, db_session
  812. ):
  813. """Only the *last* source is protected — nothing else changes."""
  814. from backend.app.models.print_queue import PrintQueueItem
  815. printer = await printer_factory()
  816. archive = await archive_factory()
  817. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
  818. first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  819. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  820. response = await async_client.delete(f"/api/v1/queue/{first['id']}")
  821. assert response.json()["deleted"] is True
  822. db_session.expire_all()
  823. assert await db_session.get(PrintQueueItem, first["id"]) is None
  824. async def test_a_completed_run_is_never_rewritten_as_cancelled(
  825. self, async_client, printer_factory, archive_factory, db_session
  826. ):
  827. """Deleting history is the user's call; falsifying it is not."""
  828. from backend.app.models.print_queue import PrintQueueItem
  829. printer = await printer_factory()
  830. archive = await archive_factory()
  831. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
  832. item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  833. await _set_status(db_session, item["id"], "completed")
  834. response = await async_client.delete(f"/api/v1/queue/{item['id']}")
  835. assert response.json()["deleted"] is True
  836. db_session.expire_all()
  837. assert await db_session.get(PrintQueueItem, item["id"]) is None
  838. async def test_a_grouping_without_targets_deletes_as_before(
  839. self, async_client, printer_factory, archive_factory, db_session
  840. ):
  841. """A grouping owes nothing, so nothing about it can be stranded."""
  842. from backend.app.models.print_queue import PrintQueueItem
  843. printer = await printer_factory()
  844. archive = await archive_factory()
  845. response = await async_client.post(
  846. "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
  847. )
  848. items = response.json()
  849. first = items[0] if isinstance(items, list) else items
  850. deleted = await async_client.delete(f"/api/v1/queue/{first['id']}")
  851. assert deleted.json()["deleted"] is True
  852. db_session.expire_all()
  853. assert await db_session.get(PrintQueueItem, first["id"]) is None
  854. async def test_a_cancelled_order_lets_its_leftover_rows_be_deleted(
  855. self, async_client, printer_factory, archive_factory, db_session
  856. ):
  857. """Nothing to protect once the order is closed — and tidying up is why you delete."""
  858. from backend.app.models.print_queue import PrintQueueItem
  859. printer = await printer_factory()
  860. archive = await archive_factory()
  861. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
  862. item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  863. await async_client.delete(f"/api/v1/queue/batches/{order['id']}")
  864. deleted = await async_client.delete(f"/api/v1/queue/{item['id']}")
  865. assert deleted.json()["deleted"] is True
  866. db_session.expire_all()
  867. assert await db_session.get(PrintQueueItem, item["id"]) is None
  868. async def test_a_plate_the_order_has_no_target_for_deletes_as_before(
  869. self, async_client, printer_factory, archive_factory, db_session
  870. ):
  871. """Grouped in by hand after the fact: it owes nothing, so delete means delete."""
  872. from backend.app.models.print_queue import PrintQueueItem
  873. printer = await printer_factory()
  874. archive = await archive_factory()
  875. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
  876. stray = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=7)
  877. deleted = await async_client.delete(f"/api/v1/queue/{stray['id']}")
  878. assert deleted.json()["deleted"] is True
  879. db_session.expire_all()
  880. assert await db_session.get(PrintQueueItem, stray["id"]) is None
  881. class TestBatchExternalLink:
  882. """A batch can carry the external record (e.g. a shop order) it fulfils."""
  883. async def test_link_round_trips_and_filters(self, async_client, archive_factory):
  884. archive = await archive_factory()
  885. order = await _create_order(
  886. async_client,
  887. archive.id,
  888. [{"plate_id": 1, "quantity_target": 2}],
  889. external_source="shopify",
  890. external_ref="shop.example/orders/1042/file/7",
  891. )
  892. assert order["external_source"] == "shopify"
  893. assert order["external_ref"] == "shop.example/orders/1042/file/7"
  894. await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
  895. by_source = (await async_client.get("/api/v1/queue/batches", params={"external_source": "shopify"})).json()
  896. assert [b["id"] for b in by_source] == [order["id"]]
  897. by_ref = (
  898. await async_client.get(
  899. "/api/v1/queue/batches",
  900. params={"external_source": "shopify", "external_ref": "shop.example/orders/1042/file/7"},
  901. )
  902. ).json()
  903. assert [b["id"] for b in by_ref] == [order["id"]]
  904. async def test_second_create_for_the_same_record_is_refused(self, async_client, archive_factory):
  905. """A retried create must not produce a second batch printing the order twice."""
  906. archive = await archive_factory()
  907. link = {"external_source": "shopify", "external_ref": "shop.example/orders/1042/file/7"}
  908. await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}], **link)
  909. response = await async_client.post(
  910. "/api/v1/queue/batches",
  911. json={"name": "Retry", "archive_id": archive.id, "plates": [{"plate_id": 1, "quantity_target": 1}], **link},
  912. )
  913. assert response.status_code == 409
  914. listed = (await async_client.get("/api/v1/queue/batches", params=link)).json()
  915. assert len(listed) == 1
  916. async def test_same_ref_under_another_source_is_a_different_record(self, async_client, archive_factory):
  917. archive = await archive_factory()
  918. plates = [{"plate_id": 1, "quantity_target": 1}]
  919. await _create_order(async_client, archive.id, plates, external_source="shopify", external_ref="1042")
  920. await _create_order(async_client, archive.id, plates, external_source="etsy", external_ref="1042")
  921. async def test_unlinked_batches_never_collide(self, async_client, archive_factory):
  922. """NULL pairs are exempt from the unique index."""
  923. archive = await archive_factory()
  924. plates = [{"plate_id": 1, "quantity_target": 1}]
  925. await _create_order(async_client, archive.id, plates)
  926. await _create_order(async_client, archive.id, plates)
  927. @pytest.mark.parametrize(
  928. "link",
  929. [
  930. {"external_source": "shopify"},
  931. {"external_ref": "1042"},
  932. {"external_source": "Shop ify", "external_ref": "1042"},
  933. {"external_source": "shopify", "external_ref": ""},
  934. ],
  935. )
  936. async def test_incomplete_or_malformed_link_is_rejected(self, async_client, archive_factory, link):
  937. archive = await archive_factory()
  938. response = await async_client.post(
  939. "/api/v1/queue/batches",
  940. json={"name": "Order", "archive_id": archive.id, "plates": [{"plate_id": 1, "quantity_target": 1}], **link},
  941. )
  942. assert response.status_code == 422