test_pipeline_runs_api.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927
  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. live_status = {
  201. "connected": True,
  202. "raw_data": {"ams": [{"tray": [{"tray_type": "PLA Basic", "tray_color": "FFFFFFFF"}]}]},
  203. }
  204. with patch(
  205. "backend.app.api.routes.pipeline_runs._load_printer_status",
  206. new=AsyncMock(return_value=live_status),
  207. ):
  208. resp = await async_client.post(
  209. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  210. json={"source_library_file_id": src.id},
  211. )
  212. assert resp.status_code == 200
  213. body = resp.json()
  214. assert body["ok"] is True
  215. assert body["issues"] == []
  216. assert body["target_printer_name"] == printer.name
  217. class TestRunPipeline:
  218. """POST /slicer-pipelines/{id}/run orchestrates slice + enqueue."""
  219. @pytest.mark.asyncio
  220. @pytest.mark.integration
  221. async def test_run_with_issues_and_no_force_returns_409(
  222. self,
  223. async_client: AsyncClient,
  224. pipeline_factory,
  225. library_file_factory,
  226. ):
  227. pipeline = await pipeline_factory() # no target set
  228. src = await library_file_factory()
  229. resp = await async_client.post(
  230. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  231. json={"source_library_file_id": src.id},
  232. )
  233. assert resp.status_code == 409
  234. # Eligibility report rides in detail.
  235. detail = resp.json()["detail"]
  236. assert detail["ok"] is False
  237. # printer_not_set or class_not_set — depends on the PR A default
  238. # target_kind. Both mean "no target chosen yet".
  239. kinds = [i["kind"] for i in detail["issues"]]
  240. assert "printer_not_set" in kinds or "class_not_set" in kinds
  241. @pytest.mark.asyncio
  242. @pytest.mark.integration
  243. async def test_run_force_with_no_target_still_400(
  244. self,
  245. async_client: AsyncClient,
  246. pipeline_factory,
  247. library_file_factory,
  248. ):
  249. """``force=True`` bypasses the 409 but the run endpoint still needs a
  250. target to enqueue against — the second guard returns 400."""
  251. pipeline = await pipeline_factory()
  252. src = await library_file_factory()
  253. resp = await async_client.post(
  254. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  255. json={"source_library_file_id": src.id, "force": True},
  256. )
  257. assert resp.status_code == 400
  258. @pytest.mark.asyncio
  259. @pytest.mark.integration
  260. async def test_run_creates_run_and_job(
  261. self,
  262. async_client: AsyncClient,
  263. pipeline_factory,
  264. printer_factory,
  265. library_file_factory,
  266. ):
  267. printer = await printer_factory()
  268. pipeline = await pipeline_factory(target_printer_id=printer.id)
  269. src = await library_file_factory()
  270. live_status = {"connected": True, "raw_data": {"ams": []}}
  271. # AMS empty → eligibility surfaces filament_unverified (non-blocking)
  272. # for the standard-tier filament refs the default factory uses; report
  273. # is ok=True so no force needed.
  274. from dataclasses import dataclass
  275. @dataclass
  276. class _FakeSliceJob:
  277. id: int = 9001
  278. with (
  279. patch(
  280. "backend.app.api.routes.pipeline_runs._load_printer_status",
  281. new=AsyncMock(return_value=live_status),
  282. ),
  283. patch(
  284. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  285. new=AsyncMock(return_value=_FakeSliceJob()),
  286. ),
  287. ):
  288. resp = await async_client.post(
  289. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  290. json={"source_library_file_id": src.id},
  291. )
  292. assert resp.status_code == 202, resp.text
  293. body = resp.json()
  294. assert body["pipeline_id"] == pipeline["id"]
  295. assert body["source_library_file_id"] == src.id
  296. assert body["copies"] == 1
  297. assert body["status"] == "queued"
  298. assert len(body["jobs"]) == 1
  299. assert body["jobs"][0]["copy_index"] == 0
  300. assert body["eligibility_overridden"] is False
  301. # slice_job_id rides on the response so the frontend can call
  302. # trackJob and render the progress toast.
  303. assert body["slice_job_id"] == 9001
  304. class TestRunListAndGet:
  305. """Run history surfaces."""
  306. @pytest.mark.asyncio
  307. @pytest.mark.integration
  308. async def test_list_runs_empty(
  309. self,
  310. async_client: AsyncClient,
  311. pipeline_factory,
  312. ):
  313. pipeline = await pipeline_factory()
  314. resp = await async_client.get(f"/api/v1/slicer-pipelines/{pipeline['id']}/runs")
  315. assert resp.status_code == 200
  316. assert resp.json() == {"runs": [], "total": 0}
  317. @pytest.mark.asyncio
  318. @pytest.mark.integration
  319. async def test_get_run_404(
  320. self,
  321. async_client: AsyncClient,
  322. ):
  323. resp = await async_client.get("/api/v1/pipeline-runs/99999")
  324. assert resp.status_code == 404
  325. class TestCancelRun:
  326. """Cancellation marks the run + linked queue entry."""
  327. @pytest.mark.asyncio
  328. @pytest.mark.integration
  329. async def test_cancel_unknown_run_404(self, async_client: AsyncClient):
  330. resp = await async_client.post("/api/v1/pipeline-runs/99999/cancel")
  331. assert resp.status_code == 404
  332. @pytest.mark.asyncio
  333. @pytest.mark.integration
  334. async def test_cancel_marks_queued_run(
  335. self,
  336. async_client: AsyncClient,
  337. pipeline_factory,
  338. printer_factory,
  339. library_file_factory,
  340. db_session,
  341. ):
  342. printer = await printer_factory()
  343. pipeline = await pipeline_factory(target_printer_id=printer.id)
  344. src = await library_file_factory()
  345. live_status = {"connected": True, "raw_data": {"ams": []}}
  346. from dataclasses import dataclass
  347. @dataclass
  348. class _FakeSliceJob:
  349. id: int = 9001
  350. with (
  351. patch(
  352. "backend.app.api.routes.pipeline_runs._load_printer_status",
  353. new=AsyncMock(return_value=live_status),
  354. ),
  355. patch(
  356. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  357. new=AsyncMock(return_value=_FakeSliceJob()),
  358. ),
  359. ):
  360. run_resp = await async_client.post(
  361. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  362. json={"source_library_file_id": src.id},
  363. )
  364. run_id = run_resp.json()["id"]
  365. cancel_resp = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/cancel")
  366. assert cancel_resp.status_code == 200
  367. assert cancel_resp.json()["status"] == "cancelled"
  368. @pytest.mark.asyncio
  369. @pytest.mark.integration
  370. async def test_run_accepts_archive_source(
  371. self,
  372. async_client: AsyncClient,
  373. pipeline_factory,
  374. printer_factory,
  375. db_session,
  376. ):
  377. """``source_archive_id`` is accepted in place of source_library_file_id."""
  378. from pathlib import Path
  379. from backend.app.core.config import settings as app_settings
  380. from backend.app.models.archive import PrintArchive
  381. printer = await printer_factory()
  382. pipeline = await pipeline_factory(target_printer_id=printer.id)
  383. rel = "test_pipeline_archive_source.3mf"
  384. (Path(app_settings.base_dir) / rel).write_bytes(b"")
  385. archive = PrintArchive(
  386. printer_id=printer.id,
  387. filename="Archive Source.3mf",
  388. file_path=rel,
  389. file_size=0,
  390. source_3mf_path=rel,
  391. )
  392. db_session.add(archive)
  393. await db_session.commit()
  394. await db_session.refresh(archive)
  395. from dataclasses import dataclass
  396. @dataclass
  397. class _FakeSliceJob:
  398. id: int = 7777
  399. live_status = {"connected": True, "raw_data": {"ams": []}}
  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. resp = await async_client.post(
  411. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  412. json={"source_archive_id": archive.id},
  413. )
  414. assert resp.status_code == 202, resp.text
  415. body = resp.json()
  416. assert body["source_library_file_id"] is None
  417. assert body["source_archive_id"] == archive.id
  418. assert body["slice_job_id"] == 7777
  419. @pytest.mark.asyncio
  420. @pytest.mark.integration
  421. async def test_run_rejects_no_source(
  422. self,
  423. async_client: AsyncClient,
  424. pipeline_factory,
  425. printer_factory,
  426. ):
  427. printer = await printer_factory()
  428. pipeline = await pipeline_factory(target_printer_id=printer.id)
  429. resp = await async_client.post(f"/api/v1/slicer-pipelines/{pipeline['id']}/run", json={})
  430. assert resp.status_code == 422
  431. @pytest.mark.asyncio
  432. @pytest.mark.integration
  433. async def test_run_rejects_both_sources(
  434. self,
  435. async_client: AsyncClient,
  436. pipeline_factory,
  437. printer_factory,
  438. library_file_factory,
  439. ):
  440. printer = await printer_factory()
  441. pipeline = await pipeline_factory(target_printer_id=printer.id)
  442. src = await library_file_factory()
  443. resp = await async_client.post(
  444. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  445. json={"source_library_file_id": src.id, "source_archive_id": 99},
  446. )
  447. assert resp.status_code == 422
  448. class TestPipelineC:
  449. """PR C — multi-copy, class targeting, fanout strategies, retry-failed,
  450. dashboard list, max-copies cap."""
  451. @pytest.mark.asyncio
  452. @pytest.mark.integration
  453. async def test_copies_cap_enforced(
  454. self,
  455. async_client: AsyncClient,
  456. pipeline_factory,
  457. printer_factory,
  458. library_file_factory,
  459. ):
  460. printer = await printer_factory()
  461. pipeline = await pipeline_factory(target_printer_id=printer.id)
  462. src = await library_file_factory()
  463. # Default cap is 50; over-request returns 422 even with valid eligibility.
  464. resp = await async_client.post(
  465. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  466. json={"source_library_file_id": src.id, "copies": 9999},
  467. )
  468. assert resp.status_code == 422 # schema gate (le=1000)
  469. @pytest.mark.asyncio
  470. @pytest.mark.integration
  471. async def test_run_copies_3_creates_3_jobs(
  472. self,
  473. async_client: AsyncClient,
  474. pipeline_factory,
  475. printer_factory,
  476. library_file_factory,
  477. ):
  478. from dataclasses import dataclass
  479. @dataclass
  480. class _FakeSliceJob:
  481. id: int = 5555
  482. printer = await printer_factory()
  483. pipeline = await pipeline_factory(target_printer_id=printer.id)
  484. src = await library_file_factory()
  485. live_status = {"connected": True, "raw_data": {"ams": []}}
  486. with (
  487. patch(
  488. "backend.app.api.routes.pipeline_runs._load_printer_status",
  489. new=AsyncMock(return_value=live_status),
  490. ),
  491. patch(
  492. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  493. new=AsyncMock(return_value=_FakeSliceJob()),
  494. ),
  495. ):
  496. resp = await async_client.post(
  497. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  498. json={"source_library_file_id": src.id, "copies": 3},
  499. )
  500. assert resp.status_code == 202, resp.text
  501. body = resp.json()
  502. assert body["copies"] == 3
  503. assert len(body["jobs"]) == 3
  504. assert [j["copy_index"] for j in body["jobs"]] == [0, 1, 2]
  505. @pytest.mark.asyncio
  506. @pytest.mark.integration
  507. async def test_class_eligibility_per_printer_breakdown(
  508. self,
  509. async_client: AsyncClient,
  510. pipeline_factory,
  511. printer_factory,
  512. library_file_factory,
  513. ):
  514. """target_kind='printer_class' surfaces per-printer reports."""
  515. await printer_factory(model="X1C")
  516. await printer_factory(model="X1C")
  517. await printer_factory(model="P1S") # noise — different model
  518. pipeline = await pipeline_factory()
  519. # Wire class targeting via PUT.
  520. put_resp = await async_client.put(
  521. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  522. json={
  523. "target_kind": "printer_class",
  524. "target_printer_id": 0,
  525. "target_model_class": "X1C",
  526. "fanout_strategy": "max_parallel",
  527. },
  528. )
  529. assert put_resp.status_code == 200, put_resp.text
  530. src = await library_file_factory()
  531. resp = await async_client.post(
  532. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  533. json={"source_library_file_id": src.id},
  534. )
  535. assert resp.status_code == 200, resp.text
  536. body = resp.json()
  537. assert body["target_kind"] == "printer_class"
  538. assert body["target_model_class"] == "X1C"
  539. # Two X1Cs were created — both should appear in the per-printer breakdown.
  540. assert len(body["printer_reports"]) == 2
  541. assert all(r["printer_name"].startswith("X1C") for r in body["printer_reports"])
  542. # AMS empty + no live state → both are offline, so ok=False.
  543. assert body["ok"] is False
  544. @pytest.mark.asyncio
  545. @pytest.mark.integration
  546. async def test_class_eligibility_no_matching_printers(
  547. self,
  548. async_client: AsyncClient,
  549. pipeline_factory,
  550. printer_factory,
  551. library_file_factory,
  552. ):
  553. await printer_factory(model="P1S") # only a P1S in the install
  554. pipeline = await pipeline_factory()
  555. await async_client.put(
  556. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  557. json={
  558. "target_kind": "printer_class",
  559. "target_printer_id": 0,
  560. "target_model_class": "X1C",
  561. },
  562. )
  563. src = await library_file_factory()
  564. resp = await async_client.post(
  565. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  566. json={"source_library_file_id": src.id},
  567. )
  568. body = resp.json()
  569. assert body["ok"] is False
  570. assert any(i["kind"] == "no_class_matches" for i in body["issues"])
  571. @pytest.mark.asyncio
  572. @pytest.mark.integration
  573. async def test_list_all_runs_dashboard_endpoint(
  574. self,
  575. async_client: AsyncClient,
  576. pipeline_factory,
  577. printer_factory,
  578. library_file_factory,
  579. db_session,
  580. ):
  581. from backend.app.models.pipeline_run import PipelineRun
  582. printer = await printer_factory()
  583. pipeline = await pipeline_factory(target_printer_id=printer.id)
  584. src = await library_file_factory()
  585. for i in range(3):
  586. run = PipelineRun(
  587. pipeline_id=pipeline["id"],
  588. source_library_file_id=src.id,
  589. copies=1,
  590. status="completed" if i % 2 == 0 else "failed",
  591. )
  592. db_session.add(run)
  593. await db_session.commit()
  594. resp = await async_client.get("/api/v1/pipeline-runs?limit=10")
  595. assert resp.status_code == 200
  596. body = resp.json()
  597. assert body["total"] == 3
  598. assert len(body["runs"]) == 3
  599. # Newest first.
  600. assert body["runs"][0]["id"] > body["runs"][-1]["id"]
  601. # Filter by status.
  602. resp = await async_client.get("/api/v1/pipeline-runs?status=failed")
  603. body = resp.json()
  604. assert all(r["status"] == "failed" for r in body["runs"])
  605. @pytest.mark.asyncio
  606. @pytest.mark.integration
  607. async def test_retry_failed_creates_child_run(
  608. self,
  609. async_client: AsyncClient,
  610. pipeline_factory,
  611. printer_factory,
  612. library_file_factory,
  613. db_session,
  614. ):
  615. from dataclasses import dataclass
  616. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  617. @dataclass
  618. class _FakeSliceJob:
  619. id: int = 6666
  620. printer = await printer_factory()
  621. pipeline = await pipeline_factory(target_printer_id=printer.id)
  622. src = await library_file_factory()
  623. # Build a parent run with 3 jobs: 1 completed, 2 failed → retry
  624. # should request copies=2.
  625. parent = PipelineRun(
  626. pipeline_id=pipeline["id"],
  627. source_library_file_id=src.id,
  628. copies=3,
  629. status="partial_failure",
  630. )
  631. db_session.add(parent)
  632. await db_session.flush()
  633. for idx, status in enumerate(["completed", "failed", "failed"]):
  634. db_session.add(PipelineJob(pipeline_run_id=parent.id, copy_index=idx, status=status))
  635. await db_session.commit()
  636. await db_session.refresh(parent)
  637. live_status = {"connected": True, "raw_data": {"ams": []}}
  638. with (
  639. patch(
  640. "backend.app.api.routes.pipeline_runs._load_printer_status",
  641. new=AsyncMock(return_value=live_status),
  642. ),
  643. patch(
  644. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  645. new=AsyncMock(return_value=_FakeSliceJob()),
  646. ),
  647. ):
  648. resp = await async_client.post(f"/api/v1/pipeline-runs/{parent.id}/retry-failed")
  649. assert resp.status_code == 202, resp.text
  650. body = resp.json()
  651. assert body["copies"] == 2 # only the 2 failed copies
  652. assert body["parent_run_id"] == parent.id
  653. class TestPolishFollowUp:
  654. """Polish-pass fixes: dashboard target filters, clear endpoint, and the
  655. deleted-queue-entry → cancelled rollup behaviour."""
  656. @pytest.mark.asyncio
  657. @pytest.mark.integration
  658. async def test_dashboard_filters_by_target_printer(
  659. self,
  660. async_client: AsyncClient,
  661. pipeline_factory,
  662. printer_factory,
  663. library_file_factory,
  664. db_session,
  665. ):
  666. from backend.app.models.pipeline_run import PipelineRun
  667. printer_a = await printer_factory()
  668. printer_b = await printer_factory()
  669. pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
  670. pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
  671. src = await library_file_factory()
  672. for pipe in (pipe_a, pipe_a, pipe_b):
  673. db_session.add(
  674. PipelineRun(
  675. pipeline_id=pipe["id"],
  676. source_library_file_id=src.id,
  677. copies=1,
  678. status="completed",
  679. )
  680. )
  681. await db_session.commit()
  682. resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
  683. assert resp.status_code == 200
  684. body = resp.json()
  685. assert body["total"] == 2
  686. assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])
  687. @pytest.mark.asyncio
  688. @pytest.mark.integration
  689. async def test_dashboard_filters_by_target_model_class(
  690. self,
  691. async_client: AsyncClient,
  692. pipeline_factory,
  693. printer_factory,
  694. library_file_factory,
  695. db_session,
  696. ):
  697. from backend.app.models.pipeline_run import PipelineRun
  698. await printer_factory(model="X1C")
  699. await printer_factory(model="P1S")
  700. # Two pipelines, one class-targeting X1C, one P1S.
  701. pipe_x = await pipeline_factory()
  702. await async_client.put(
  703. f"/api/v1/slicer-pipelines/{pipe_x['id']}",
  704. json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
  705. )
  706. pipe_p = await pipeline_factory()
  707. await async_client.put(
  708. f"/api/v1/slicer-pipelines/{pipe_p['id']}",
  709. json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
  710. )
  711. src = await library_file_factory()
  712. for pipe in (pipe_x, pipe_p, pipe_p):
  713. db_session.add(
  714. PipelineRun(
  715. pipeline_id=pipe["id"],
  716. source_library_file_id=src.id,
  717. copies=1,
  718. status="completed",
  719. )
  720. )
  721. await db_session.commit()
  722. resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
  723. assert resp.status_code == 200
  724. body = resp.json()
  725. assert body["total"] == 2
  726. assert all(r["target_model_class"] == "P1S" for r in body["runs"])
  727. @pytest.mark.asyncio
  728. @pytest.mark.integration
  729. async def test_clear_endpoint_deletes_terminal_runs_only(
  730. self,
  731. async_client: AsyncClient,
  732. pipeline_factory,
  733. printer_factory,
  734. library_file_factory,
  735. db_session,
  736. ):
  737. from backend.app.models.pipeline_run import PipelineRun
  738. printer = await printer_factory()
  739. pipe = await pipeline_factory(target_printer_id=printer.id)
  740. src = await library_file_factory()
  741. for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
  742. db_session.add(
  743. PipelineRun(
  744. pipeline_id=pipe["id"],
  745. source_library_file_id=src.id,
  746. copies=1,
  747. status=status,
  748. )
  749. )
  750. await db_session.commit()
  751. resp = await async_client.post("/api/v1/pipeline-runs/clear")
  752. assert resp.status_code == 200, resp.text
  753. assert resp.json()["deleted"] == 4 # 4 terminal statuses cleared
  754. # The in-flight rows survive.
  755. survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
  756. assert survivors["total"] == 2
  757. assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
  758. @pytest.mark.asyncio
  759. @pytest.mark.integration
  760. async def test_deleted_queue_entry_rolls_up_as_cancelled(
  761. self,
  762. async_client: AsyncClient,
  763. pipeline_factory,
  764. printer_factory,
  765. library_file_factory,
  766. db_session,
  767. ):
  768. """When the queue entry that a PipelineJob is linked to gets deleted
  769. from the print-queue page, the job's live status should roll up to
  770. ``cancelled`` so the run doesn't sit forever showing ``queued`` /
  771. ``dispatching``."""
  772. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  773. printer = await printer_factory()
  774. pipe = await pipeline_factory(target_printer_id=printer.id)
  775. src = await library_file_factory()
  776. # Simulate the state PR C leaves a successful dispatch in: run is
  777. # 'dispatching' and the job has a queue_entry_id pointing at a
  778. # PrintQueueItem that no longer exists.
  779. run = PipelineRun(
  780. pipeline_id=pipe["id"],
  781. source_library_file_id=src.id,
  782. copies=1,
  783. status="dispatching",
  784. )
  785. db_session.add(run)
  786. await db_session.flush()
  787. db_session.add(
  788. PipelineJob(
  789. pipeline_run_id=run.id,
  790. copy_index=0,
  791. queue_entry_id=999999, # Doesn't exist — simulates manual delete from queue.
  792. assigned_printer_id=printer.id,
  793. status="queued",
  794. )
  795. )
  796. await db_session.commit()
  797. await db_session.refresh(run)
  798. resp = await async_client.get(f"/api/v1/pipeline-runs/{run.id}")
  799. assert resp.status_code == 200, resp.text
  800. body = resp.json()
  801. # Job rolled up to cancelled because the queue entry is gone.
  802. assert body["jobs"][0]["status"] == "cancelled"
  803. # Run also rolls up — all jobs cancelled → run reads as cancelled.
  804. assert body["status"] == "cancelled"
  805. class TestCancelTerminal:
  806. @pytest.mark.asyncio
  807. @pytest.mark.integration
  808. async def test_cancel_terminal_run_is_idempotent(
  809. self,
  810. async_client: AsyncClient,
  811. pipeline_factory,
  812. printer_factory,
  813. library_file_factory,
  814. db_session,
  815. ):
  816. from backend.app.models.pipeline_run import PipelineRun
  817. printer = await printer_factory()
  818. pipeline = await pipeline_factory(target_printer_id=printer.id)
  819. src = await library_file_factory()
  820. run = PipelineRun(
  821. pipeline_id=pipeline["id"],
  822. source_library_file_id=src.id,
  823. copies=1,
  824. status="completed",
  825. )
  826. db_session.add(run)
  827. await db_session.commit()
  828. await db_session.refresh(run)
  829. resp = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/cancel")
  830. assert resp.status_code == 200
  831. assert resp.json()["status"] == "completed" # unchanged