"""src/wov_app/routers/apps.py 的模块级测试(数据 → 测试过程 → 验证结果)。 被测模块:`src/wov_app/routers/apps.py`(用户端 API:应用列表、上传建任务、 进度查询、产物下载、重试/暂停/继续/删除),可独立调用。用例通过真实 FastAPI TestClient 走完整 HTTP 链路,数据库为临时 SQLite。 """ from __future__ import annotations from pathlib import Path import pytest from fastapi.testclient import TestClient from wov_app import registry from wov_app.db import Database from wov_app.main import app as fastapi_app @pytest.fixture(autouse=True) def _isolate_registry(): """保存/恢复注册表(模块自带隔离)。""" snapshot = dict(registry._registry) yield registry._registry.clear() registry._registry.update(snapshot) @pytest.fixture() def client(tmp_path: Path, monkeypatch) -> TestClient: """构造隔离存储与库的真实 TestClient。 `wov_app.config` 在导入时锁定路径常量,因此这里同时替换 main 与 config 的 STORAGE_DIR(路由在函数内 `from wov_app.config import STORAGE_DIR`), 保证测试不写入真实 data/。 """ storage = tmp_path / "storage" monkeypatch.setattr("wov_app.config.STORAGE_DIR", storage) monkeypatch.setattr("wov_app.config.DB_PATH", tmp_path / "wov.db") monkeypatch.setattr("wov_app.main.DB_PATH", tmp_path / "wov.db") monkeypatch.setattr("wov_app.main.STORAGE_DIR", storage) monkeypatch.setenv("WOV_AUTO_SEED", "0") monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "0") monkeypatch.setenv("WOV_CLEANUP_ENABLED", "0") monkeypatch.setenv("WOV_BATCH_ENABLED", "0") # 生命周期用 main.DB_PATH 建库;库文件路径与断言用的库保持一致。 with TestClient(fastapi_app) as test_client: yield test_client def _publish_echo_app(client: TestClient, app_id: str = "echo-app") -> str: """创建并发布一个单 echo 节点应用(echo 为内置节点)。""" definition = { "name": "echo-flow", "version": 1, "nodes": [{"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}}], "edges": [], "entry_inputs": {"video_uri": "file"}, "final_outputs": {"result": "step.file_uri"}, } client.post("/api/admin/workflows", json={ "id": app_id, "name": "Echo App", "description": "upload a file", "definition": definition, }) client.post(f"/api/admin/workflows/{app_id}/publish") return app_id # --------------------------------------------------------------------------- # 应用列表与创建任务 # --------------------------------------------------------------------------- def test_list_apps_returns_only_published(client: TestClient) -> None: """应用列表只返回已发布工作流(用户看不到草稿)。""" # 数据:一个草稿 + 一个已发布。 client.post("/api/admin/workflows", json={ "id": "draft", "name": "草稿", "description": "", "definition": {"name": "d", "version": 1, "nodes": [], "edges": []}, }) _publish_echo_app(client) # 测试过程 response = client.get("/api/apps") # 验证结果 assert response.status_code == 200 ids = {item["id"] for item in response.json()} assert ids == {"echo-app"} def test_create_run_accepts_upload_and_persists_params(client: TestClient) -> None: """上传视频创建任务:返回 run_id,参数覆盖被持久化。""" # 数据:已发布应用 + 真实上传文件 + 裁剪参数覆盖。 app_id = _publish_echo_app(client) # 测试过程 response = client.post( f"/api/apps/{app_id}/runs", files={"file": ("movie.mp4", b"fake video bytes", "video/mp4")}, data={"params": '{"step": {"interval_seconds": 3}}'}, ) # 验证结果:任务创建成功且参数被保存。 assert response.status_code == 200, response.text run_id = response.json()["id"] stored = client.get(f"/api/runs/{run_id}").json() assert stored["param_overrides"] == {"step": {"interval_seconds": 3}} assert stored["status"] in ("QUEUED", "RUNNING", "COMPLETED") def test_create_run_rejects_unpublished_app(client: TestClient) -> None: """未发布应用不接单(404)。""" # 数据:只创建不发布。 client.post("/api/admin/workflows", json={ "id": "draft-app", "name": "草稿", "description": "", "definition": {"name": "d", "version": 1, "nodes": [], "edges": []}, }) # 测试过程 response = client.post( "/api/apps/draft-app/runs", files={"file": ("a.mp4", b"x", "video/mp4")}, ) # 验证结果 assert response.status_code == 404 def test_create_run_rejects_unknown_app(client: TestClient) -> None: """不存在的应用返回 404。""" # 数据:未注册的应用 ID。 # 测试过程 response = client.post( "/api/apps/nope/runs", files={"file": ("a.mp4", b"x", "video/mp4")}, ) # 验证结果 assert response.status_code == 404 def test_create_run_rejects_invalid_params_json(client: TestClient) -> None: """params 不是合法 JSON 时返回 422(参数错误不产生任务)。""" # 数据:已发布应用 + 非法 params。 app_id = _publish_echo_app(client) # 测试过程 response = client.post( f"/api/apps/{app_id}/runs", files={"file": ("a.mp4", b"x", "video/mp4")}, data={"params": "{not json"}, ) # 验证结果 assert response.status_code == 422 # --------------------------------------------------------------------------- # 任务查询、暂停、继续、重试、删除 # --------------------------------------------------------------------------- def _create_run(client: TestClient) -> str: """创建一条任务并返回 run_id(测试辅助)。""" app_id = _publish_echo_app(client) response = client.post( f"/api/apps/{app_id}/runs", files={"file": ("m.mp4", b"data", "video/mp4")}, ) assert response.status_code == 200, response.text return response.json()["id"] def test_list_and_get_runs(client: TestClient) -> None: """任务列表与详情可读(详情含状态与进度)。""" # 数据:一条任务。 run_id = _create_run(client) # 测试过程 listed = client.get("/api/runs").json() detail = client.get(f"/api/runs/{run_id}") # 验证结果 assert any(item["id"] == run_id for item in listed) assert detail.status_code == 200 assert {"status", "progress", "id"} <= set(detail.json()) def test_get_missing_run_returns_404(client: TestClient) -> None: """不存在的任务返回 404。""" # 数据:未创建的任务 ID。 # 测试过程与验证结果 assert client.get("/api/runs/nope").status_code == 404 def test_list_runs_excludes_batch_runs_by_default(client: TestClient, tmp_path: Path) -> None: """批量 run 不进任务管理默认列表(它们是批量任务明细,由批量页展示)。""" # 数据:一条上传任务 + 一条同工作流的批量 run。 upload_id = _create_run(client) db = Database(tmp_path / "wov.db") db.create_run({ "id": "run_batch_listed", "workflow_id": "echo-app", "workflow_version": 1, "status": "RUNNING", "current_node_id": "step", "progress": 0.0, "error": None, "input_uri": "/videos/movie.mp4", "param_overrides": None, "source": "batch", "created_at": "2026-09-09T00:00:00+00:00", "updated_at": "2026-09-09T00:00:00+00:00", }) # 测试过程 default_ids = [item["id"] for item in client.get("/api/runs").json()] all_ids = [item["id"] for item in client.get("/api/runs", params={"include_batch": 1}).json()] # 验证结果:默认列表只有上传任务,显式请求时包含批量 run。 assert default_ids == [upload_id] assert set(all_ids) == {upload_id, "run_batch_listed"} def test_pause_and_resume_run(client: TestClient) -> None: """暂停置 PAUSED、继续置 QUEUED,并写入/清除暂停信号文件。""" # 数据:一条任务。 run_id = _create_run(client) # 测试过程 paused = client.post(f"/api/runs/{run_id}/pause") paused_status = client.get(f"/api/runs/{run_id}").json()["status"] resumed = client.post(f"/api/runs/{run_id}/resume") resumed_status = client.get(f"/api/runs/{run_id}").json()["status"] # 验证结果 assert paused.status_code == 200 and resumed.status_code == 200 assert paused_status == "PAUSED" assert resumed_status == "QUEUED" def test_resume_rejects_non_paused_run(client: TestClient) -> None: """非 PAUSED 状态不能继续(409/422 语义,避免误触发执行)。""" # 数据:一条刚创建的任务(QUEUED)。 run_id = _create_run(client) # 测试过程 response = client.post(f"/api/runs/{run_id}/resume") # 验证结果:不是 200 成功。 assert response.status_code != 200 def test_retry_requeues_failed_run(client: TestClient) -> None: """FAILED 任务可重试:状态回到 QUEUED 并清空错误。""" # 数据:手动把任务置为 FAILED。 run_id = _create_run(client) db: Database = client.app.state.db db.update_run(run_id, status="FAILED", error="boom", updated_at="2026-09-01T00:00:00+00:00") # 测试过程 response = client.post(f"/api/runs/{run_id}/retry") # 验证结果 assert response.status_code == 200 stored = client.get(f"/api/runs/{run_id}").json() assert stored["status"] == "QUEUED" assert stored["error"] is None def test_delete_run_removes_record_and_files(client: TestClient) -> None: """删除任务清理记录与私有上传/产物目录(不动外部路径)。""" # 数据:一条任务。 run_id = _create_run(client) db: Database = client.app.state.db # 测试过程 response = client.delete(f"/api/runs/{run_id}") # 验证结果:记录消失,再次查询 404。 assert response.status_code == 200 assert db.get_run(run_id) is None assert client.get(f"/api/runs/{run_id}").status_code == 404 def test_delete_rejects_batch_source_run(client: TestClient) -> None: """source=batch 的任务拒绝普通删除(R01:避免误删用户视频目录)。""" # 数据:一条 batch 来源任务(source 不在 update_run 白名单,直连库改)。 run_id = _create_run(client) db: Database = client.app.state.db with db._connect() as conn: conn.execute("UPDATE workflow_runs SET source='batch' WHERE id = ?", (run_id,)) # 测试过程 response = client.delete(f"/api/runs/{run_id}") # 验证结果:被拒绝(422),记录仍存在。 assert response.status_code == 422 assert db.get_run(run_id) is not None # --------------------------------------------------------------------------- # 产物查询与下载 # --------------------------------------------------------------------------- def test_list_artifacts_and_download(client: TestClient, tmp_path: Path) -> None: """产物列表可查,产物内容可按名下载。""" # 数据:任务 + 真实产物文件 + 产物记录。 run_id = _create_run(client) db: Database = client.app.state.db payload = tmp_path / "out.srt" payload.write_text("1\n00:00:01,000 --> 00:00:02,000\n你好\n", encoding="utf-8") db.create_artifact({ "run_id": run_id, "node_id": "step", "name": "result", "uri": str(payload), "mime_type": "application/x-subrip", "size": payload.stat().st_size, }) # 测试过程 listed = client.get(f"/api/runs/{run_id}/artifacts") downloaded = client.get(f"/api/runs/{run_id}/artifacts/result") # 验证结果 assert listed.status_code == 200 assert any(item["name"] == "result" for item in listed.json()) assert downloaded.status_code == 200 assert "你好" in downloaded.content.decode("utf-8") def test_download_missing_artifact_returns_404(client: TestClient) -> None: """下载不存在的产物返回 404。""" # 数据:一条任务,无该产物。 run_id = _create_run(client) # 测试过程与验证结果 assert client.get(f"/api/runs/{run_id}/artifacts/nope").status_code == 404 def test_download_missing_file_returns_404(client: TestClient) -> None: """产物记录存在但文件已被删除时返回 404(不返回残缺内容)。""" # 数据:产物记录指向不存在的文件。 run_id = _create_run(client) db: Database = client.app.state.db db.create_artifact({ "run_id": run_id, "node_id": "step", "name": "gone", "uri": "/nonexistent/file.srt", "mime_type": "application/x-subrip", "size": 0, }) # 测试过程与验证结果 assert client.get(f"/api/runs/{run_id}/artifacts/gone").status_code == 404