test_pipeline_runs_api.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191
  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_no_matching_printers(
  597. self,
  598. async_client: AsyncClient,
  599. pipeline_factory,
  600. printer_factory,
  601. library_file_factory,
  602. ):
  603. await printer_factory(model="P1S") # only a P1S in the install
  604. pipeline = await pipeline_factory()
  605. 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. },
  612. )
  613. src = await library_file_factory()
  614. resp = await async_client.post(
  615. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  616. json={"source_library_file_id": src.id},
  617. )
  618. body = resp.json()
  619. assert body["ok"] is False
  620. assert any(i["kind"] == "no_class_matches" for i in body["issues"])
  621. @pytest.mark.asyncio
  622. @pytest.mark.integration
  623. async def test_list_all_runs_dashboard_endpoint(
  624. self,
  625. async_client: AsyncClient,
  626. pipeline_factory,
  627. printer_factory,
  628. library_file_factory,
  629. db_session,
  630. ):
  631. from backend.app.models.pipeline_run import PipelineRun
  632. printer = await printer_factory()
  633. pipeline = await pipeline_factory(target_printer_id=printer.id)
  634. src = await library_file_factory()
  635. for i in range(3):
  636. run = PipelineRun(
  637. pipeline_id=pipeline["id"],
  638. source_library_file_id=src.id,
  639. copies=1,
  640. status="completed" if i % 2 == 0 else "failed",
  641. )
  642. db_session.add(run)
  643. await db_session.commit()
  644. resp = await async_client.get("/api/v1/pipeline-runs?limit=10")
  645. assert resp.status_code == 200
  646. body = resp.json()
  647. assert body["total"] == 3
  648. assert len(body["runs"]) == 3
  649. # Newest first.
  650. assert body["runs"][0]["id"] > body["runs"][-1]["id"]
  651. # Filter by status.
  652. resp = await async_client.get("/api/v1/pipeline-runs?status=failed")
  653. body = resp.json()
  654. assert all(r["status"] == "failed" for r in body["runs"])
  655. @pytest.mark.asyncio
  656. @pytest.mark.integration
  657. async def test_retry_failed_creates_child_run(
  658. self,
  659. async_client: AsyncClient,
  660. pipeline_factory,
  661. printer_factory,
  662. library_file_factory,
  663. db_session,
  664. ):
  665. from dataclasses import dataclass
  666. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  667. @dataclass
  668. class _FakeSliceJob:
  669. id: int = 6666
  670. printer = await printer_factory()
  671. pipeline = await pipeline_factory(target_printer_id=printer.id)
  672. src = await library_file_factory()
  673. # Build a parent run with 3 jobs: 1 completed, 2 failed → retry
  674. # should request copies=2.
  675. parent = PipelineRun(
  676. pipeline_id=pipeline["id"],
  677. source_library_file_id=src.id,
  678. copies=3,
  679. status="partial_failure",
  680. )
  681. db_session.add(parent)
  682. await db_session.flush()
  683. for idx, status in enumerate(["completed", "failed", "failed"]):
  684. db_session.add(PipelineJob(pipeline_run_id=parent.id, copy_index=idx, status=status))
  685. await db_session.commit()
  686. await db_session.refresh(parent)
  687. live_status = {"connected": True, "raw_data": {"ams": []}}
  688. with (
  689. patch(
  690. "backend.app.api.routes.pipeline_runs._load_printer_status",
  691. new=AsyncMock(return_value=live_status),
  692. ),
  693. patch(
  694. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  695. new=AsyncMock(return_value=_FakeSliceJob()),
  696. ),
  697. ):
  698. resp = await async_client.post(f"/api/v1/pipeline-runs/{parent.id}/retry-failed")
  699. assert resp.status_code == 202, resp.text
  700. body = resp.json()
  701. assert body["copies"] == 2 # only the 2 failed copies
  702. assert body["parent_run_id"] == parent.id
  703. class TestPolishFollowUp:
  704. """Polish-pass fixes: dashboard target filters, clear endpoint, and the
  705. deleted-queue-entry → cancelled rollup behaviour."""
  706. @pytest.mark.asyncio
  707. @pytest.mark.integration
  708. async def test_dashboard_filters_by_target_printer(
  709. self,
  710. async_client: AsyncClient,
  711. pipeline_factory,
  712. printer_factory,
  713. library_file_factory,
  714. db_session,
  715. ):
  716. from backend.app.models.pipeline_run import PipelineRun
  717. printer_a = await printer_factory()
  718. printer_b = await printer_factory()
  719. pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
  720. pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
  721. src = await library_file_factory()
  722. for pipe in (pipe_a, pipe_a, pipe_b):
  723. db_session.add(
  724. PipelineRun(
  725. pipeline_id=pipe["id"],
  726. source_library_file_id=src.id,
  727. copies=1,
  728. status="completed",
  729. )
  730. )
  731. await db_session.commit()
  732. resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
  733. assert resp.status_code == 200
  734. body = resp.json()
  735. assert body["total"] == 2
  736. assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])
  737. @pytest.mark.asyncio
  738. @pytest.mark.integration
  739. async def test_dashboard_filters_by_target_model_class(
  740. self,
  741. async_client: AsyncClient,
  742. pipeline_factory,
  743. printer_factory,
  744. library_file_factory,
  745. db_session,
  746. ):
  747. from backend.app.models.pipeline_run import PipelineRun
  748. await printer_factory(model="X1C")
  749. await printer_factory(model="P1S")
  750. # Two pipelines, one class-targeting X1C, one P1S.
  751. pipe_x = await pipeline_factory()
  752. await async_client.put(
  753. f"/api/v1/slicer-pipelines/{pipe_x['id']}",
  754. json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
  755. )
  756. pipe_p = await pipeline_factory()
  757. await async_client.put(
  758. f"/api/v1/slicer-pipelines/{pipe_p['id']}",
  759. json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
  760. )
  761. src = await library_file_factory()
  762. for pipe in (pipe_x, pipe_p, pipe_p):
  763. db_session.add(
  764. PipelineRun(
  765. pipeline_id=pipe["id"],
  766. source_library_file_id=src.id,
  767. copies=1,
  768. status="completed",
  769. )
  770. )
  771. await db_session.commit()
  772. resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
  773. assert resp.status_code == 200
  774. body = resp.json()
  775. assert body["total"] == 2
  776. assert all(r["target_model_class"] == "P1S" for r in body["runs"])
  777. @pytest.mark.asyncio
  778. @pytest.mark.integration
  779. async def test_clear_endpoint_deletes_terminal_runs_only(
  780. self,
  781. async_client: AsyncClient,
  782. pipeline_factory,
  783. printer_factory,
  784. library_file_factory,
  785. db_session,
  786. ):
  787. from backend.app.models.pipeline_run import PipelineRun
  788. printer = await printer_factory()
  789. pipe = await pipeline_factory(target_printer_id=printer.id)
  790. src = await library_file_factory()
  791. for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
  792. db_session.add(
  793. PipelineRun(
  794. pipeline_id=pipe["id"],
  795. source_library_file_id=src.id,
  796. copies=1,
  797. status=status,
  798. )
  799. )
  800. await db_session.commit()
  801. resp = await async_client.post("/api/v1/pipeline-runs/clear")
  802. assert resp.status_code == 200, resp.text
  803. assert resp.json()["deleted"] == 4 # 4 terminal statuses cleared
  804. # The in-flight rows survive.
  805. survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
  806. assert survivors["total"] == 2
  807. assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
  808. @pytest.mark.asyncio
  809. @pytest.mark.integration
  810. async def test_deleted_queue_entry_rolls_up_as_cancelled(
  811. self,
  812. async_client: AsyncClient,
  813. pipeline_factory,
  814. printer_factory,
  815. library_file_factory,
  816. db_session,
  817. ):
  818. """When the queue entry that a PipelineJob is linked to gets deleted
  819. from the print-queue page, the job's live status should roll up to
  820. ``cancelled`` so the run doesn't sit forever showing ``queued`` /
  821. ``dispatching``."""
  822. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  823. printer = await printer_factory()
  824. pipe = await pipeline_factory(target_printer_id=printer.id)
  825. src = await library_file_factory()
  826. # Simulate the state PR C leaves a successful dispatch in: run is
  827. # 'dispatching' and the job has a queue_entry_id pointing at a
  828. # PrintQueueItem that no longer exists.
  829. run = PipelineRun(
  830. pipeline_id=pipe["id"],
  831. source_library_file_id=src.id,
  832. copies=1,
  833. status="dispatching",
  834. )
  835. db_session.add(run)
  836. await db_session.flush()
  837. db_session.add(
  838. PipelineJob(
  839. pipeline_run_id=run.id,
  840. copy_index=0,
  841. queue_entry_id=999999, # Doesn't exist — simulates manual delete from queue.
  842. assigned_printer_id=printer.id,
  843. status="queued",
  844. )
  845. )
  846. await db_session.commit()
  847. await db_session.refresh(run)
  848. resp = await async_client.get(f"/api/v1/pipeline-runs/{run.id}")
  849. assert resp.status_code == 200, resp.text
  850. body = resp.json()
  851. # Job rolled up to cancelled because the queue entry is gone.
  852. assert body["jobs"][0]["status"] == "cancelled"
  853. # Run also rolls up — all jobs cancelled → run reads as cancelled.
  854. assert body["status"] == "cancelled"
  855. class TestCancelTerminal:
  856. @pytest.mark.asyncio
  857. @pytest.mark.integration
  858. async def test_cancel_terminal_run_is_idempotent(
  859. self,
  860. async_client: AsyncClient,
  861. pipeline_factory,
  862. printer_factory,
  863. library_file_factory,
  864. db_session,
  865. ):
  866. from backend.app.models.pipeline_run import PipelineRun
  867. printer = await printer_factory()
  868. pipeline = await pipeline_factory(target_printer_id=printer.id)
  869. src = await library_file_factory()
  870. run = PipelineRun(
  871. pipeline_id=pipeline["id"],
  872. source_library_file_id=src.id,
  873. copies=1,
  874. status="completed",
  875. )
  876. db_session.add(run)
  877. await db_session.commit()
  878. await db_session.refresh(run)
  879. resp = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/cancel")
  880. assert resp.status_code == 200
  881. assert resp.json()["status"] == "completed" # unchanged
  882. class TestRunViaApiKey:
  883. """An API key may run a pipeline (#1425 follow-up).
  884. Every pipeline endpoint used to answer 403 for API keys — the three
  885. permissions were parked as administrative in PR A, before the run dispatch
  886. existed to decide about. ``pipelines:run`` now needs the key's Manage Queue
  887. *and* Manage Library scopes together, because a run slices into the library
  888. and then queues prints.
  889. """
  890. async def _admin_and_key(self, async_client: AsyncClient, db_session, **flags):
  891. """Enable auth, then mint a key owned by the admin. The owner matters:
  892. a key never out-ranks its owner, and only an owned key can stand in for
  893. a user when a cloud preset has to be resolved."""
  894. from sqlalchemy import select
  895. from backend.app.core.auth import generate_api_key
  896. from backend.app.models.api_key import APIKey
  897. from backend.app.models.user import User
  898. await async_client.post(
  899. "/api/v1/auth/setup",
  900. json={"auth_enabled": True, "admin_username": "pipeadmin", "admin_password": "AdminPass1!"},
  901. )
  902. admin = (await db_session.execute(select(User).where(User.username == "pipeadmin"))).scalar_one()
  903. full_key, key_hash, key_prefix = generate_api_key()
  904. db_session.add(
  905. APIKey(
  906. name="pipeline-runner",
  907. key_hash=key_hash,
  908. key_prefix=key_prefix,
  909. user_id=admin.id,
  910. enabled=True,
  911. **{"can_read_status": False, "can_queue": False, "can_manage_library": False, **flags},
  912. )
  913. )
  914. await db_session.commit()
  915. return admin, full_key
  916. @pytest.mark.asyncio
  917. @pytest.mark.integration
  918. async def test_a_scoped_key_runs_the_pipeline(
  919. self,
  920. async_client: AsyncClient,
  921. pipeline_factory,
  922. printer_factory,
  923. library_file_factory,
  924. db_session,
  925. ):
  926. from dataclasses import dataclass
  927. @dataclass
  928. class _FakeSliceJob:
  929. id: int = 7777
  930. printer = await printer_factory()
  931. pipeline = await pipeline_factory(target_printer_id=printer.id)
  932. src = await library_file_factory()
  933. # Auth goes on only now: the factories above post as an anonymous
  934. # caller, which is how every other test in this file works.
  935. _, key = await self._admin_and_key(
  936. async_client, db_session, can_read_status=True, can_queue=True, can_manage_library=True
  937. )
  938. live_status = {"connected": True, "raw_data": {"ams": []}}
  939. with (
  940. patch(
  941. "backend.app.api.routes.pipeline_runs._load_printer_status",
  942. new=AsyncMock(return_value=live_status),
  943. ),
  944. patch(
  945. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  946. new=AsyncMock(return_value=_FakeSliceJob()),
  947. ),
  948. ):
  949. resp = await async_client.post(
  950. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  951. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  952. headers={"X-API-Key": key},
  953. )
  954. assert resp.status_code == 202, resp.text
  955. @pytest.mark.asyncio
  956. @pytest.mark.integration
  957. async def test_a_key_without_manage_library_is_refused(
  958. self,
  959. async_client: AsyncClient,
  960. pipeline_factory,
  961. printer_factory,
  962. library_file_factory,
  963. db_session,
  964. ):
  965. """Queueing prints is only half of what a run does. The refusal names
  966. the flag that is missing rather than calling the whole thing
  967. administrative."""
  968. printer = await printer_factory()
  969. pipeline = await pipeline_factory(target_printer_id=printer.id)
  970. src = await library_file_factory()
  971. _, key = await self._admin_and_key(async_client, db_session, can_read_status=True, can_queue=True)
  972. resp = await async_client.post(
  973. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  974. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  975. headers={"X-API-Key": key},
  976. )
  977. assert resp.status_code == 403
  978. assert "can_manage_library" in resp.json()["detail"]
  979. @pytest.mark.asyncio
  980. @pytest.mark.integration
  981. async def test_a_cloud_scoped_key_slices_as_its_owner(
  982. self,
  983. async_client: AsyncClient,
  984. pipeline_factory,
  985. printer_factory,
  986. library_file_factory,
  987. db_session,
  988. ):
  989. """A pipeline can be built on Bambu/Orca Cloud presets, and resolving
  990. those reads a cloud token off a user record. The permission gate hands
  991. an API-keyed request ``current_user=None``, so without falling back to
  992. the key's owner such a pipeline would have nobody to resolve against
  993. and would fail at slice time — the same fallback the direct slice route
  994. makes."""
  995. from dataclasses import dataclass
  996. @dataclass
  997. class _FakeSliceJob:
  998. id: int = 7778
  999. printer = await printer_factory()
  1000. pipeline = await pipeline_factory(target_printer_id=printer.id)
  1001. src = await library_file_factory()
  1002. admin, key = await self._admin_and_key(
  1003. async_client,
  1004. db_session,
  1005. can_read_status=True,
  1006. can_queue=True,
  1007. can_manage_library=True,
  1008. can_access_cloud=True,
  1009. )
  1010. enqueue = AsyncMock(return_value=_FakeSliceJob())
  1011. live_status = {"connected": True, "raw_data": {"ams": []}}
  1012. with (
  1013. patch(
  1014. "backend.app.api.routes.pipeline_runs._load_printer_status",
  1015. new=AsyncMock(return_value=live_status),
  1016. ),
  1017. patch("backend.app.services.slice_dispatch.slice_dispatch.enqueue", new=enqueue),
  1018. ):
  1019. resp = await async_client.post(
  1020. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1021. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1022. headers={"X-API-Key": key},
  1023. )
  1024. assert resp.status_code == 202, resp.text
  1025. assert enqueue.await_args.kwargs["owner_id"] == admin.id
  1026. @pytest.mark.asyncio
  1027. @pytest.mark.integration
  1028. async def test_a_key_without_cloud_scope_stays_anonymous(
  1029. self,
  1030. async_client: AsyncClient,
  1031. pipeline_factory,
  1032. printer_factory,
  1033. library_file_factory,
  1034. db_session,
  1035. ):
  1036. """The fallback is the cloud scope's own opt-in, not a general identity
  1037. for API keys: a key without it slices unattributed, exactly as before."""
  1038. from dataclasses import dataclass
  1039. @dataclass
  1040. class _FakeSliceJob:
  1041. id: int = 7779
  1042. printer = await printer_factory()
  1043. pipeline = await pipeline_factory(target_printer_id=printer.id)
  1044. src = await library_file_factory()
  1045. # Same owner and same three scopes as the test above — only the cloud
  1046. # opt-in differs.
  1047. _, full_key = await self._admin_and_key(
  1048. async_client, db_session, can_read_status=True, can_queue=True, can_manage_library=True
  1049. )
  1050. enqueue = AsyncMock(return_value=_FakeSliceJob())
  1051. live_status = {"connected": True, "raw_data": {"ams": []}}
  1052. with (
  1053. patch(
  1054. "backend.app.api.routes.pipeline_runs._load_printer_status",
  1055. new=AsyncMock(return_value=live_status),
  1056. ),
  1057. patch("backend.app.services.slice_dispatch.slice_dispatch.enqueue", new=enqueue),
  1058. ):
  1059. resp = await async_client.post(
  1060. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1061. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1062. headers={"X-API-Key": full_key},
  1063. )
  1064. assert resp.status_code == 202, resp.text
  1065. assert enqueue.await_args.kwargs["owner_id"] is None