test_pipeline_runs_api.py 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428
  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_run_accepts_archive_source(
  421. self,
  422. async_client: AsyncClient,
  423. pipeline_factory,
  424. printer_factory,
  425. db_session,
  426. ):
  427. """``source_archive_id`` is accepted in place of source_library_file_id."""
  428. from pathlib import Path
  429. from backend.app.core.config import settings as app_settings
  430. from backend.app.models.archive import PrintArchive
  431. printer = await printer_factory()
  432. pipeline = await pipeline_factory(target_printer_id=printer.id)
  433. rel = "test_pipeline_archive_source.3mf"
  434. (Path(app_settings.base_dir) / rel).write_bytes(b"")
  435. archive = PrintArchive(
  436. printer_id=printer.id,
  437. filename="Archive Source.3mf",
  438. file_path=rel,
  439. file_size=0,
  440. source_3mf_path=rel,
  441. )
  442. db_session.add(archive)
  443. await db_session.commit()
  444. await db_session.refresh(archive)
  445. from dataclasses import dataclass
  446. @dataclass
  447. class _FakeSliceJob:
  448. id: int = 7777
  449. live_status = {"connected": True, "raw_data": {"ams": []}}
  450. with (
  451. patch(
  452. "backend.app.api.routes.pipeline_runs._load_printer_status",
  453. new=AsyncMock(return_value=live_status),
  454. ),
  455. patch(
  456. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  457. new=AsyncMock(return_value=_FakeSliceJob()),
  458. ),
  459. ):
  460. resp = await async_client.post(
  461. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  462. json={"source_archive_id": archive.id},
  463. )
  464. assert resp.status_code == 202, resp.text
  465. body = resp.json()
  466. assert body["source_library_file_id"] is None
  467. assert body["source_archive_id"] == archive.id
  468. assert body["slice_job_id"] == 7777
  469. @pytest.mark.asyncio
  470. @pytest.mark.integration
  471. async def test_run_rejects_no_source(
  472. self,
  473. async_client: AsyncClient,
  474. pipeline_factory,
  475. printer_factory,
  476. ):
  477. printer = await printer_factory()
  478. pipeline = await pipeline_factory(target_printer_id=printer.id)
  479. resp = await async_client.post(f"/api/v1/slicer-pipelines/{pipeline['id']}/run", json={})
  480. assert resp.status_code == 422
  481. @pytest.mark.asyncio
  482. @pytest.mark.integration
  483. async def test_run_rejects_both_sources(
  484. self,
  485. async_client: AsyncClient,
  486. pipeline_factory,
  487. printer_factory,
  488. library_file_factory,
  489. ):
  490. printer = await printer_factory()
  491. pipeline = await pipeline_factory(target_printer_id=printer.id)
  492. src = await library_file_factory()
  493. resp = await async_client.post(
  494. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  495. json={"source_library_file_id": src.id, "source_archive_id": 99},
  496. )
  497. assert resp.status_code == 422
  498. class TestPipelineC:
  499. """PR C — multi-copy, class targeting, fanout strategies, retry-failed,
  500. dashboard list, max-copies cap."""
  501. @pytest.mark.asyncio
  502. @pytest.mark.integration
  503. async def test_copies_cap_enforced(
  504. self,
  505. async_client: AsyncClient,
  506. pipeline_factory,
  507. printer_factory,
  508. library_file_factory,
  509. ):
  510. printer = await printer_factory()
  511. pipeline = await pipeline_factory(target_printer_id=printer.id)
  512. src = await library_file_factory()
  513. # Default cap is 50; over-request returns 422 even with valid eligibility.
  514. resp = await async_client.post(
  515. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  516. json={"source_library_file_id": src.id, "copies": 9999},
  517. )
  518. assert resp.status_code == 422 # schema gate (le=1000)
  519. @pytest.mark.asyncio
  520. @pytest.mark.integration
  521. async def test_run_copies_3_creates_3_jobs(
  522. self,
  523. async_client: AsyncClient,
  524. pipeline_factory,
  525. printer_factory,
  526. library_file_factory,
  527. ):
  528. from dataclasses import dataclass
  529. @dataclass
  530. class _FakeSliceJob:
  531. id: int = 5555
  532. printer = await printer_factory()
  533. pipeline = await pipeline_factory(target_printer_id=printer.id)
  534. src = await library_file_factory()
  535. live_status = {"connected": True, "raw_data": {"ams": []}}
  536. with (
  537. patch(
  538. "backend.app.api.routes.pipeline_runs._load_printer_status",
  539. new=AsyncMock(return_value=live_status),
  540. ),
  541. patch(
  542. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  543. new=AsyncMock(return_value=_FakeSliceJob()),
  544. ),
  545. ):
  546. resp = await async_client.post(
  547. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  548. json={"source_library_file_id": src.id, "copies": 3},
  549. )
  550. assert resp.status_code == 202, resp.text
  551. body = resp.json()
  552. assert body["copies"] == 3
  553. assert len(body["jobs"]) == 3
  554. assert [j["copy_index"] for j in body["jobs"]] == [0, 1, 2]
  555. @pytest.mark.asyncio
  556. @pytest.mark.integration
  557. async def test_class_eligibility_per_printer_breakdown(
  558. self,
  559. async_client: AsyncClient,
  560. pipeline_factory,
  561. printer_factory,
  562. library_file_factory,
  563. ):
  564. """target_kind='printer_class' surfaces per-printer reports."""
  565. await printer_factory(model="X1C")
  566. await printer_factory(model="X1C")
  567. await printer_factory(model="P1S") # noise — different model
  568. pipeline = await pipeline_factory()
  569. # Wire class targeting via PUT.
  570. put_resp = await async_client.put(
  571. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  572. json={
  573. "target_kind": "printer_class",
  574. "target_printer_id": 0,
  575. "target_model_class": "X1C",
  576. "fanout_strategy": "max_parallel",
  577. },
  578. )
  579. assert put_resp.status_code == 200, put_resp.text
  580. src = await library_file_factory()
  581. resp = await async_client.post(
  582. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  583. json={"source_library_file_id": src.id},
  584. )
  585. assert resp.status_code == 200, resp.text
  586. body = resp.json()
  587. assert body["target_kind"] == "printer_class"
  588. assert body["target_model_class"] == "X1C"
  589. # Two X1Cs were created — both should appear in the per-printer breakdown.
  590. assert len(body["printer_reports"]) == 2
  591. assert all(r["printer_name"].startswith("X1C") for r in body["printer_reports"])
  592. # AMS empty + no live state → both are offline, so ok=False.
  593. assert body["ok"] is False
  594. @pytest.mark.asyncio
  595. @pytest.mark.integration
  596. async def test_class_eligibility_matches_the_model_regardless_of_case(
  597. self,
  598. async_client: AsyncClient,
  599. pipeline_factory,
  600. printer_factory,
  601. library_file_factory,
  602. ):
  603. """As the queue and the scheduler match a model (Q-U8)."""
  604. await printer_factory(model="X1C")
  605. pipeline = await pipeline_factory()
  606. await async_client.put(
  607. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  608. json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "x1c"},
  609. )
  610. src = await library_file_factory()
  611. resp = await async_client.post(
  612. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  613. json={"source_library_file_id": src.id},
  614. )
  615. body = resp.json()
  616. assert len(body["printer_reports"]) == 1
  617. assert not any(i["kind"] == "no_class_matches" for i in body["issues"])
  618. @pytest.mark.asyncio
  619. @pytest.mark.integration
  620. async def test_class_eligibility_no_matching_printers(
  621. self,
  622. async_client: AsyncClient,
  623. pipeline_factory,
  624. printer_factory,
  625. library_file_factory,
  626. ):
  627. await printer_factory(model="P1S") # only a P1S in the install
  628. pipeline = await pipeline_factory()
  629. await async_client.put(
  630. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  631. json={
  632. "target_kind": "printer_class",
  633. "target_printer_id": 0,
  634. "target_model_class": "X1C",
  635. },
  636. )
  637. src = await library_file_factory()
  638. resp = await async_client.post(
  639. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  640. json={"source_library_file_id": src.id},
  641. )
  642. body = resp.json()
  643. assert body["ok"] is False
  644. assert any(i["kind"] == "no_class_matches" for i in body["issues"])
  645. @pytest.mark.asyncio
  646. @pytest.mark.integration
  647. async def test_list_all_runs_dashboard_endpoint(
  648. self,
  649. async_client: AsyncClient,
  650. pipeline_factory,
  651. printer_factory,
  652. library_file_factory,
  653. db_session,
  654. ):
  655. from backend.app.models.pipeline_run import PipelineRun
  656. printer = await printer_factory()
  657. pipeline = await pipeline_factory(target_printer_id=printer.id)
  658. src = await library_file_factory()
  659. for i in range(3):
  660. run = PipelineRun(
  661. pipeline_id=pipeline["id"],
  662. source_library_file_id=src.id,
  663. copies=1,
  664. status="completed" if i % 2 == 0 else "failed",
  665. )
  666. db_session.add(run)
  667. await db_session.commit()
  668. resp = await async_client.get("/api/v1/pipeline-runs?limit=10")
  669. assert resp.status_code == 200
  670. body = resp.json()
  671. assert body["total"] == 3
  672. assert len(body["runs"]) == 3
  673. # Newest first.
  674. assert body["runs"][0]["id"] > body["runs"][-1]["id"]
  675. # Filter by status.
  676. resp = await async_client.get("/api/v1/pipeline-runs?status=failed")
  677. body = resp.json()
  678. assert all(r["status"] == "failed" for r in body["runs"])
  679. @pytest.mark.asyncio
  680. @pytest.mark.integration
  681. async def test_retry_failed_creates_child_run(
  682. self,
  683. async_client: AsyncClient,
  684. pipeline_factory,
  685. printer_factory,
  686. library_file_factory,
  687. db_session,
  688. ):
  689. from dataclasses import dataclass
  690. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  691. @dataclass
  692. class _FakeSliceJob:
  693. id: int = 6666
  694. printer = await printer_factory()
  695. pipeline = await pipeline_factory(target_printer_id=printer.id)
  696. src = await library_file_factory()
  697. # Build a parent run with 3 jobs: 1 completed, 2 failed → retry
  698. # should request copies=2.
  699. parent = PipelineRun(
  700. pipeline_id=pipeline["id"],
  701. source_library_file_id=src.id,
  702. copies=3,
  703. status="partial_failure",
  704. )
  705. db_session.add(parent)
  706. await db_session.flush()
  707. for idx, status in enumerate(["completed", "failed", "failed"]):
  708. db_session.add(PipelineJob(pipeline_run_id=parent.id, copy_index=idx, status=status))
  709. await db_session.commit()
  710. await db_session.refresh(parent)
  711. live_status = {"connected": True, "raw_data": {"ams": []}}
  712. with (
  713. patch(
  714. "backend.app.api.routes.pipeline_runs._load_printer_status",
  715. new=AsyncMock(return_value=live_status),
  716. ),
  717. patch(
  718. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  719. new=AsyncMock(return_value=_FakeSliceJob()),
  720. ),
  721. ):
  722. resp = await async_client.post(f"/api/v1/pipeline-runs/{parent.id}/retry-failed")
  723. assert resp.status_code == 202, resp.text
  724. body = resp.json()
  725. assert body["copies"] == 2 # only the 2 failed copies
  726. assert body["parent_run_id"] == parent.id
  727. class TestPolishFollowUp:
  728. """Polish-pass fixes: dashboard target filters, clear endpoint, and the
  729. deleted-queue-entry → cancelled rollup behaviour."""
  730. @pytest.mark.asyncio
  731. @pytest.mark.integration
  732. async def test_dashboard_filters_by_target_printer(
  733. self,
  734. async_client: AsyncClient,
  735. pipeline_factory,
  736. printer_factory,
  737. library_file_factory,
  738. db_session,
  739. ):
  740. from backend.app.models.pipeline_run import PipelineRun
  741. printer_a = await printer_factory()
  742. printer_b = await printer_factory()
  743. pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
  744. pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
  745. src = await library_file_factory()
  746. for pipe in (pipe_a, pipe_a, pipe_b):
  747. db_session.add(
  748. PipelineRun(
  749. pipeline_id=pipe["id"],
  750. source_library_file_id=src.id,
  751. copies=1,
  752. status="completed",
  753. )
  754. )
  755. await db_session.commit()
  756. resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
  757. assert resp.status_code == 200
  758. body = resp.json()
  759. assert body["total"] == 2
  760. assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])
  761. @pytest.mark.asyncio
  762. @pytest.mark.integration
  763. async def test_dashboard_filters_by_target_model_class(
  764. self,
  765. async_client: AsyncClient,
  766. pipeline_factory,
  767. printer_factory,
  768. library_file_factory,
  769. db_session,
  770. ):
  771. from backend.app.models.pipeline_run import PipelineRun
  772. await printer_factory(model="X1C")
  773. await printer_factory(model="P1S")
  774. # Two pipelines, one class-targeting X1C, one P1S.
  775. pipe_x = await pipeline_factory()
  776. await async_client.put(
  777. f"/api/v1/slicer-pipelines/{pipe_x['id']}",
  778. json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
  779. )
  780. pipe_p = await pipeline_factory()
  781. await async_client.put(
  782. f"/api/v1/slicer-pipelines/{pipe_p['id']}",
  783. json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
  784. )
  785. src = await library_file_factory()
  786. for pipe in (pipe_x, pipe_p, pipe_p):
  787. db_session.add(
  788. PipelineRun(
  789. pipeline_id=pipe["id"],
  790. source_library_file_id=src.id,
  791. copies=1,
  792. status="completed",
  793. )
  794. )
  795. await db_session.commit()
  796. resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
  797. assert resp.status_code == 200
  798. body = resp.json()
  799. assert body["total"] == 2
  800. assert all(r["target_model_class"] == "P1S" for r in body["runs"])
  801. @pytest.mark.asyncio
  802. @pytest.mark.integration
  803. async def test_clear_endpoint_deletes_terminal_runs_only(
  804. self,
  805. async_client: AsyncClient,
  806. pipeline_factory,
  807. printer_factory,
  808. library_file_factory,
  809. db_session,
  810. ):
  811. from backend.app.models.pipeline_run import PipelineRun
  812. printer = await printer_factory()
  813. pipe = await pipeline_factory(target_printer_id=printer.id)
  814. src = await library_file_factory()
  815. for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
  816. db_session.add(
  817. PipelineRun(
  818. pipeline_id=pipe["id"],
  819. source_library_file_id=src.id,
  820. copies=1,
  821. status=status,
  822. )
  823. )
  824. await db_session.commit()
  825. resp = await async_client.post("/api/v1/pipeline-runs/clear")
  826. assert resp.status_code == 200, resp.text
  827. assert resp.json()["deleted"] == 4 # 4 terminal statuses cleared
  828. # The in-flight rows survive.
  829. survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
  830. assert survivors["total"] == 2
  831. assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
  832. @staticmethod
  833. async def _dispatched_run(db_session, pipeline_factory, printer_factory, library_file_factory):
  834. """A run in 'dispatching' whose one job is queued as a real queue entry."""
  835. from datetime import datetime, timezone
  836. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  837. from backend.app.models.print_queue import PrintQueueItem
  838. printer = await printer_factory()
  839. pipe = await pipeline_factory(target_printer_id=printer.id)
  840. src = await library_file_factory()
  841. item = PrintQueueItem(printer_id=printer.id, status="pending", library_file_id=src.id)
  842. run = PipelineRun(pipeline_id=pipe["id"], source_library_file_id=src.id, copies=1, status="dispatching")
  843. db_session.add_all([item, run])
  844. await db_session.flush()
  845. db_session.add(
  846. PipelineJob(
  847. pipeline_run_id=run.id,
  848. copy_index=0,
  849. queue_entry_id=item.id,
  850. assigned_printer_id=printer.id,
  851. status="queued",
  852. dispatched_at=datetime.now(timezone.utc),
  853. )
  854. )
  855. await db_session.commit()
  856. return run.id, item.id, src.id
  857. @pytest.mark.asyncio
  858. @pytest.mark.integration
  859. async def test_deleted_queue_entry_rolls_up_as_cancelled(
  860. self,
  861. async_client: AsyncClient,
  862. pipeline_factory,
  863. printer_factory,
  864. library_file_factory,
  865. db_session,
  866. ):
  867. """When the queue entry that a PipelineJob is linked to gets deleted
  868. from the print-queue page, the job's live status should roll up to
  869. ``cancelled`` so the run doesn't sit forever showing ``queued`` /
  870. ``dispatching``. The delete leaves the job without its queue entry."""
  871. run_id, item_id, _ = await self._dispatched_run(
  872. db_session, pipeline_factory, printer_factory, library_file_factory
  873. )
  874. assert (await async_client.delete(f"/api/v1/queue/{item_id}")).status_code == 200
  875. resp = await async_client.get(f"/api/v1/pipeline-runs/{run_id}")
  876. assert resp.status_code == 200, resp.text
  877. body = resp.json()
  878. assert body["jobs"][0]["status"] == "cancelled"
  879. assert body["status"] == "cancelled"
  880. assert body["copies_cancelled"] == 1 and body["copies_in_progress"] == 0
  881. @pytest.mark.asyncio
  882. @pytest.mark.integration
  883. async def test_a_job_never_dispatched_is_not_called_cancelled(
  884. self,
  885. async_client: AsyncClient,
  886. pipeline_factory,
  887. printer_factory,
  888. library_file_factory,
  889. db_session,
  890. ):
  891. """No queue entry yet because it was never dispatched: still waiting."""
  892. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  893. printer = await printer_factory()
  894. pipe = await pipeline_factory(target_printer_id=printer.id)
  895. src = await library_file_factory()
  896. run = PipelineRun(pipeline_id=pipe["id"], source_library_file_id=src.id, copies=1, status="slicing")
  897. db_session.add(run)
  898. await db_session.flush()
  899. db_session.add(PipelineJob(pipeline_run_id=run.id, copy_index=0, status="pending"))
  900. await db_session.commit()
  901. body = (await async_client.get(f"/api/v1/pipeline-runs/{run.id}")).json()
  902. assert body["jobs"][0]["status"] == "pending"
  903. @pytest.mark.asyncio
  904. @pytest.mark.integration
  905. async def test_retry_counts_a_deleted_queue_entry_as_cancelled(
  906. self,
  907. async_client: AsyncClient,
  908. pipeline_factory,
  909. printer_factory,
  910. library_file_factory,
  911. db_session,
  912. ):
  913. """What the run view shows as cancelled, retry starts again."""
  914. from dataclasses import dataclass
  915. @dataclass
  916. class _FakeSliceJob:
  917. id: int = 6667
  918. run_id, item_id, _ = await self._dispatched_run(
  919. db_session, pipeline_factory, printer_factory, library_file_factory
  920. )
  921. assert (await async_client.delete(f"/api/v1/queue/{item_id}")).status_code == 200
  922. live_status = {"connected": True, "raw_data": {"ams": []}}
  923. with (
  924. patch(
  925. "backend.app.api.routes.pipeline_runs._load_printer_status",
  926. new=AsyncMock(return_value=live_status),
  927. ),
  928. patch(
  929. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  930. new=AsyncMock(return_value=_FakeSliceJob()),
  931. ),
  932. ):
  933. resp = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/retry-failed")
  934. assert resp.status_code == 202, resp.text
  935. assert resp.json()["copies"] == 1
  936. class TestCancelTerminal:
  937. @pytest.mark.asyncio
  938. @pytest.mark.integration
  939. async def test_cancel_terminal_run_is_idempotent(
  940. self,
  941. async_client: AsyncClient,
  942. pipeline_factory,
  943. printer_factory,
  944. library_file_factory,
  945. db_session,
  946. ):
  947. from backend.app.models.pipeline_run import PipelineRun
  948. printer = await printer_factory()
  949. pipeline = await pipeline_factory(target_printer_id=printer.id)
  950. src = await library_file_factory()
  951. run = PipelineRun(
  952. pipeline_id=pipeline["id"],
  953. source_library_file_id=src.id,
  954. copies=1,
  955. status="completed",
  956. )
  957. db_session.add(run)
  958. await db_session.commit()
  959. await db_session.refresh(run)
  960. resp = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/cancel")
  961. assert resp.status_code == 200
  962. assert resp.json()["status"] == "completed" # unchanged
  963. class TestRunViaApiKey:
  964. """An API key may run a pipeline (#1425 follow-up).
  965. Every pipeline endpoint used to answer 403 for API keys — the three
  966. permissions were parked as administrative in PR A, before the run dispatch
  967. existed to decide about. ``pipelines:run`` now needs the key's Manage Queue
  968. *and* Manage Library scopes together, because a run slices into the library
  969. and then queues prints.
  970. """
  971. async def _admin_and_key(self, async_client: AsyncClient, db_session, **flags):
  972. """Enable auth, then mint a key owned by the admin. The owner matters:
  973. a key never out-ranks its owner, and only an owned key can stand in for
  974. a user when a cloud preset has to be resolved."""
  975. from sqlalchemy import select
  976. from backend.app.core.auth import generate_api_key
  977. from backend.app.models.api_key import APIKey
  978. from backend.app.models.user import User
  979. await async_client.post(
  980. "/api/v1/auth/setup",
  981. json={"auth_enabled": True, "admin_username": "pipeadmin", "admin_password": "AdminPass1!"},
  982. )
  983. admin = (await db_session.execute(select(User).where(User.username == "pipeadmin"))).scalar_one()
  984. full_key, key_hash, key_prefix = generate_api_key()
  985. db_session.add(
  986. APIKey(
  987. name="pipeline-runner",
  988. key_hash=key_hash,
  989. key_prefix=key_prefix,
  990. user_id=admin.id,
  991. enabled=True,
  992. **{"can_read_status": False, "can_queue": False, "can_manage_library": False, **flags},
  993. )
  994. )
  995. await db_session.commit()
  996. return admin, full_key
  997. @pytest.mark.asyncio
  998. @pytest.mark.integration
  999. async def test_a_scoped_key_runs_the_pipeline(
  1000. self,
  1001. async_client: AsyncClient,
  1002. pipeline_factory,
  1003. printer_factory,
  1004. library_file_factory,
  1005. db_session,
  1006. ):
  1007. from dataclasses import dataclass
  1008. @dataclass
  1009. class _FakeSliceJob:
  1010. id: int = 7777
  1011. printer = await printer_factory()
  1012. pipeline = await pipeline_factory(target_printer_id=printer.id)
  1013. src = await library_file_factory()
  1014. # Auth goes on only now: the factories above post as an anonymous
  1015. # caller, which is how every other test in this file works.
  1016. _, key = await self._admin_and_key(
  1017. async_client, db_session, can_read_status=True, can_queue=True, can_manage_library=True
  1018. )
  1019. live_status = {"connected": True, "raw_data": {"ams": []}}
  1020. with (
  1021. patch(
  1022. "backend.app.api.routes.pipeline_runs._load_printer_status",
  1023. new=AsyncMock(return_value=live_status),
  1024. ),
  1025. patch(
  1026. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  1027. new=AsyncMock(return_value=_FakeSliceJob()),
  1028. ),
  1029. ):
  1030. resp = await async_client.post(
  1031. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1032. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1033. headers={"X-API-Key": key},
  1034. )
  1035. assert resp.status_code == 202, resp.text
  1036. @pytest.mark.asyncio
  1037. @pytest.mark.integration
  1038. async def test_a_key_without_manage_library_is_refused(
  1039. self,
  1040. async_client: AsyncClient,
  1041. pipeline_factory,
  1042. printer_factory,
  1043. library_file_factory,
  1044. db_session,
  1045. ):
  1046. """Queueing prints is only half of what a run does. The refusal names
  1047. the flag that is missing rather than calling the whole thing
  1048. administrative."""
  1049. printer = await printer_factory()
  1050. pipeline = await pipeline_factory(target_printer_id=printer.id)
  1051. src = await library_file_factory()
  1052. _, key = await self._admin_and_key(async_client, db_session, can_read_status=True, can_queue=True)
  1053. resp = await async_client.post(
  1054. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1055. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1056. headers={"X-API-Key": key},
  1057. )
  1058. assert resp.status_code == 403
  1059. assert "can_manage_library" in resp.json()["detail"]
  1060. @pytest.mark.asyncio
  1061. @pytest.mark.integration
  1062. async def test_a_cloud_scoped_key_slices_as_its_owner(
  1063. self,
  1064. async_client: AsyncClient,
  1065. pipeline_factory,
  1066. printer_factory,
  1067. library_file_factory,
  1068. db_session,
  1069. ):
  1070. """A pipeline can be built on Bambu/Orca Cloud presets, and resolving
  1071. those reads a cloud token off a user record. The permission gate hands
  1072. an API-keyed request ``current_user=None``, so without falling back to
  1073. the key's owner such a pipeline would have nobody to resolve against
  1074. and would fail at slice time — the same fallback the direct slice route
  1075. makes."""
  1076. from dataclasses import dataclass
  1077. @dataclass
  1078. class _FakeSliceJob:
  1079. id: int = 7778
  1080. printer = await printer_factory()
  1081. pipeline = await pipeline_factory(target_printer_id=printer.id)
  1082. src = await library_file_factory()
  1083. admin, key = await self._admin_and_key(
  1084. async_client,
  1085. db_session,
  1086. can_read_status=True,
  1087. can_queue=True,
  1088. can_manage_library=True,
  1089. can_access_cloud=True,
  1090. )
  1091. enqueue = AsyncMock(return_value=_FakeSliceJob())
  1092. live_status = {"connected": True, "raw_data": {"ams": []}}
  1093. with (
  1094. patch(
  1095. "backend.app.api.routes.pipeline_runs._load_printer_status",
  1096. new=AsyncMock(return_value=live_status),
  1097. ),
  1098. patch("backend.app.services.slice_dispatch.slice_dispatch.enqueue", new=enqueue),
  1099. ):
  1100. resp = await async_client.post(
  1101. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1102. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1103. headers={"X-API-Key": key},
  1104. )
  1105. assert resp.status_code == 202, resp.text
  1106. assert enqueue.await_args.kwargs["owner_id"] == admin.id
  1107. @pytest.mark.asyncio
  1108. @pytest.mark.integration
  1109. async def test_a_key_without_cloud_scope_stays_anonymous(
  1110. self,
  1111. async_client: AsyncClient,
  1112. pipeline_factory,
  1113. printer_factory,
  1114. library_file_factory,
  1115. db_session,
  1116. ):
  1117. """The fallback is the cloud scope's own opt-in, not a general identity
  1118. for API keys: a key without it slices unattributed, exactly as before."""
  1119. from dataclasses import dataclass
  1120. @dataclass
  1121. class _FakeSliceJob:
  1122. id: int = 7779
  1123. printer = await printer_factory()
  1124. pipeline = await pipeline_factory(target_printer_id=printer.id)
  1125. src = await library_file_factory()
  1126. # Same owner and same three scopes as the test above — only the cloud
  1127. # opt-in differs.
  1128. _, full_key = await self._admin_and_key(
  1129. async_client, db_session, can_read_status=True, can_queue=True, can_manage_library=True
  1130. )
  1131. enqueue = AsyncMock(return_value=_FakeSliceJob())
  1132. live_status = {"connected": True, "raw_data": {"ams": []}}
  1133. with (
  1134. patch(
  1135. "backend.app.api.routes.pipeline_runs._load_printer_status",
  1136. new=AsyncMock(return_value=live_status),
  1137. ),
  1138. patch("backend.app.services.slice_dispatch.slice_dispatch.enqueue", new=enqueue),
  1139. ):
  1140. resp = await async_client.post(
  1141. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1142. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1143. headers={"X-API-Key": full_key},
  1144. )
  1145. assert resp.status_code == 202, resp.text
  1146. assert enqueue.await_args.kwargs["owner_id"] is None
  1147. class TestJobKeepsItsFinalStatus:
  1148. """A dispatched job stores its queue entry's final status, so deleting the entry
  1149. later cannot turn a printed copy into one that looks pending (or retryable)."""
  1150. @staticmethod
  1151. async def _job(db_session, run_id: int):
  1152. from sqlalchemy import select
  1153. from backend.app.models.pipeline_run import PipelineJob
  1154. db_session.expire_all()
  1155. return (await db_session.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run_id))).scalar_one()
  1156. @pytest.mark.asyncio
  1157. @pytest.mark.integration
  1158. async def test_a_printed_copy_stays_completed_after_its_entry_is_deleted(
  1159. self, async_client: AsyncClient, pipeline_factory, printer_factory, library_file_factory, db_session
  1160. ):
  1161. from backend.app.models.print_queue import PrintQueueItem
  1162. run_id, item_id, _ = await TestPolishFollowUp._dispatched_run(
  1163. db_session, pipeline_factory, printer_factory, library_file_factory
  1164. )
  1165. item = await db_session.get(PrintQueueItem, item_id)
  1166. item.status = "completed"
  1167. await db_session.commit()
  1168. assert (await async_client.delete(f"/api/v1/queue/{item_id}")).status_code == 200
  1169. body = (await async_client.get(f"/api/v1/pipeline-runs/{run_id}")).json()
  1170. retry = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/retry-failed")
  1171. assert (body["jobs"][0]["status"], body["status"]) == ("completed", "completed")
  1172. assert retry.status_code == 400, retry.text
  1173. @pytest.mark.asyncio
  1174. @pytest.mark.integration
  1175. @pytest.mark.parametrize(
  1176. ("bulk", "expected"), [("cancel", "cancelled"), ("delete", "cancelled"), ("fail", "failed")]
  1177. )
  1178. async def test_bulk_statements_store_the_final_status(
  1179. self, pipeline_factory, printer_factory, library_file_factory, db_session, bulk, expected
  1180. ):
  1181. from sqlalchemy import delete, update
  1182. from backend.app.models.print_queue import PrintQueueItem
  1183. run_id, item_id, _ = await TestPolishFollowUp._dispatched_run(
  1184. db_session, pipeline_factory, printer_factory, library_file_factory
  1185. )
  1186. if bulk == "delete":
  1187. statement = delete(PrintQueueItem).where(PrintQueueItem.id == item_id)
  1188. elif bulk == "cancel":
  1189. # As the library trash cancels the jobs of a deleted file: on the table itself.
  1190. statement = PrintQueueItem.__table__.update().where(PrintQueueItem.id == item_id).values(status="cancelled")
  1191. else:
  1192. statement = update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(status="failed")
  1193. await db_session.execute(statement)
  1194. await db_session.commit()
  1195. assert (await self._job(db_session, run_id)).status == expected
  1196. @pytest.mark.asyncio
  1197. @pytest.mark.integration
  1198. async def test_a_job_whose_entry_vanished_earlier_is_unknown_not_retried(
  1199. self, async_client: AsyncClient, pipeline_factory, printer_factory, library_file_factory, db_session
  1200. ):
  1201. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  1202. printer = await printer_factory()
  1203. pipe = await pipeline_factory(target_printer_id=printer.id)
  1204. src = await library_file_factory()
  1205. run = PipelineRun(pipeline_id=pipe["id"], source_library_file_id=src.id, copies=1, status="dispatching")
  1206. db_session.add(run)
  1207. await db_session.flush()
  1208. # Dispatched before jobs stored a final status; the entry is gone since.
  1209. db_session.add(PipelineJob(pipeline_run_id=run.id, copy_index=0, queue_entry_id=None, status="queued"))
  1210. await db_session.commit()
  1211. body = (await async_client.get(f"/api/v1/pipeline-runs/{run.id}")).json()
  1212. retry = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/retry-failed")
  1213. assert (body["jobs"][0]["status"], body["status"]) == ("unknown", "completed")
  1214. assert retry.status_code == 400, retry.text
  1215. @pytest.mark.asyncio
  1216. @pytest.mark.integration
  1217. async def test_startup_stores_the_status_of_entries_that_ended_before(
  1218. self, pipeline_factory, printer_factory, library_file_factory, db_session
  1219. ):
  1220. from backend.app.core.database import _backfill_pipeline_job_final_status
  1221. run_id, item_id, _ = await TestPolishFollowUp._dispatched_run(
  1222. db_session, pipeline_factory, printer_factory, library_file_factory
  1223. )
  1224. connection = await db_session.connection()
  1225. # Ended before jobs stored it: written past the session hooks.
  1226. await connection.exec_driver_sql(f"UPDATE print_queue SET status = 'aborted' WHERE id = {int(item_id)}")
  1227. await db_session.commit()
  1228. await _backfill_pipeline_job_final_status(await db_session.connection())
  1229. await _backfill_pipeline_job_final_status(await db_session.connection()) # a restart changes nothing
  1230. await db_session.commit()
  1231. assert (await self._job(db_session, run_id)).status == "failed"
  1232. @pytest.mark.asyncio
  1233. @pytest.mark.integration
  1234. async def test_cancelling_a_run_leaves_an_unknown_copy_unknown(
  1235. self, async_client: AsyncClient, pipeline_factory, printer_factory, library_file_factory, db_session
  1236. ):
  1237. """Cancelled would make retry print a copy that may have printed already."""
  1238. from dataclasses import dataclass
  1239. from backend.app.models.pipeline_run import PipelineJob
  1240. @dataclass
  1241. class _FakeSliceJob:
  1242. id: int = 6668
  1243. run_id, _, _ = await TestPolishFollowUp._dispatched_run(
  1244. db_session, pipeline_factory, printer_factory, library_file_factory
  1245. )
  1246. # A second copy, dispatched before jobs stored a final status; its entry is gone.
  1247. db_session.add(PipelineJob(pipeline_run_id=run_id, copy_index=1, queue_entry_id=None, status="queued"))
  1248. await db_session.commit()
  1249. cancelled = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/cancel")
  1250. live_status = {"connected": True, "raw_data": {"ams": []}}
  1251. with (
  1252. patch("backend.app.api.routes.pipeline_runs._load_printer_status", new=AsyncMock(return_value=live_status)),
  1253. patch(
  1254. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  1255. new=AsyncMock(return_value=_FakeSliceJob()),
  1256. ),
  1257. ):
  1258. retry = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/retry-failed")
  1259. assert cancelled.status_code == 200, cancelled.text
  1260. assert sorted(job["status"] for job in cancelled.json()["jobs"]) == ["cancelled", "unknown"]
  1261. assert retry.status_code == 202, retry.text
  1262. assert retry.json()["copies"] == 1