test_print_batch_orders.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833
  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_in_their_own_printer_queue(
  420. self, async_client, printer_factory, archive_factory, db_session
  421. ):
  422. """Positions are per-printer sequences — a global MAX would scramble them."""
  423. printer_a = await printer_factory()
  424. printer_b = await printer_factory()
  425. archive = await archive_factory()
  426. order = await _create_order(
  427. async_client,
  428. archive.id,
  429. [{"plate_id": 1, "quantity_target": 3}, {"plate_id": 2, "quantity_target": 2}],
  430. )
  431. # Pad printer B's queue so a global MAX would push plate 1's clones
  432. # past the end of printer A's much shorter queue.
  433. for _ in range(5):
  434. await async_client.post("/api/v1/queue/", json={"printer_id": printer_b.id, "archive_id": archive.id})
  435. await _queue_item(async_client, printer_a.id, archive.id, order["id"], plate_id=1)
  436. await _queue_item(async_client, printer_b.id, archive.id, order["id"], plate_id=2)
  437. response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
  438. assert response.status_code == 200
  439. from sqlalchemy import select
  440. from backend.app.models.print_queue import PrintQueueItem
  441. for printer in (printer_a, printer_b):
  442. rows = (
  443. (
  444. await db_session.execute(
  445. select(PrintQueueItem)
  446. .where(PrintQueueItem.printer_id == printer.id)
  447. .where(PrintQueueItem.status == "pending")
  448. )
  449. )
  450. .scalars()
  451. .all()
  452. )
  453. positions = sorted(r.position for r in rows)
  454. assert len(positions) == len(set(positions)), f"duplicate positions on printer {printer.id}"
  455. assert positions == list(range(1, len(rows) + 1)), f"gap in printer {printer.id} queue"
  456. async def test_clone_differs_from_its_source_only_in_lifecycle_state(
  457. self, async_client, printer_factory, archive_factory, db_session
  458. ):
  459. """Guard for future columns.
  460. A clone must carry every *setting* of the item it copies and reset
  461. every piece of *lifecycle* state. Adding a new setting column to
  462. PrintQueueItem without listing it in CLONED_SETTING_COLUMNS would make
  463. the second run of a plate behave differently from the first — silently,
  464. and on real hardware. This fails when that happens.
  465. """
  466. from sqlalchemy import inspect, select
  467. from backend.app.models.print_queue import PrintQueueItem
  468. printer = await printer_factory()
  469. archive = await archive_factory()
  470. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
  471. source_id = (
  472. await _queue_item(
  473. async_client,
  474. printer.id,
  475. archive.id,
  476. order["id"],
  477. plate_id=1,
  478. timelapse=True,
  479. use_ams=False,
  480. bed_levelling="off",
  481. flow_cali="on",
  482. vibration_cali=False,
  483. layer_inspect=True,
  484. gcode_injection=True,
  485. auto_off_after=True,
  486. require_previous_success=True,
  487. )
  488. )["id"]
  489. # Dirty the source with scheduler state that must not be inherited.
  490. source = await db_session.get(PrintQueueItem, source_id)
  491. source.dispatch_attempts = 3
  492. source.been_jumped = True
  493. source.gate_acknowledged = True
  494. source.filament_short = True
  495. source.waiting_reason = "no idle printer"
  496. source.error_message = "previous failure"
  497. source.scheduled_time = datetime(2026, 1, 1, 12, 0, 0)
  498. await db_session.commit()
  499. assert (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).status_code == 200
  500. clone = (
  501. (
  502. await db_session.execute(
  503. select(PrintQueueItem)
  504. .where(PrintQueueItem.batch_id == order["id"])
  505. .where(PrintQueueItem.id != source_id)
  506. )
  507. )
  508. .scalars()
  509. .one()
  510. )
  511. # Every column that is neither identity, ordering, nor deliberately reset
  512. # must match the source exactly.
  513. reset_on_clone = {
  514. "status",
  515. "waiting_reason",
  516. "been_jumped",
  517. "dispatch_attempts",
  518. "dispatching_at",
  519. "gate_acknowledged",
  520. "filament_short",
  521. "error_message",
  522. "started_at",
  523. "completed_at",
  524. "scheduled_time",
  525. "cleanup_library_after_dispatch",
  526. }
  527. identity = {"id", "created_at", "position"}
  528. await db_session.refresh(source)
  529. for column in (c.key for c in inspect(PrintQueueItem).mapper.column_attrs):
  530. if column in identity or column in reset_on_clone:
  531. continue
  532. assert getattr(clone, column) == getattr(source, column), (
  533. f"{column} was not carried onto the clone — a new setting column probably needs adding to "
  534. "CLONED_SETTING_COLUMNS"
  535. )
  536. assert clone.status == "pending"
  537. assert clone.dispatch_attempts == 0
  538. assert clone.been_jumped is False
  539. assert clone.gate_acknowledged is False
  540. assert clone.filament_short is False
  541. assert clone.waiting_reason is None
  542. assert clone.error_message is None
  543. assert clone.started_at is None and clone.completed_at is None
  544. # "Queue the rest now" must not replay a moment chosen for a different print.
  545. assert clone.scheduled_time is None
  546. # Would delete the source file out from under the rest of the order.
  547. assert clone.cleanup_library_after_dispatch is False
  548. async def test_dispatch_respects_limit(self, async_client, printer_factory, archive_factory):
  549. printer = await printer_factory()
  550. archive = await archive_factory()
  551. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 10}])
  552. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  553. result = (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={"limit": 4})).json()
  554. assert result["pending_count"] == 5 # the original plus four
  555. assert result["remaining_count"] == 5
  556. async def test_dispatch_can_target_a_single_plate(self, async_client, printer_factory, archive_factory):
  557. printer = await printer_factory()
  558. archive = await archive_factory()
  559. order = await _create_order(
  560. async_client,
  561. archive.id,
  562. [{"plate_id": 1, "quantity_target": 2}, {"plate_id": 2, "quantity_target": 2}],
  563. )
  564. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  565. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=2)
  566. result = (
  567. await async_client.post(
  568. f"/api/v1/queue/batches/{order['id']}/dispatch",
  569. json={"plate_id": 2, "only_plate": True},
  570. )
  571. ).json()
  572. plate_one = next(p for p in result["plates"] if p["plate_id"] == 1)
  573. plate_two = next(p for p in result["plates"] if p["plate_id"] == 2)
  574. assert plate_one["remaining"] == 1
  575. assert plate_two["remaining"] == 0
  576. async def test_dispatch_without_a_reference_item_is_rejected(self, async_client, archive_factory):
  577. """Nothing to clone means no configuration to copy — say so, don't guess."""
  578. archive = await archive_factory()
  579. order = await _create_order(async_client, archive.id, [{"plate_id": 4, "quantity_target": 2}])
  580. response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
  581. assert response.status_code == 400
  582. assert "no queued or finished run" in response.json()["detail"]
  583. async def test_dispatch_on_legacy_batch_is_a_noop(self, async_client, printer_factory, archive_factory):
  584. printer = await printer_factory()
  585. archive = await archive_factory()
  586. response = await async_client.post(
  587. "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
  588. )
  589. batch_id = response.json()["batch_id"]
  590. result = (await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={})).json()
  591. assert result["pending_count"] == 2
  592. async def test_cannot_dispatch_a_cancelled_order(self, async_client, printer_factory, archive_factory):
  593. printer = await printer_factory()
  594. archive = await archive_factory()
  595. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
  596. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  597. await async_client.delete(f"/api/v1/queue/batches/{order['id']}")
  598. response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
  599. assert response.status_code == 400
  600. assert "cancelled" in response.json()["detail"]
  601. async def test_redispatch_after_failure_replaces_the_burned_run(
  602. self, async_client, printer_factory, archive_factory, db_session
  603. ):
  604. """End to end: 2 wanted, 1 completes, 1 fails, dispatch queues the replacement."""
  605. printer = await printer_factory()
  606. archive = await archive_factory()
  607. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
  608. first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  609. second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  610. await _set_status(db_session, first["id"], "completed")
  611. await _set_status(db_session, second["id"], "failed")
  612. result = (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).json()
  613. assert result["pending_count"] == 1
  614. assert result["remaining_count"] == 0
  615. assert result["status"] == "active"
  616. @pytest.mark.asyncio
  617. @pytest.mark.integration
  618. class TestBatchOrderHeader:
  619. async def test_header_fields_round_trip(self, async_client, archive_factory):
  620. archive = await archive_factory()
  621. order = await _create_order(
  622. async_client,
  623. archive.id,
  624. [{"plate_id": 1, "quantity_target": 1}],
  625. due_date="2026-09-01T12:00:00",
  626. notes="Rush job",
  627. )
  628. assert order["notes"] == "Rush job"
  629. assert order["due_date"].startswith("2026-09-01T12:00:00")
  630. patched = (
  631. await async_client.patch(
  632. f"/api/v1/queue/batches/{order['id']}", json={"name": "Renamed", "notes": "Updated"}
  633. )
  634. ).json()
  635. assert patched["name"] == "Renamed"
  636. assert patched["notes"] == "Updated"
  637. async def test_unknown_project_is_rejected(self, async_client, archive_factory):
  638. archive = await archive_factory()
  639. response = await async_client.post(
  640. "/api/v1/queue/batches",
  641. json={
  642. "name": "Order",
  643. "archive_id": archive.id,
  644. "project_id": 999999,
  645. "plates": [{"plate_id": 1, "quantity_target": 1}],
  646. },
  647. )
  648. assert response.status_code == 404
  649. async def test_patch_replaces_the_target_set(self, async_client, archive_factory):
  650. """A plate omitted from the payload has its target row removed."""
  651. archive = await archive_factory()
  652. order = await _create_order(
  653. async_client,
  654. archive.id,
  655. [{"plate_id": 1, "quantity_target": 1}, {"plate_id": 2, "quantity_target": 1}],
  656. )
  657. patched = (
  658. await async_client.patch(
  659. f"/api/v1/queue/batches/{order['id']}",
  660. json={"plates": [{"plate_id": 1, "quantity_target": 5}]},
  661. )
  662. ).json()
  663. assert [p["plate_id"] for p in patched["plates"]] == [1]
  664. assert patched["target_count"] == 5
  665. @pytest.mark.asyncio
  666. @pytest.mark.integration
  667. class TestBatchOrderCost:
  668. async def test_cost_rolls_up_from_logged_runs(self, async_client, printer_factory, archive_factory, db_session):
  669. """Cost is attributed through queue_item_id, not guessed from the archive."""
  670. from backend.app.models.print_log import PrintLogEntry
  671. printer = await printer_factory()
  672. archive = await archive_factory()
  673. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 4}])
  674. first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  675. second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  676. await _set_status(db_session, first["id"], "completed")
  677. await _set_status(db_session, second["id"], "completed")
  678. db_session.add(
  679. PrintLogEntry(
  680. archive_id=archive.id,
  681. queue_item_id=first["id"],
  682. status="completed",
  683. cost=2.0,
  684. energy_cost=0.5,
  685. filament_used_grams=40.0,
  686. )
  687. )
  688. db_session.add(
  689. PrintLogEntry(
  690. archive_id=archive.id,
  691. queue_item_id=second["id"],
  692. status="completed",
  693. cost=3.0,
  694. energy_cost=0.5,
  695. filament_used_grams=60.0,
  696. )
  697. )
  698. # A run of the same archive that has nothing to do with this order.
  699. db_session.add(PrintLogEntry(archive_id=archive.id, queue_item_id=None, status="completed", cost=99.0))
  700. await db_session.commit()
  701. result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
  702. assert result["actual_cost"] == pytest.approx(6.0)
  703. assert result["filament_used_grams"] == pytest.approx(100.0)
  704. # Two completed at 3.00 each, two still owed.
  705. assert result["estimated_remaining_cost"] == pytest.approx(6.0)
  706. async def test_cost_is_unknown_not_zero_before_the_first_run(self, async_client, printer_factory, archive_factory):
  707. printer = await printer_factory()
  708. archive = await archive_factory()
  709. order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
  710. await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
  711. result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
  712. assert result["actual_cost"] is None
  713. assert result["estimated_remaining_cost"] is None