test_pipeline_runs_api.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  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. assert "printer_not_set" in kinds
  140. @pytest.mark.asyncio
  141. @pytest.mark.integration
  142. async def test_printer_disabled(
  143. self,
  144. async_client: AsyncClient,
  145. pipeline_factory,
  146. printer_factory,
  147. library_file_factory,
  148. ):
  149. printer = await printer_factory(is_active=False)
  150. pipeline = await pipeline_factory(target_printer_id=printer.id)
  151. src = await library_file_factory()
  152. with patch("backend.app.api.routes.pipeline_runs._load_printer_status", new=AsyncMock(return_value=None)):
  153. resp = await async_client.post(
  154. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  155. json={"source_library_file_id": src.id},
  156. )
  157. assert resp.status_code == 200
  158. body = resp.json()
  159. kinds = [i["kind"] for i in body["issues"]]
  160. assert "printer_disabled" in kinds
  161. # printer_offline also fires because get_status returns None — both
  162. # issues are expected and both block.
  163. assert "printer_offline" in kinds
  164. assert body["ok"] is False
  165. @pytest.mark.asyncio
  166. @pytest.mark.integration
  167. async def test_online_match_clears_issues(
  168. self,
  169. async_client: AsyncClient,
  170. pipeline_factory,
  171. printer_factory,
  172. library_file_factory,
  173. db_session,
  174. ):
  175. """Patch printer_manager so AMS slot 0 carries the same canonical
  176. type the pipeline's local-tier filament preset declares."""
  177. from backend.app.models.local_preset import LocalPreset
  178. preset = LocalPreset(
  179. name="My PLA",
  180. preset_type="filament",
  181. source="manual",
  182. setting="{}",
  183. filament_type="PLA",
  184. default_filament_colour="#FFFFFF",
  185. )
  186. db_session.add(preset)
  187. await db_session.commit()
  188. await db_session.refresh(preset)
  189. printer = await printer_factory()
  190. pipeline = await pipeline_factory(
  191. target_printer_id=printer.id,
  192. filament_presets=[{"source": "local", "id": str(preset.id)}],
  193. )
  194. src = await library_file_factory()
  195. live_status = {
  196. "connected": True,
  197. "raw_data": {"ams": [{"tray": [{"tray_type": "PLA Basic", "tray_color": "FFFFFFFF"}]}]},
  198. }
  199. with patch(
  200. "backend.app.api.routes.pipeline_runs._load_printer_status",
  201. new=AsyncMock(return_value=live_status),
  202. ):
  203. resp = await async_client.post(
  204. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  205. json={"source_library_file_id": src.id},
  206. )
  207. assert resp.status_code == 200
  208. body = resp.json()
  209. assert body["ok"] is True
  210. assert body["issues"] == []
  211. assert body["target_printer_name"] == printer.name
  212. class TestRunPipeline:
  213. """POST /slicer-pipelines/{id}/run orchestrates slice + enqueue."""
  214. @pytest.mark.asyncio
  215. @pytest.mark.integration
  216. async def test_run_with_issues_and_no_force_returns_409(
  217. self,
  218. async_client: AsyncClient,
  219. pipeline_factory,
  220. library_file_factory,
  221. ):
  222. pipeline = await pipeline_factory() # no target set
  223. src = await library_file_factory()
  224. resp = await async_client.post(
  225. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  226. json={"source_library_file_id": src.id},
  227. )
  228. assert resp.status_code == 409
  229. # Eligibility report rides in detail.
  230. detail = resp.json()["detail"]
  231. assert detail["ok"] is False
  232. assert any(i["kind"] == "printer_not_set" for i in detail["issues"])
  233. @pytest.mark.asyncio
  234. @pytest.mark.integration
  235. async def test_run_force_with_no_target_still_400(
  236. self,
  237. async_client: AsyncClient,
  238. pipeline_factory,
  239. library_file_factory,
  240. ):
  241. """``force=True`` bypasses the 409 but the run endpoint still needs a
  242. target to enqueue against — the second guard returns 400."""
  243. pipeline = await pipeline_factory()
  244. src = await library_file_factory()
  245. resp = await async_client.post(
  246. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  247. json={"source_library_file_id": src.id, "force": True},
  248. )
  249. assert resp.status_code == 400
  250. @pytest.mark.asyncio
  251. @pytest.mark.integration
  252. async def test_run_creates_run_and_job(
  253. self,
  254. async_client: AsyncClient,
  255. pipeline_factory,
  256. printer_factory,
  257. library_file_factory,
  258. ):
  259. printer = await printer_factory()
  260. pipeline = await pipeline_factory(target_printer_id=printer.id)
  261. src = await library_file_factory()
  262. live_status = {"connected": True, "raw_data": {"ams": []}}
  263. # AMS empty → eligibility surfaces filament_unverified (non-blocking)
  264. # for the standard-tier filament refs the default factory uses; report
  265. # is ok=True so no force needed.
  266. from dataclasses import dataclass
  267. @dataclass
  268. class _FakeSliceJob:
  269. id: int = 9001
  270. with (
  271. patch(
  272. "backend.app.api.routes.pipeline_runs._load_printer_status",
  273. new=AsyncMock(return_value=live_status),
  274. ),
  275. patch(
  276. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  277. new=AsyncMock(return_value=_FakeSliceJob()),
  278. ),
  279. ):
  280. resp = await async_client.post(
  281. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  282. json={"source_library_file_id": src.id},
  283. )
  284. assert resp.status_code == 202, resp.text
  285. body = resp.json()
  286. assert body["pipeline_id"] == pipeline["id"]
  287. assert body["source_library_file_id"] == src.id
  288. assert body["copies"] == 1
  289. assert body["status"] == "queued"
  290. assert len(body["jobs"]) == 1
  291. assert body["jobs"][0]["copy_index"] == 0
  292. assert body["eligibility_overridden"] is False
  293. # slice_job_id rides on the response so the frontend can call
  294. # trackJob and render the progress toast.
  295. assert body["slice_job_id"] == 9001
  296. class TestRunListAndGet:
  297. """Run history surfaces."""
  298. @pytest.mark.asyncio
  299. @pytest.mark.integration
  300. async def test_list_runs_empty(
  301. self,
  302. async_client: AsyncClient,
  303. pipeline_factory,
  304. ):
  305. pipeline = await pipeline_factory()
  306. resp = await async_client.get(f"/api/v1/slicer-pipelines/{pipeline['id']}/runs")
  307. assert resp.status_code == 200
  308. assert resp.json() == {"runs": []}
  309. @pytest.mark.asyncio
  310. @pytest.mark.integration
  311. async def test_get_run_404(
  312. self,
  313. async_client: AsyncClient,
  314. ):
  315. resp = await async_client.get("/api/v1/pipeline-runs/99999")
  316. assert resp.status_code == 404
  317. class TestCancelRun:
  318. """Cancellation marks the run + linked queue entry."""
  319. @pytest.mark.asyncio
  320. @pytest.mark.integration
  321. async def test_cancel_unknown_run_404(self, async_client: AsyncClient):
  322. resp = await async_client.post("/api/v1/pipeline-runs/99999/cancel")
  323. assert resp.status_code == 404
  324. @pytest.mark.asyncio
  325. @pytest.mark.integration
  326. async def test_cancel_marks_queued_run(
  327. self,
  328. async_client: AsyncClient,
  329. pipeline_factory,
  330. printer_factory,
  331. library_file_factory,
  332. db_session,
  333. ):
  334. printer = await printer_factory()
  335. pipeline = await pipeline_factory(target_printer_id=printer.id)
  336. src = await library_file_factory()
  337. live_status = {"connected": True, "raw_data": {"ams": []}}
  338. from dataclasses import dataclass
  339. @dataclass
  340. class _FakeSliceJob:
  341. id: int = 9001
  342. with (
  343. patch(
  344. "backend.app.api.routes.pipeline_runs._load_printer_status",
  345. new=AsyncMock(return_value=live_status),
  346. ),
  347. patch(
  348. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  349. new=AsyncMock(return_value=_FakeSliceJob()),
  350. ),
  351. ):
  352. run_resp = await async_client.post(
  353. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  354. json={"source_library_file_id": src.id},
  355. )
  356. run_id = run_resp.json()["id"]
  357. cancel_resp = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/cancel")
  358. assert cancel_resp.status_code == 200
  359. assert cancel_resp.json()["status"] == "cancelled"
  360. @pytest.mark.asyncio
  361. @pytest.mark.integration
  362. async def test_run_accepts_archive_source(
  363. self,
  364. async_client: AsyncClient,
  365. pipeline_factory,
  366. printer_factory,
  367. db_session,
  368. ):
  369. """``source_archive_id`` is accepted in place of source_library_file_id."""
  370. from pathlib import Path
  371. from backend.app.core.config import settings as app_settings
  372. from backend.app.models.archive import PrintArchive
  373. printer = await printer_factory()
  374. pipeline = await pipeline_factory(target_printer_id=printer.id)
  375. rel = "test_pipeline_archive_source.3mf"
  376. (Path(app_settings.base_dir) / rel).write_bytes(b"")
  377. archive = PrintArchive(
  378. printer_id=printer.id,
  379. filename="Archive Source.3mf",
  380. file_path=rel,
  381. file_size=0,
  382. source_3mf_path=rel,
  383. )
  384. db_session.add(archive)
  385. await db_session.commit()
  386. await db_session.refresh(archive)
  387. from dataclasses import dataclass
  388. @dataclass
  389. class _FakeSliceJob:
  390. id: int = 7777
  391. live_status = {"connected": True, "raw_data": {"ams": []}}
  392. with (
  393. patch(
  394. "backend.app.api.routes.pipeline_runs._load_printer_status",
  395. new=AsyncMock(return_value=live_status),
  396. ),
  397. patch(
  398. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  399. new=AsyncMock(return_value=_FakeSliceJob()),
  400. ),
  401. ):
  402. resp = await async_client.post(
  403. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  404. json={"source_archive_id": archive.id},
  405. )
  406. assert resp.status_code == 202, resp.text
  407. body = resp.json()
  408. assert body["source_library_file_id"] is None
  409. assert body["source_archive_id"] == archive.id
  410. assert body["slice_job_id"] == 7777
  411. @pytest.mark.asyncio
  412. @pytest.mark.integration
  413. async def test_run_rejects_no_source(
  414. self,
  415. async_client: AsyncClient,
  416. pipeline_factory,
  417. printer_factory,
  418. ):
  419. printer = await printer_factory()
  420. pipeline = await pipeline_factory(target_printer_id=printer.id)
  421. resp = await async_client.post(f"/api/v1/slicer-pipelines/{pipeline['id']}/run", json={})
  422. assert resp.status_code == 422
  423. @pytest.mark.asyncio
  424. @pytest.mark.integration
  425. async def test_run_rejects_both_sources(
  426. self,
  427. async_client: AsyncClient,
  428. pipeline_factory,
  429. printer_factory,
  430. library_file_factory,
  431. ):
  432. printer = await printer_factory()
  433. pipeline = await pipeline_factory(target_printer_id=printer.id)
  434. src = await library_file_factory()
  435. resp = await async_client.post(
  436. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  437. json={"source_library_file_id": src.id, "source_archive_id": 99},
  438. )
  439. assert resp.status_code == 422
  440. class TestCancelTerminal:
  441. @pytest.mark.asyncio
  442. @pytest.mark.integration
  443. async def test_cancel_terminal_run_is_idempotent(
  444. self,
  445. async_client: AsyncClient,
  446. pipeline_factory,
  447. printer_factory,
  448. library_file_factory,
  449. db_session,
  450. ):
  451. from backend.app.models.pipeline_run import PipelineRun
  452. printer = await printer_factory()
  453. pipeline = await pipeline_factory(target_printer_id=printer.id)
  454. src = await library_file_factory()
  455. run = PipelineRun(
  456. pipeline_id=pipeline["id"],
  457. source_library_file_id=src.id,
  458. copies=1,
  459. status="completed",
  460. )
  461. db_session.add(run)
  462. await db_session.commit()
  463. await db_session.refresh(run)
  464. resp = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/cancel")
  465. assert resp.status_code == 200
  466. assert resp.json()["status"] == "completed" # unchanged