test_pipeline_runs_api.py 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467
  1. """Integration tests for Slicer Pipeline runs (#1425 PR B).
  2. Slicing itself is a network call to the slicer sidecar — these tests
  3. stub ``slice_and_persist`` so the orchestration logic is exercised without
  4. needing a live sidecar in CI.
  5. """
  6. from __future__ import annotations
  7. from unittest.mock import AsyncMock, patch
  8. import pytest
  9. from httpx import AsyncClient
  10. def _pipeline_payload(**overrides) -> dict:
  11. payload = {
  12. "name": "Production Batch",
  13. "description": None,
  14. "printer_preset": {"source": "local", "id": "1"},
  15. "process_preset": {"source": "local", "id": "2"},
  16. "filament_presets": [{"source": "local", "id": "3"}],
  17. "bed_type": None,
  18. }
  19. payload.update(overrides)
  20. return payload
  21. @pytest.fixture
  22. async def pipeline_factory(async_client: AsyncClient):
  23. """Create pipelines via the API + optionally set a target printer."""
  24. async def _make(target_printer_id: int | None = None, **overrides) -> dict:
  25. resp = await async_client.post("/api/v1/slicer-pipelines/", json=_pipeline_payload(**overrides))
  26. assert resp.status_code == 201, resp.text
  27. pipeline = resp.json()
  28. if target_printer_id is not None:
  29. put_resp = await async_client.put(
  30. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  31. json={"target_kind": "specific_printer", "target_printer_id": target_printer_id},
  32. )
  33. assert put_resp.status_code == 200, put_resp.text
  34. pipeline = put_resp.json()
  35. return pipeline
  36. return _make
  37. @pytest.fixture
  38. async def printer_factory(db_session):
  39. """Insert a Printer row for tests that need a target_printer_id."""
  40. from backend.app.models.printer import Printer
  41. counter = [0]
  42. async def _make(**overrides) -> Printer:
  43. counter[0] += 1
  44. defaults = {
  45. "name": f"X1C #{counter[0]}",
  46. "serial_number": f"SERIAL{counter[0]:04d}",
  47. "ip_address": "192.0.2.1",
  48. "access_code": "ABCD1234",
  49. "model": "Bambu Lab X1 Carbon",
  50. "is_active": True,
  51. }
  52. defaults.update(overrides)
  53. printer = Printer(**defaults)
  54. db_session.add(printer)
  55. await db_session.commit()
  56. await db_session.refresh(printer)
  57. return printer
  58. return _make
  59. @pytest.fixture
  60. async def library_file_factory(db_session):
  61. """Insert a LibraryFile row for tests that need a source_library_file_id."""
  62. from pathlib import Path
  63. from backend.app.core.config import settings as app_settings
  64. from backend.app.models.library import LibraryFile
  65. counter = [0]
  66. async def _make(**overrides) -> LibraryFile:
  67. counter[0] += 1
  68. # Materialise an empty file on disk so the orchestration's path-exists
  69. # guard passes when tests reach it.
  70. rel = f"test_pipeline_run_{counter[0]}.3mf"
  71. abs_path = Path(app_settings.base_dir) / rel
  72. abs_path.parent.mkdir(parents=True, exist_ok=True)
  73. abs_path.write_bytes(b"")
  74. defaults = {
  75. "filename": f"cube_{counter[0]}.3mf",
  76. "file_path": rel,
  77. "file_type": "3mf",
  78. "file_size": 0,
  79. "file_hash": f"hash_{counter[0]}",
  80. "source_type": "uploaded",
  81. }
  82. defaults.update(overrides)
  83. row = LibraryFile(**defaults)
  84. db_session.add(row)
  85. await db_session.commit()
  86. await db_session.refresh(row)
  87. return row
  88. return _make
  89. class TestSlicerPipelineTarget:
  90. """PUT /slicer-pipelines/{id} accepts the new target fields."""
  91. @pytest.mark.asyncio
  92. @pytest.mark.integration
  93. async def test_update_writes_target(self, async_client: AsyncClient, pipeline_factory, printer_factory):
  94. printer = await printer_factory()
  95. pipeline = await pipeline_factory()
  96. resp = await async_client.put(
  97. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  98. json={"target_kind": "specific_printer", "target_printer_id": printer.id},
  99. )
  100. assert resp.status_code == 200, resp.text
  101. updated = resp.json()
  102. assert updated["target_kind"] == "specific_printer"
  103. assert updated["target_printer_id"] == printer.id
  104. @pytest.mark.asyncio
  105. @pytest.mark.integration
  106. async def test_update_target_printer_id_zero_clears(
  107. self, async_client: AsyncClient, pipeline_factory, printer_factory
  108. ):
  109. """Empty-select dropdown sends target_printer_id=0 → backend treats
  110. as 'clear' rather than referencing printer #0 (which doesn't exist)."""
  111. printer = await printer_factory()
  112. pipeline = await pipeline_factory(target_printer_id=printer.id)
  113. resp = await async_client.put(
  114. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  115. json={"target_printer_id": 0},
  116. )
  117. assert resp.status_code == 200, resp.text
  118. assert resp.json()["target_printer_id"] is None
  119. class TestCheckEligibility:
  120. """POST /slicer-pipelines/{id}/check-eligibility surfaces structured issues."""
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_no_target_set(
  124. self,
  125. async_client: AsyncClient,
  126. pipeline_factory,
  127. library_file_factory,
  128. ):
  129. pipeline = await pipeline_factory() # no target set
  130. src = await library_file_factory()
  131. resp = await async_client.post(
  132. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  133. json={"source_library_file_id": src.id},
  134. )
  135. assert resp.status_code == 200
  136. body = resp.json()
  137. assert body["ok"] is False
  138. kinds = [i["kind"] for i in body["issues"]]
  139. # PR A defaults target_kind to 'printer_class' so a freshly-saved
  140. # pipeline with no target_model_class surfaces ``class_not_set``; the
  141. # PR B UI path that hadn't pinned a target_printer_id would surface
  142. # ``printer_not_set``. Both signal the same thing to the operator;
  143. # accept either.
  144. assert kinds == ["class_not_set"] or kinds == ["printer_not_set"]
  145. @pytest.mark.asyncio
  146. @pytest.mark.integration
  147. async def test_printer_disabled(
  148. self,
  149. async_client: AsyncClient,
  150. pipeline_factory,
  151. printer_factory,
  152. library_file_factory,
  153. ):
  154. printer = await printer_factory(is_active=False)
  155. pipeline = await pipeline_factory(target_printer_id=printer.id)
  156. src = await library_file_factory()
  157. with patch("backend.app.api.routes.pipeline_runs._load_printer_status", new=AsyncMock(return_value=None)):
  158. resp = await async_client.post(
  159. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  160. json={"source_library_file_id": src.id},
  161. )
  162. assert resp.status_code == 200
  163. body = resp.json()
  164. kinds = [i["kind"] for i in body["issues"]]
  165. assert "printer_disabled" in kinds
  166. # printer_offline also fires because get_status returns None — both
  167. # issues are expected and both block.
  168. assert "printer_offline" in kinds
  169. assert body["ok"] is False
  170. @pytest.mark.asyncio
  171. @pytest.mark.integration
  172. async def test_online_match_clears_issues(
  173. self,
  174. async_client: AsyncClient,
  175. pipeline_factory,
  176. printer_factory,
  177. library_file_factory,
  178. db_session,
  179. ):
  180. """Patch printer_manager so AMS slot 0 carries the same canonical
  181. type the pipeline's local-tier filament preset declares."""
  182. from backend.app.models.local_preset import LocalPreset
  183. preset = LocalPreset(
  184. name="My PLA",
  185. preset_type="filament",
  186. source="manual",
  187. setting="{}",
  188. filament_type="PLA",
  189. default_filament_colour="#FFFFFF",
  190. )
  191. db_session.add(preset)
  192. await db_session.commit()
  193. await db_session.refresh(preset)
  194. printer = await printer_factory()
  195. pipeline = await pipeline_factory(
  196. target_printer_id=printer.id,
  197. filament_presets=[{"source": "local", "id": str(preset.id)}],
  198. )
  199. src = await library_file_factory()
  200. # The printer reports the generic material in tray_type and the product
  201. # name in tray_sub_brands, so this is the shape a real AMS sends.
  202. live_status = {
  203. "connected": True,
  204. "raw_data": {"ams": [{"tray": [{"tray_type": "PLA", "tray_color": "FFFFFFFF"}]}]},
  205. }
  206. with patch(
  207. "backend.app.api.routes.pipeline_runs._load_printer_status",
  208. new=AsyncMock(return_value=live_status),
  209. ):
  210. resp = await async_client.post(
  211. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  212. json={"source_library_file_id": src.id},
  213. )
  214. assert resp.status_code == 200
  215. body = resp.json()
  216. assert body["ok"] is True
  217. assert body["issues"] == []
  218. assert body["target_printer_name"] == printer.name
  219. @pytest.mark.asyncio
  220. @pytest.mark.integration
  221. async def test_a_product_name_in_tray_type_is_reported_as_a_mismatch(
  222. self,
  223. async_client: AsyncClient,
  224. db_session,
  225. printer_factory,
  226. pipeline_factory,
  227. library_file_factory,
  228. ):
  229. """Eligibility answers with the dispatch matcher's type rules, not its own.
  230. It used to alias "PLA Basic" to "PLA" and pass this; the matcher never
  231. did, so the run cleared the pre-flight and then failed to map the slot.
  232. Flagging it here is the honest answer even though it is the stricter one.
  233. """
  234. from backend.app.models.local_preset import LocalPreset
  235. preset = LocalPreset(
  236. name="My PLA",
  237. preset_type="filament",
  238. source="manual",
  239. setting="{}",
  240. filament_type="PLA",
  241. default_filament_colour="#FFFFFF",
  242. )
  243. db_session.add(preset)
  244. await db_session.commit()
  245. await db_session.refresh(preset)
  246. printer = await printer_factory()
  247. pipeline = await pipeline_factory(
  248. target_printer_id=printer.id,
  249. filament_presets=[{"source": "local", "id": str(preset.id)}],
  250. )
  251. src = await library_file_factory()
  252. live_status = {
  253. "connected": True,
  254. "raw_data": {"ams": [{"tray": [{"tray_type": "PLA Basic", "tray_color": "FFFFFFFF"}]}]},
  255. }
  256. with patch(
  257. "backend.app.api.routes.pipeline_runs._load_printer_status",
  258. new=AsyncMock(return_value=live_status),
  259. ):
  260. resp = await async_client.post(
  261. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  262. json={"source_library_file_id": src.id},
  263. )
  264. assert resp.status_code == 200
  265. body = resp.json()
  266. assert [i["kind"] for i in body["issues"]] == ["filament_type_mismatch"]
  267. class TestRunPipeline:
  268. """POST /slicer-pipelines/{id}/run orchestrates slice + enqueue."""
  269. @pytest.mark.asyncio
  270. @pytest.mark.integration
  271. async def test_run_with_issues_and_no_force_returns_409(
  272. self,
  273. async_client: AsyncClient,
  274. pipeline_factory,
  275. library_file_factory,
  276. ):
  277. pipeline = await pipeline_factory() # no target set
  278. src = await library_file_factory()
  279. resp = await async_client.post(
  280. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  281. json={"source_library_file_id": src.id},
  282. )
  283. assert resp.status_code == 409
  284. # Eligibility report rides in detail.
  285. detail = resp.json()["detail"]
  286. assert detail["ok"] is False
  287. # printer_not_set or class_not_set — depends on the PR A default
  288. # target_kind. Both mean "no target chosen yet".
  289. kinds = [i["kind"] for i in detail["issues"]]
  290. assert "printer_not_set" in kinds or "class_not_set" in kinds
  291. @pytest.mark.asyncio
  292. @pytest.mark.integration
  293. async def test_run_force_with_no_target_still_400(
  294. self,
  295. async_client: AsyncClient,
  296. pipeline_factory,
  297. library_file_factory,
  298. ):
  299. """``force=True`` bypasses the 409 but the run endpoint still needs a
  300. target to enqueue against — the second guard returns 400."""
  301. pipeline = await pipeline_factory()
  302. src = await library_file_factory()
  303. resp = await async_client.post(
  304. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  305. json={"source_library_file_id": src.id, "force": True},
  306. )
  307. assert resp.status_code == 400
  308. @pytest.mark.asyncio
  309. @pytest.mark.integration
  310. async def test_run_creates_run_and_job(
  311. self,
  312. async_client: AsyncClient,
  313. pipeline_factory,
  314. printer_factory,
  315. library_file_factory,
  316. ):
  317. printer = await printer_factory()
  318. pipeline = await pipeline_factory(target_printer_id=printer.id)
  319. src = await library_file_factory()
  320. live_status = {"connected": True, "raw_data": {"ams": []}}
  321. # AMS empty → eligibility surfaces filament_unverified (non-blocking)
  322. # for the standard-tier filament refs the default factory uses; report
  323. # is ok=True so no force needed.
  324. from dataclasses import dataclass
  325. @dataclass
  326. class _FakeSliceJob:
  327. id: int = 9001
  328. with (
  329. patch(
  330. "backend.app.api.routes.pipeline_runs._load_printer_status",
  331. new=AsyncMock(return_value=live_status),
  332. ),
  333. patch(
  334. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  335. new=AsyncMock(return_value=_FakeSliceJob()),
  336. ),
  337. ):
  338. resp = await async_client.post(
  339. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  340. json={"source_library_file_id": src.id},
  341. )
  342. assert resp.status_code == 202, resp.text
  343. body = resp.json()
  344. assert body["pipeline_id"] == pipeline["id"]
  345. assert body["source_library_file_id"] == src.id
  346. assert body["copies"] == 1
  347. assert body["status"] == "queued"
  348. assert len(body["jobs"]) == 1
  349. assert body["jobs"][0]["copy_index"] == 0
  350. assert body["eligibility_overridden"] is False
  351. # slice_job_id rides on the response so the frontend can call
  352. # trackJob and render the progress toast.
  353. assert body["slice_job_id"] == 9001
  354. class TestRunListAndGet:
  355. """Run history surfaces."""
  356. @pytest.mark.asyncio
  357. @pytest.mark.integration
  358. async def test_list_runs_empty(
  359. self,
  360. async_client: AsyncClient,
  361. pipeline_factory,
  362. ):
  363. pipeline = await pipeline_factory()
  364. resp = await async_client.get(f"/api/v1/slicer-pipelines/{pipeline['id']}/runs")
  365. assert resp.status_code == 200
  366. assert resp.json() == {"runs": [], "total": 0}
  367. @pytest.mark.asyncio
  368. @pytest.mark.integration
  369. async def test_get_run_404(
  370. self,
  371. async_client: AsyncClient,
  372. ):
  373. resp = await async_client.get("/api/v1/pipeline-runs/99999")
  374. assert resp.status_code == 404
  375. class TestCancelRun:
  376. """Cancellation marks the run + linked queue entry."""
  377. @pytest.mark.asyncio
  378. @pytest.mark.integration
  379. async def test_cancel_unknown_run_404(self, async_client: AsyncClient):
  380. resp = await async_client.post("/api/v1/pipeline-runs/99999/cancel")
  381. assert resp.status_code == 404
  382. @pytest.mark.asyncio
  383. @pytest.mark.integration
  384. async def test_cancel_marks_queued_run(
  385. self,
  386. async_client: AsyncClient,
  387. pipeline_factory,
  388. printer_factory,
  389. library_file_factory,
  390. db_session,
  391. ):
  392. printer = await printer_factory()
  393. pipeline = await pipeline_factory(target_printer_id=printer.id)
  394. src = await library_file_factory()
  395. live_status = {"connected": True, "raw_data": {"ams": []}}
  396. from dataclasses import dataclass
  397. @dataclass
  398. class _FakeSliceJob:
  399. id: int = 9001
  400. with (
  401. patch(
  402. "backend.app.api.routes.pipeline_runs._load_printer_status",
  403. new=AsyncMock(return_value=live_status),
  404. ),
  405. patch(
  406. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  407. new=AsyncMock(return_value=_FakeSliceJob()),
  408. ),
  409. ):
  410. run_resp = await async_client.post(
  411. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  412. json={"source_library_file_id": src.id},
  413. )
  414. run_id = run_resp.json()["id"]
  415. cancel_resp = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/cancel")
  416. assert cancel_resp.status_code == 200
  417. assert cancel_resp.json()["status"] == "cancelled"
  418. @pytest.mark.asyncio
  419. @pytest.mark.integration
  420. async def test_cancel_stops_the_queue_entry_like_a_queue_cancel(
  421. self,
  422. async_client: AsyncClient,
  423. pipeline_factory,
  424. printer_factory,
  425. library_file_factory,
  426. db_session,
  427. ):
  428. """QS/I7: a preheating entry stops heating, its budget hold is freed."""
  429. from backend.app.models.finance import BudgetReservation, CostCenter
  430. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  431. from backend.app.models.print_queue import PrintQueueItem
  432. printer = await printer_factory()
  433. pipeline = await pipeline_factory(target_printer_id=printer.id)
  434. src = await library_file_factory()
  435. center = CostCenter(name="Lab", is_active=True, is_private=False)
  436. entry = PrintQueueItem(printer_id=printer.id, library_file_id=src.id, status="pending")
  437. db_session.add_all([center, entry])
  438. await db_session.flush()
  439. run = PipelineRun(pipeline_id=pipeline["id"], source_library_file_id=src.id, status="running", copies=1)
  440. db_session.add(run)
  441. await db_session.flush()
  442. db_session.add(PipelineJob(pipeline_run_id=run.id, queue_entry_id=entry.id, status="queued"))
  443. hold = BudgetReservation(cost_center_id=center.id, amount=5.0, source_type="print_queue", source_id=entry.id)
  444. db_session.add(hold)
  445. await db_session.commit()
  446. with patch("backend.app.services.print_scheduler.scheduler.notify_dispatch_cancelled") as notify:
  447. response = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/cancel")
  448. assert response.status_code == 200, response.text
  449. notify.assert_called_once_with(entry.id)
  450. await db_session.refresh(hold)
  451. await db_session.refresh(entry)
  452. assert (entry.status, hold.status) == ("cancelled", "released")
  453. @pytest.mark.asyncio
  454. @pytest.mark.integration
  455. async def test_run_accepts_archive_source(
  456. self,
  457. async_client: AsyncClient,
  458. pipeline_factory,
  459. printer_factory,
  460. db_session,
  461. ):
  462. """``source_archive_id`` is accepted in place of source_library_file_id."""
  463. from pathlib import Path
  464. from backend.app.core.config import settings as app_settings
  465. from backend.app.models.archive import PrintArchive
  466. printer = await printer_factory()
  467. pipeline = await pipeline_factory(target_printer_id=printer.id)
  468. rel = "test_pipeline_archive_source.3mf"
  469. (Path(app_settings.base_dir) / rel).write_bytes(b"")
  470. archive = PrintArchive(
  471. printer_id=printer.id,
  472. filename="Archive Source.3mf",
  473. file_path=rel,
  474. file_size=0,
  475. source_3mf_path=rel,
  476. )
  477. db_session.add(archive)
  478. await db_session.commit()
  479. await db_session.refresh(archive)
  480. from dataclasses import dataclass
  481. @dataclass
  482. class _FakeSliceJob:
  483. id: int = 7777
  484. live_status = {"connected": True, "raw_data": {"ams": []}}
  485. with (
  486. patch(
  487. "backend.app.api.routes.pipeline_runs._load_printer_status",
  488. new=AsyncMock(return_value=live_status),
  489. ),
  490. patch(
  491. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  492. new=AsyncMock(return_value=_FakeSliceJob()),
  493. ),
  494. ):
  495. resp = await async_client.post(
  496. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  497. json={"source_archive_id": archive.id},
  498. )
  499. assert resp.status_code == 202, resp.text
  500. body = resp.json()
  501. assert body["source_library_file_id"] is None
  502. assert body["source_archive_id"] == archive.id
  503. assert body["slice_job_id"] == 7777
  504. @pytest.mark.asyncio
  505. @pytest.mark.integration
  506. async def test_run_rejects_no_source(
  507. self,
  508. async_client: AsyncClient,
  509. pipeline_factory,
  510. printer_factory,
  511. ):
  512. printer = await printer_factory()
  513. pipeline = await pipeline_factory(target_printer_id=printer.id)
  514. resp = await async_client.post(f"/api/v1/slicer-pipelines/{pipeline['id']}/run", json={})
  515. assert resp.status_code == 422
  516. @pytest.mark.asyncio
  517. @pytest.mark.integration
  518. async def test_run_rejects_both_sources(
  519. self,
  520. async_client: AsyncClient,
  521. pipeline_factory,
  522. printer_factory,
  523. library_file_factory,
  524. ):
  525. printer = await printer_factory()
  526. pipeline = await pipeline_factory(target_printer_id=printer.id)
  527. src = await library_file_factory()
  528. resp = await async_client.post(
  529. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  530. json={"source_library_file_id": src.id, "source_archive_id": 99},
  531. )
  532. assert resp.status_code == 422
  533. class TestPipelineC:
  534. """PR C — multi-copy, class targeting, fanout strategies, retry-failed,
  535. dashboard list, max-copies cap."""
  536. @pytest.mark.asyncio
  537. @pytest.mark.integration
  538. async def test_copies_cap_enforced(
  539. self,
  540. async_client: AsyncClient,
  541. pipeline_factory,
  542. printer_factory,
  543. library_file_factory,
  544. ):
  545. printer = await printer_factory()
  546. pipeline = await pipeline_factory(target_printer_id=printer.id)
  547. src = await library_file_factory()
  548. # Default cap is 50; over-request returns 422 even with valid eligibility.
  549. resp = await async_client.post(
  550. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  551. json={"source_library_file_id": src.id, "copies": 9999},
  552. )
  553. assert resp.status_code == 422 # schema gate (le=1000)
  554. @pytest.mark.asyncio
  555. @pytest.mark.integration
  556. async def test_run_copies_3_creates_3_jobs(
  557. self,
  558. async_client: AsyncClient,
  559. pipeline_factory,
  560. printer_factory,
  561. library_file_factory,
  562. ):
  563. from dataclasses import dataclass
  564. @dataclass
  565. class _FakeSliceJob:
  566. id: int = 5555
  567. printer = await printer_factory()
  568. pipeline = await pipeline_factory(target_printer_id=printer.id)
  569. src = await library_file_factory()
  570. live_status = {"connected": True, "raw_data": {"ams": []}}
  571. with (
  572. patch(
  573. "backend.app.api.routes.pipeline_runs._load_printer_status",
  574. new=AsyncMock(return_value=live_status),
  575. ),
  576. patch(
  577. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  578. new=AsyncMock(return_value=_FakeSliceJob()),
  579. ),
  580. ):
  581. resp = await async_client.post(
  582. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  583. json={"source_library_file_id": src.id, "copies": 3},
  584. )
  585. assert resp.status_code == 202, resp.text
  586. body = resp.json()
  587. assert body["copies"] == 3
  588. assert len(body["jobs"]) == 3
  589. assert [j["copy_index"] for j in body["jobs"]] == [0, 1, 2]
  590. @pytest.mark.asyncio
  591. @pytest.mark.integration
  592. async def test_class_eligibility_per_printer_breakdown(
  593. self,
  594. async_client: AsyncClient,
  595. pipeline_factory,
  596. printer_factory,
  597. library_file_factory,
  598. ):
  599. """target_kind='printer_class' surfaces per-printer reports."""
  600. await printer_factory(model="X1C")
  601. await printer_factory(model="X1C")
  602. await printer_factory(model="P1S") # noise — different model
  603. pipeline = await pipeline_factory()
  604. # Wire class targeting via PUT.
  605. put_resp = await async_client.put(
  606. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  607. json={
  608. "target_kind": "printer_class",
  609. "target_printer_id": 0,
  610. "target_model_class": "X1C",
  611. "fanout_strategy": "max_parallel",
  612. },
  613. )
  614. assert put_resp.status_code == 200, put_resp.text
  615. src = await library_file_factory()
  616. resp = await async_client.post(
  617. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  618. json={"source_library_file_id": src.id},
  619. )
  620. assert resp.status_code == 200, resp.text
  621. body = resp.json()
  622. assert body["target_kind"] == "printer_class"
  623. assert body["target_model_class"] == "X1C"
  624. # Two X1Cs were created — both should appear in the per-printer breakdown.
  625. assert len(body["printer_reports"]) == 2
  626. assert all(r["printer_name"].startswith("X1C") for r in body["printer_reports"])
  627. # AMS empty + no live state → both are offline, so ok=False.
  628. assert body["ok"] is False
  629. @pytest.mark.asyncio
  630. @pytest.mark.integration
  631. async def test_class_eligibility_matches_the_model_regardless_of_case(
  632. self,
  633. async_client: AsyncClient,
  634. pipeline_factory,
  635. printer_factory,
  636. library_file_factory,
  637. ):
  638. """As the queue and the scheduler match a model (Q-U8)."""
  639. await printer_factory(model="X1C")
  640. pipeline = await pipeline_factory()
  641. await async_client.put(
  642. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  643. json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "x1c"},
  644. )
  645. src = await library_file_factory()
  646. resp = await async_client.post(
  647. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  648. json={"source_library_file_id": src.id},
  649. )
  650. body = resp.json()
  651. assert len(body["printer_reports"]) == 1
  652. assert not any(i["kind"] == "no_class_matches" for i in body["issues"])
  653. @pytest.mark.asyncio
  654. @pytest.mark.integration
  655. async def test_class_eligibility_no_matching_printers(
  656. self,
  657. async_client: AsyncClient,
  658. pipeline_factory,
  659. printer_factory,
  660. library_file_factory,
  661. ):
  662. await printer_factory(model="P1S") # only a P1S in the install
  663. pipeline = await pipeline_factory()
  664. await async_client.put(
  665. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  666. json={
  667. "target_kind": "printer_class",
  668. "target_printer_id": 0,
  669. "target_model_class": "X1C",
  670. },
  671. )
  672. src = await library_file_factory()
  673. resp = await async_client.post(
  674. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  675. json={"source_library_file_id": src.id},
  676. )
  677. body = resp.json()
  678. assert body["ok"] is False
  679. assert any(i["kind"] == "no_class_matches" for i in body["issues"])
  680. @pytest.mark.asyncio
  681. @pytest.mark.integration
  682. async def test_list_all_runs_dashboard_endpoint(
  683. self,
  684. async_client: AsyncClient,
  685. pipeline_factory,
  686. printer_factory,
  687. library_file_factory,
  688. db_session,
  689. ):
  690. from backend.app.models.pipeline_run import PipelineRun
  691. printer = await printer_factory()
  692. pipeline = await pipeline_factory(target_printer_id=printer.id)
  693. src = await library_file_factory()
  694. for i in range(3):
  695. run = PipelineRun(
  696. pipeline_id=pipeline["id"],
  697. source_library_file_id=src.id,
  698. copies=1,
  699. status="completed" if i % 2 == 0 else "failed",
  700. )
  701. db_session.add(run)
  702. await db_session.commit()
  703. resp = await async_client.get("/api/v1/pipeline-runs?limit=10")
  704. assert resp.status_code == 200
  705. body = resp.json()
  706. assert body["total"] == 3
  707. assert len(body["runs"]) == 3
  708. # Newest first.
  709. assert body["runs"][0]["id"] > body["runs"][-1]["id"]
  710. # Filter by status.
  711. resp = await async_client.get("/api/v1/pipeline-runs?status=failed")
  712. body = resp.json()
  713. assert all(r["status"] == "failed" for r in body["runs"])
  714. @pytest.mark.asyncio
  715. @pytest.mark.integration
  716. async def test_retry_failed_creates_child_run(
  717. self,
  718. async_client: AsyncClient,
  719. pipeline_factory,
  720. printer_factory,
  721. library_file_factory,
  722. db_session,
  723. ):
  724. from dataclasses import dataclass
  725. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  726. @dataclass
  727. class _FakeSliceJob:
  728. id: int = 6666
  729. printer = await printer_factory()
  730. pipeline = await pipeline_factory(target_printer_id=printer.id)
  731. src = await library_file_factory()
  732. # Build a parent run with 3 jobs: 1 completed, 2 failed → retry
  733. # should request copies=2.
  734. parent = PipelineRun(
  735. pipeline_id=pipeline["id"],
  736. source_library_file_id=src.id,
  737. copies=3,
  738. status="partial_failure",
  739. )
  740. db_session.add(parent)
  741. await db_session.flush()
  742. for idx, status in enumerate(["completed", "failed", "failed"]):
  743. db_session.add(PipelineJob(pipeline_run_id=parent.id, copy_index=idx, status=status))
  744. await db_session.commit()
  745. await db_session.refresh(parent)
  746. live_status = {"connected": True, "raw_data": {"ams": []}}
  747. with (
  748. patch(
  749. "backend.app.api.routes.pipeline_runs._load_printer_status",
  750. new=AsyncMock(return_value=live_status),
  751. ),
  752. patch(
  753. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  754. new=AsyncMock(return_value=_FakeSliceJob()),
  755. ),
  756. ):
  757. resp = await async_client.post(f"/api/v1/pipeline-runs/{parent.id}/retry-failed")
  758. assert resp.status_code == 202, resp.text
  759. body = resp.json()
  760. assert body["copies"] == 2 # only the 2 failed copies
  761. assert body["parent_run_id"] == parent.id
  762. class TestPolishFollowUp:
  763. """Polish-pass fixes: dashboard target filters, clear endpoint, and the
  764. deleted-queue-entry → cancelled rollup behaviour."""
  765. @pytest.mark.asyncio
  766. @pytest.mark.integration
  767. async def test_dashboard_filters_by_target_printer(
  768. self,
  769. async_client: AsyncClient,
  770. pipeline_factory,
  771. printer_factory,
  772. library_file_factory,
  773. db_session,
  774. ):
  775. from backend.app.models.pipeline_run import PipelineRun
  776. printer_a = await printer_factory()
  777. printer_b = await printer_factory()
  778. pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
  779. pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
  780. src = await library_file_factory()
  781. for pipe in (pipe_a, pipe_a, pipe_b):
  782. db_session.add(
  783. PipelineRun(
  784. pipeline_id=pipe["id"],
  785. source_library_file_id=src.id,
  786. copies=1,
  787. status="completed",
  788. )
  789. )
  790. await db_session.commit()
  791. resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
  792. assert resp.status_code == 200
  793. body = resp.json()
  794. assert body["total"] == 2
  795. assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])
  796. @pytest.mark.asyncio
  797. @pytest.mark.integration
  798. async def test_dashboard_filters_by_target_model_class(
  799. self,
  800. async_client: AsyncClient,
  801. pipeline_factory,
  802. printer_factory,
  803. library_file_factory,
  804. db_session,
  805. ):
  806. from backend.app.models.pipeline_run import PipelineRun
  807. await printer_factory(model="X1C")
  808. await printer_factory(model="P1S")
  809. # Two pipelines, one class-targeting X1C, one P1S.
  810. pipe_x = await pipeline_factory()
  811. await async_client.put(
  812. f"/api/v1/slicer-pipelines/{pipe_x['id']}",
  813. json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
  814. )
  815. pipe_p = await pipeline_factory()
  816. await async_client.put(
  817. f"/api/v1/slicer-pipelines/{pipe_p['id']}",
  818. json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
  819. )
  820. src = await library_file_factory()
  821. for pipe in (pipe_x, pipe_p, pipe_p):
  822. db_session.add(
  823. PipelineRun(
  824. pipeline_id=pipe["id"],
  825. source_library_file_id=src.id,
  826. copies=1,
  827. status="completed",
  828. )
  829. )
  830. await db_session.commit()
  831. resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
  832. assert resp.status_code == 200
  833. body = resp.json()
  834. assert body["total"] == 2
  835. assert all(r["target_model_class"] == "P1S" for r in body["runs"])
  836. @pytest.mark.asyncio
  837. @pytest.mark.integration
  838. async def test_clear_endpoint_deletes_terminal_runs_only(
  839. self,
  840. async_client: AsyncClient,
  841. pipeline_factory,
  842. printer_factory,
  843. library_file_factory,
  844. db_session,
  845. ):
  846. from backend.app.models.pipeline_run import PipelineRun
  847. printer = await printer_factory()
  848. pipe = await pipeline_factory(target_printer_id=printer.id)
  849. src = await library_file_factory()
  850. for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
  851. db_session.add(
  852. PipelineRun(
  853. pipeline_id=pipe["id"],
  854. source_library_file_id=src.id,
  855. copies=1,
  856. status=status,
  857. )
  858. )
  859. await db_session.commit()
  860. resp = await async_client.post("/api/v1/pipeline-runs/clear")
  861. assert resp.status_code == 200, resp.text
  862. assert resp.json()["deleted"] == 4 # 4 terminal statuses cleared
  863. # The in-flight rows survive.
  864. survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
  865. assert survivors["total"] == 2
  866. assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
  867. @staticmethod
  868. async def _dispatched_run(db_session, pipeline_factory, printer_factory, library_file_factory):
  869. """A run in 'dispatching' whose one job is queued as a real queue entry."""
  870. from datetime import datetime, timezone
  871. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  872. from backend.app.models.print_queue import PrintQueueItem
  873. printer = await printer_factory()
  874. pipe = await pipeline_factory(target_printer_id=printer.id)
  875. src = await library_file_factory()
  876. item = PrintQueueItem(printer_id=printer.id, status="pending", library_file_id=src.id)
  877. run = PipelineRun(pipeline_id=pipe["id"], source_library_file_id=src.id, copies=1, status="dispatching")
  878. db_session.add_all([item, run])
  879. await db_session.flush()
  880. db_session.add(
  881. PipelineJob(
  882. pipeline_run_id=run.id,
  883. copy_index=0,
  884. queue_entry_id=item.id,
  885. assigned_printer_id=printer.id,
  886. status="queued",
  887. dispatched_at=datetime.now(timezone.utc),
  888. )
  889. )
  890. await db_session.commit()
  891. return run.id, item.id, src.id
  892. @pytest.mark.asyncio
  893. @pytest.mark.integration
  894. async def test_deleted_queue_entry_rolls_up_as_cancelled(
  895. self,
  896. async_client: AsyncClient,
  897. pipeline_factory,
  898. printer_factory,
  899. library_file_factory,
  900. db_session,
  901. ):
  902. """When the queue entry that a PipelineJob is linked to gets deleted
  903. from the print-queue page, the job's live status should roll up to
  904. ``cancelled`` so the run doesn't sit forever showing ``queued`` /
  905. ``dispatching``. The delete leaves the job without its queue entry."""
  906. run_id, item_id, _ = await self._dispatched_run(
  907. db_session, pipeline_factory, printer_factory, library_file_factory
  908. )
  909. assert (await async_client.delete(f"/api/v1/queue/{item_id}")).status_code == 200
  910. resp = await async_client.get(f"/api/v1/pipeline-runs/{run_id}")
  911. assert resp.status_code == 200, resp.text
  912. body = resp.json()
  913. assert body["jobs"][0]["status"] == "cancelled"
  914. assert body["status"] == "cancelled"
  915. assert body["copies_cancelled"] == 1 and body["copies_in_progress"] == 0
  916. @pytest.mark.asyncio
  917. @pytest.mark.integration
  918. async def test_a_job_never_dispatched_is_not_called_cancelled(
  919. self,
  920. async_client: AsyncClient,
  921. pipeline_factory,
  922. printer_factory,
  923. library_file_factory,
  924. db_session,
  925. ):
  926. """No queue entry yet because it was never dispatched: still waiting."""
  927. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  928. printer = await printer_factory()
  929. pipe = await pipeline_factory(target_printer_id=printer.id)
  930. src = await library_file_factory()
  931. run = PipelineRun(pipeline_id=pipe["id"], source_library_file_id=src.id, copies=1, status="slicing")
  932. db_session.add(run)
  933. await db_session.flush()
  934. db_session.add(PipelineJob(pipeline_run_id=run.id, copy_index=0, status="pending"))
  935. await db_session.commit()
  936. body = (await async_client.get(f"/api/v1/pipeline-runs/{run.id}")).json()
  937. assert body["jobs"][0]["status"] == "pending"
  938. @pytest.mark.asyncio
  939. @pytest.mark.integration
  940. async def test_retry_counts_a_deleted_queue_entry_as_cancelled(
  941. self,
  942. async_client: AsyncClient,
  943. pipeline_factory,
  944. printer_factory,
  945. library_file_factory,
  946. db_session,
  947. ):
  948. """What the run view shows as cancelled, retry starts again."""
  949. from dataclasses import dataclass
  950. @dataclass
  951. class _FakeSliceJob:
  952. id: int = 6667
  953. run_id, item_id, _ = await self._dispatched_run(
  954. db_session, pipeline_factory, printer_factory, library_file_factory
  955. )
  956. assert (await async_client.delete(f"/api/v1/queue/{item_id}")).status_code == 200
  957. live_status = {"connected": True, "raw_data": {"ams": []}}
  958. with (
  959. patch(
  960. "backend.app.api.routes.pipeline_runs._load_printer_status",
  961. new=AsyncMock(return_value=live_status),
  962. ),
  963. patch(
  964. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  965. new=AsyncMock(return_value=_FakeSliceJob()),
  966. ),
  967. ):
  968. resp = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/retry-failed")
  969. assert resp.status_code == 202, resp.text
  970. assert resp.json()["copies"] == 1
  971. class TestCancelTerminal:
  972. @pytest.mark.asyncio
  973. @pytest.mark.integration
  974. async def test_cancel_terminal_run_is_idempotent(
  975. self,
  976. async_client: AsyncClient,
  977. pipeline_factory,
  978. printer_factory,
  979. library_file_factory,
  980. db_session,
  981. ):
  982. from backend.app.models.pipeline_run import PipelineRun
  983. printer = await printer_factory()
  984. pipeline = await pipeline_factory(target_printer_id=printer.id)
  985. src = await library_file_factory()
  986. run = PipelineRun(
  987. pipeline_id=pipeline["id"],
  988. source_library_file_id=src.id,
  989. copies=1,
  990. status="completed",
  991. )
  992. db_session.add(run)
  993. await db_session.commit()
  994. await db_session.refresh(run)
  995. resp = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/cancel")
  996. assert resp.status_code == 200
  997. assert resp.json()["status"] == "completed" # unchanged
  998. class TestRunViaApiKey:
  999. """An API key may run a pipeline (#1425 follow-up).
  1000. Every pipeline endpoint used to answer 403 for API keys — the three
  1001. permissions were parked as administrative in PR A, before the run dispatch
  1002. existed to decide about. ``pipelines:run`` now needs the key's Manage Queue
  1003. *and* Manage Library scopes together, because a run slices into the library
  1004. and then queues prints.
  1005. """
  1006. async def _admin_and_key(self, async_client: AsyncClient, db_session, **flags):
  1007. """Enable auth, then mint a key owned by the admin. The owner matters:
  1008. a key never out-ranks its owner, and only an owned key can stand in for
  1009. a user when a cloud preset has to be resolved."""
  1010. from sqlalchemy import select
  1011. from backend.app.core.auth import generate_api_key
  1012. from backend.app.models.api_key import APIKey
  1013. from backend.app.models.user import User
  1014. await async_client.post(
  1015. "/api/v1/auth/setup",
  1016. json={"auth_enabled": True, "admin_username": "pipeadmin", "admin_password": "AdminPass1!"},
  1017. )
  1018. admin = (await db_session.execute(select(User).where(User.username == "pipeadmin"))).scalar_one()
  1019. full_key, key_hash, key_prefix = generate_api_key()
  1020. db_session.add(
  1021. APIKey(
  1022. name="pipeline-runner",
  1023. key_hash=key_hash,
  1024. key_prefix=key_prefix,
  1025. user_id=admin.id,
  1026. enabled=True,
  1027. **{"can_read_status": False, "can_queue": False, "can_manage_library": False, **flags},
  1028. )
  1029. )
  1030. await db_session.commit()
  1031. return admin, full_key
  1032. @pytest.mark.asyncio
  1033. @pytest.mark.integration
  1034. async def test_a_scoped_key_runs_the_pipeline(
  1035. self,
  1036. async_client: AsyncClient,
  1037. pipeline_factory,
  1038. printer_factory,
  1039. library_file_factory,
  1040. db_session,
  1041. ):
  1042. from dataclasses import dataclass
  1043. @dataclass
  1044. class _FakeSliceJob:
  1045. id: int = 7777
  1046. printer = await printer_factory()
  1047. pipeline = await pipeline_factory(target_printer_id=printer.id)
  1048. src = await library_file_factory()
  1049. # Auth goes on only now: the factories above post as an anonymous
  1050. # caller, which is how every other test in this file works.
  1051. _, key = await self._admin_and_key(
  1052. async_client, db_session, can_read_status=True, can_queue=True, can_manage_library=True
  1053. )
  1054. live_status = {"connected": True, "raw_data": {"ams": []}}
  1055. with (
  1056. patch(
  1057. "backend.app.api.routes.pipeline_runs._load_printer_status",
  1058. new=AsyncMock(return_value=live_status),
  1059. ),
  1060. patch(
  1061. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  1062. new=AsyncMock(return_value=_FakeSliceJob()),
  1063. ),
  1064. ):
  1065. resp = await async_client.post(
  1066. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1067. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1068. headers={"X-API-Key": key},
  1069. )
  1070. assert resp.status_code == 202, resp.text
  1071. @pytest.mark.asyncio
  1072. @pytest.mark.integration
  1073. async def test_a_key_without_manage_library_is_refused(
  1074. self,
  1075. async_client: AsyncClient,
  1076. pipeline_factory,
  1077. printer_factory,
  1078. library_file_factory,
  1079. db_session,
  1080. ):
  1081. """Queueing prints is only half of what a run does. The refusal names
  1082. the flag that is missing rather than calling the whole thing
  1083. administrative."""
  1084. printer = await printer_factory()
  1085. pipeline = await pipeline_factory(target_printer_id=printer.id)
  1086. src = await library_file_factory()
  1087. _, key = await self._admin_and_key(async_client, db_session, can_read_status=True, can_queue=True)
  1088. resp = await async_client.post(
  1089. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1090. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1091. headers={"X-API-Key": key},
  1092. )
  1093. assert resp.status_code == 403
  1094. assert "can_manage_library" in resp.json()["detail"]
  1095. @pytest.mark.asyncio
  1096. @pytest.mark.integration
  1097. async def test_a_cloud_scoped_key_slices_as_its_owner(
  1098. self,
  1099. async_client: AsyncClient,
  1100. pipeline_factory,
  1101. printer_factory,
  1102. library_file_factory,
  1103. db_session,
  1104. ):
  1105. """A pipeline can be built on Bambu/Orca Cloud presets, and resolving
  1106. those reads a cloud token off a user record. The permission gate hands
  1107. an API-keyed request ``current_user=None``, so without falling back to
  1108. the key's owner such a pipeline would have nobody to resolve against
  1109. and would fail at slice time — the same fallback the direct slice route
  1110. makes."""
  1111. from dataclasses import dataclass
  1112. @dataclass
  1113. class _FakeSliceJob:
  1114. id: int = 7778
  1115. printer = await printer_factory()
  1116. pipeline = await pipeline_factory(target_printer_id=printer.id)
  1117. src = await library_file_factory()
  1118. admin, key = await self._admin_and_key(
  1119. async_client,
  1120. db_session,
  1121. can_read_status=True,
  1122. can_queue=True,
  1123. can_manage_library=True,
  1124. can_access_cloud=True,
  1125. )
  1126. enqueue = AsyncMock(return_value=_FakeSliceJob())
  1127. live_status = {"connected": True, "raw_data": {"ams": []}}
  1128. with (
  1129. patch(
  1130. "backend.app.api.routes.pipeline_runs._load_printer_status",
  1131. new=AsyncMock(return_value=live_status),
  1132. ),
  1133. patch("backend.app.services.slice_dispatch.slice_dispatch.enqueue", new=enqueue),
  1134. ):
  1135. resp = await async_client.post(
  1136. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1137. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1138. headers={"X-API-Key": key},
  1139. )
  1140. assert resp.status_code == 202, resp.text
  1141. assert enqueue.await_args.kwargs["owner_id"] == admin.id
  1142. @pytest.mark.asyncio
  1143. @pytest.mark.integration
  1144. async def test_a_key_without_cloud_scope_stays_anonymous(
  1145. self,
  1146. async_client: AsyncClient,
  1147. pipeline_factory,
  1148. printer_factory,
  1149. library_file_factory,
  1150. db_session,
  1151. ):
  1152. """The fallback is the cloud scope's own opt-in, not a general identity
  1153. for API keys: a key without it slices unattributed, exactly as before."""
  1154. from dataclasses import dataclass
  1155. @dataclass
  1156. class _FakeSliceJob:
  1157. id: int = 7779
  1158. printer = await printer_factory()
  1159. pipeline = await pipeline_factory(target_printer_id=printer.id)
  1160. src = await library_file_factory()
  1161. # Same owner and same three scopes as the test above — only the cloud
  1162. # opt-in differs.
  1163. _, full_key = await self._admin_and_key(
  1164. async_client, db_session, can_read_status=True, can_queue=True, can_manage_library=True
  1165. )
  1166. enqueue = AsyncMock(return_value=_FakeSliceJob())
  1167. live_status = {"connected": True, "raw_data": {"ams": []}}
  1168. with (
  1169. patch(
  1170. "backend.app.api.routes.pipeline_runs._load_printer_status",
  1171. new=AsyncMock(return_value=live_status),
  1172. ),
  1173. patch("backend.app.services.slice_dispatch.slice_dispatch.enqueue", new=enqueue),
  1174. ):
  1175. resp = await async_client.post(
  1176. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1177. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1178. headers={"X-API-Key": full_key},
  1179. )
  1180. assert resp.status_code == 202, resp.text
  1181. assert enqueue.await_args.kwargs["owner_id"] is None
  1182. class TestJobKeepsItsFinalStatus:
  1183. """A dispatched job stores its queue entry's final status, so deleting the entry
  1184. later cannot turn a printed copy into one that looks pending (or retryable)."""
  1185. @staticmethod
  1186. async def _job(db_session, run_id: int):
  1187. from sqlalchemy import select
  1188. from backend.app.models.pipeline_run import PipelineJob
  1189. db_session.expire_all()
  1190. return (await db_session.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run_id))).scalar_one()
  1191. @pytest.mark.asyncio
  1192. @pytest.mark.integration
  1193. async def test_a_printed_copy_stays_completed_after_its_entry_is_deleted(
  1194. self, async_client: AsyncClient, pipeline_factory, printer_factory, library_file_factory, db_session
  1195. ):
  1196. from backend.app.models.print_queue import PrintQueueItem
  1197. run_id, item_id, _ = await TestPolishFollowUp._dispatched_run(
  1198. db_session, pipeline_factory, printer_factory, library_file_factory
  1199. )
  1200. item = await db_session.get(PrintQueueItem, item_id)
  1201. item.status = "completed"
  1202. await db_session.commit()
  1203. assert (await async_client.delete(f"/api/v1/queue/{item_id}")).status_code == 200
  1204. body = (await async_client.get(f"/api/v1/pipeline-runs/{run_id}")).json()
  1205. retry = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/retry-failed")
  1206. assert (body["jobs"][0]["status"], body["status"]) == ("completed", "completed")
  1207. assert retry.status_code == 400, retry.text
  1208. @pytest.mark.asyncio
  1209. @pytest.mark.integration
  1210. @pytest.mark.parametrize(
  1211. ("bulk", "expected"), [("cancel", "cancelled"), ("delete", "cancelled"), ("fail", "failed")]
  1212. )
  1213. async def test_bulk_statements_store_the_final_status(
  1214. self, pipeline_factory, printer_factory, library_file_factory, db_session, bulk, expected
  1215. ):
  1216. from sqlalchemy import delete, update
  1217. from backend.app.models.print_queue import PrintQueueItem
  1218. run_id, item_id, _ = await TestPolishFollowUp._dispatched_run(
  1219. db_session, pipeline_factory, printer_factory, library_file_factory
  1220. )
  1221. if bulk == "delete":
  1222. statement = delete(PrintQueueItem).where(PrintQueueItem.id == item_id)
  1223. elif bulk == "cancel":
  1224. # As the library trash cancels the jobs of a deleted file: on the table itself.
  1225. statement = PrintQueueItem.__table__.update().where(PrintQueueItem.id == item_id).values(status="cancelled")
  1226. else:
  1227. statement = update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(status="failed")
  1228. await db_session.execute(statement)
  1229. await db_session.commit()
  1230. assert (await self._job(db_session, run_id)).status == expected
  1231. @pytest.mark.asyncio
  1232. @pytest.mark.integration
  1233. async def test_a_job_whose_entry_vanished_earlier_is_unknown_not_retried(
  1234. self, async_client: AsyncClient, pipeline_factory, printer_factory, library_file_factory, db_session
  1235. ):
  1236. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  1237. printer = await printer_factory()
  1238. pipe = await pipeline_factory(target_printer_id=printer.id)
  1239. src = await library_file_factory()
  1240. run = PipelineRun(pipeline_id=pipe["id"], source_library_file_id=src.id, copies=1, status="dispatching")
  1241. db_session.add(run)
  1242. await db_session.flush()
  1243. # Dispatched before jobs stored a final status; the entry is gone since.
  1244. db_session.add(PipelineJob(pipeline_run_id=run.id, copy_index=0, queue_entry_id=None, status="queued"))
  1245. await db_session.commit()
  1246. body = (await async_client.get(f"/api/v1/pipeline-runs/{run.id}")).json()
  1247. retry = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/retry-failed")
  1248. assert (body["jobs"][0]["status"], body["status"]) == ("unknown", "completed")
  1249. assert retry.status_code == 400, retry.text
  1250. @pytest.mark.asyncio
  1251. @pytest.mark.integration
  1252. async def test_startup_stores_the_status_of_entries_that_ended_before(
  1253. self, pipeline_factory, printer_factory, library_file_factory, db_session
  1254. ):
  1255. from backend.app.core.database import _backfill_pipeline_job_final_status
  1256. run_id, item_id, _ = await TestPolishFollowUp._dispatched_run(
  1257. db_session, pipeline_factory, printer_factory, library_file_factory
  1258. )
  1259. connection = await db_session.connection()
  1260. # Ended before jobs stored it: written past the session hooks.
  1261. await connection.exec_driver_sql(f"UPDATE print_queue SET status = 'aborted' WHERE id = {int(item_id)}")
  1262. await db_session.commit()
  1263. await _backfill_pipeline_job_final_status(await db_session.connection())
  1264. await _backfill_pipeline_job_final_status(await db_session.connection()) # a restart changes nothing
  1265. await db_session.commit()
  1266. assert (await self._job(db_session, run_id)).status == "failed"
  1267. @pytest.mark.asyncio
  1268. @pytest.mark.integration
  1269. async def test_cancelling_a_run_leaves_an_unknown_copy_unknown(
  1270. self, async_client: AsyncClient, pipeline_factory, printer_factory, library_file_factory, db_session
  1271. ):
  1272. """Cancelled would make retry print a copy that may have printed already."""
  1273. from dataclasses import dataclass
  1274. from backend.app.models.pipeline_run import PipelineJob
  1275. @dataclass
  1276. class _FakeSliceJob:
  1277. id: int = 6668
  1278. run_id, _, _ = await TestPolishFollowUp._dispatched_run(
  1279. db_session, pipeline_factory, printer_factory, library_file_factory
  1280. )
  1281. # A second copy, dispatched before jobs stored a final status; its entry is gone.
  1282. db_session.add(PipelineJob(pipeline_run_id=run_id, copy_index=1, queue_entry_id=None, status="queued"))
  1283. await db_session.commit()
  1284. cancelled = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/cancel")
  1285. live_status = {"connected": True, "raw_data": {"ams": []}}
  1286. with (
  1287. patch("backend.app.api.routes.pipeline_runs._load_printer_status", new=AsyncMock(return_value=live_status)),
  1288. patch(
  1289. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  1290. new=AsyncMock(return_value=_FakeSliceJob()),
  1291. ),
  1292. ):
  1293. retry = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/retry-failed")
  1294. assert cancelled.status_code == 200, cancelled.text
  1295. assert sorted(job["status"] for job in cancelled.json()["jobs"]) == ["cancelled", "unknown"]
  1296. assert retry.status_code == 202, retry.text
  1297. assert retry.json()["copies"] == 1