test: 按模块重写测试代码,删除旧平铺结构
按"测试规则"重写 tests/:一个模块一个目录、用例按数据→过程→验证三段书写、 不保留全局 conftest.py、测试过程只调用真实生产代码。 结构(73 个文件、30 个模块目录、477 用例): - tests/nodes/ 15 个模块目录(srt/whisper/ass/ffmpeg/frame_extract/vlm/ subtitle_ocr/llm/llm_filter/subtitle_cleanup/subtitle_correction/ proper_nouns/adaptive_pool/vad_profiler/echo); - tests/app/ 11 个模块目录(db/scheduler/batch/maintenance/registry/seed/ storage/config/logging/main/routers 三组 API); - tests/sdk/test_models、tests/web/test_crop、tests/shared(公共设施)。 测试数据随模块目录入库(tests/**/data/),删除根级 testdata/;.gitignore 的 data/ 改为 /data/,否则会连带忽略 tests/**/data/ 导致测试数据无法入库。 顺带发现并修复三个真实缺陷: - nodes/srt.py:相邻条目缺少空行时把下一条时间轴吞进正文(静默错位), 改为正文行遇时间戳行即报错; - src/wov_app/scheduler.py:_file_size 只捕获 OSError,含 \x00 的产物 URI 抛 ValueError 导致任务误判失败,改为同时捕获; - nodes/subtitle_correction.py:生产代码依赖测试包解析 SRT, 改用生产模块 nodes/srt.py。 真实模型/服务集成测试按外部状态跳过:新增 tests/shared/gpu_memory.py (运行时探测显存、CUDA OOM 转跳过)与 tests/shared/llm_service.py (无 Key / 余额 / 限流转跳过)。全量 477 passed。
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""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_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
|
||||
@@ -0,0 +1,310 @@
|
||||
"""src/wov_app/routers/batch.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`src/wov_app/routers/batch.py`(批量处理 API:目录浏览、任务创建
|
||||
与列表、暂停/继续、删除、产物下载),可独立调用。用例通过真实 TestClient,
|
||||
文件夹为临时目录下的真实视频文件。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from wov_app import registry
|
||||
from wov_app.main import app as fastapi_app
|
||||
from wov_sdk.models import WorkflowDefinition
|
||||
|
||||
|
||||
@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。"""
|
||||
monkeypatch.setattr("wov_app.config.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.config.STORAGE_DIR", tmp_path / "storage")
|
||||
monkeypatch.setattr("wov_app.main.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.main.STORAGE_DIR", tmp_path / "storage")
|
||||
# 批量工作空间也指向临时目录(模块常量在导入时绑定)。
|
||||
monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch")
|
||||
monkeypatch.setenv("WOV_AUTO_SEED", "0")
|
||||
monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_CLEANUP_ENABLED", "0")
|
||||
monkeypatch.setenv("WOV_BATCH_ENABLED", "0")
|
||||
with TestClient(fastapi_app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def _make_video(path: Path) -> Path:
|
||||
"""创建真实可读的视频文件。"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"\x00\x00\x00\x18ftypmp42" + b"\x00" * 32)
|
||||
return path
|
||||
|
||||
|
||||
def _publish_workflow(client: TestClient, workflow_id: str = "wf") -> None:
|
||||
"""创建并发布单 echo 节点工作流。"""
|
||||
definition = WorkflowDefinition.from_dict({
|
||||
"name": "批量流程", "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"},
|
||||
}).to_dict()
|
||||
client.post("/api/admin/workflows", json={
|
||||
"id": workflow_id, "name": "批量流程", "description": "", "definition": definition,
|
||||
})
|
||||
client.post(f"/api/admin/workflows/{workflow_id}/publish")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 目录浏览(本地后端提供,浏览器拿不到绝对路径)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_roots_returns_browsable_roots(client: TestClient) -> None:
|
||||
"""根目录列表返回可浏览位置(POSIX 返回 / 与家目录)。"""
|
||||
# 数据:无(真实文件系统)。
|
||||
# 测试过程
|
||||
response = client.get("/api/batch/roots")
|
||||
|
||||
# 验证结果:非空且每项含 path/name。
|
||||
assert response.status_code == 200
|
||||
roots = response.json()
|
||||
assert roots, "至少应返回一个可浏览根目录"
|
||||
assert all({"path", "name"} <= set(item) for item in roots)
|
||||
|
||||
|
||||
def test_list_dirs_returns_only_directories(client: TestClient, tmp_path: Path) -> None:
|
||||
"""列目录只返回直接子目录,且过滤隐藏目录。"""
|
||||
# 数据:两个子目录 + 一个隐藏目录 + 一个文件。
|
||||
folder = tmp_path / "root"
|
||||
(folder / "sub1").mkdir(parents=True)
|
||||
(folder / "sub2").mkdir()
|
||||
(folder / ".hidden").mkdir()
|
||||
(folder / "file.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
# 测试过程
|
||||
body = client.get("/api/batch/dirs", params={"path": str(folder)}).json()
|
||||
|
||||
# 验证结果
|
||||
names = {item["name"] for item in body["dirs"]}
|
||||
assert names == {"sub1", "sub2"}
|
||||
|
||||
|
||||
def test_list_dirs_returns_empty_for_missing_path(client: TestClient, tmp_path: Path) -> None:
|
||||
"""目录不存在时返回空列表而非 500(前端树保持可用)。"""
|
||||
# 数据:不存在的路径。
|
||||
# 测试过程
|
||||
response = client.get("/api/batch/dirs", params={"path": str(tmp_path / "nope")})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dirs"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 创建与查询批量任务
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_job_returns_job_with_videos(client: TestClient, tmp_path: Path) -> None:
|
||||
"""创建批量任务:返回任务 ID、总数与明细列表(含跳过项)。"""
|
||||
# 数据:两个视频,其中一个已有旁挂字幕。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
_make_video(folder / "b.mp4")
|
||||
(folder / "b.CN.srt").write_text("1\n", encoding="utf-8")
|
||||
_publish_workflow(client)
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["total"] == 1
|
||||
assert len(body["videos"]) == 2
|
||||
|
||||
|
||||
def test_create_job_rejects_missing_folder(client: TestClient, tmp_path: Path) -> None:
|
||||
"""文件夹不存在返回 422。"""
|
||||
# 数据:未发布的目录路径。
|
||||
_publish_workflow(client)
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/batch/jobs", json={
|
||||
"folder": str(tmp_path / "nope"), "workflow_id": "wf", "recursive": False,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_create_job_rejects_unpublished_workflow(client: TestClient, tmp_path: Path) -> None:
|
||||
"""工作流未发布返回 422。"""
|
||||
# 数据:有视频但工作流未发布。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_create_job_rejects_folder_without_videos(client: TestClient, tmp_path: Path) -> None:
|
||||
"""文件夹内无视频返回 422。"""
|
||||
# 数据:空文件夹 + 已发布工作流。
|
||||
folder = tmp_path / "videos"
|
||||
folder.mkdir()
|
||||
_publish_workflow(client)
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_list_and_get_job(client: TestClient, tmp_path: Path) -> None:
|
||||
"""任务列表与详情可读,详情含视频明细。"""
|
||||
# 数据:一条批量任务。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
_publish_workflow(client)
|
||||
job_id = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
}).json()["id"]
|
||||
|
||||
# 测试过程
|
||||
listed = client.get("/api/batch/jobs").json()
|
||||
detail = client.get(f"/api/batch/jobs/{job_id}")
|
||||
|
||||
# 验证结果
|
||||
assert any(item["id"] == job_id for item in listed)
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["videos"][0]["video_path"].endswith("a.mp4")
|
||||
|
||||
|
||||
def test_get_missing_job_returns_404(client: TestClient) -> None:
|
||||
"""不存在的批量任务返回 404。"""
|
||||
# 数据:未创建。
|
||||
# 测试过程与验证结果
|
||||
assert client.get("/api/batch/jobs/nope").status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 暂停 / 继续 / 删除
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pause_and_resume_job(client: TestClient, tmp_path: Path) -> None:
|
||||
"""暂停置 PAUSED、继续置 QUEUED。"""
|
||||
# 数据:一条待处理批量任务。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "a.mp4")
|
||||
_publish_workflow(client)
|
||||
job_id = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
}).json()["id"]
|
||||
|
||||
# 测试过程
|
||||
paused = client.post(f"/api/batch/jobs/{job_id}/pause")
|
||||
after_pause = client.get(f"/api/batch/jobs/{job_id}").json()["status"]
|
||||
resumed = client.post(f"/api/batch/jobs/{job_id}/resume")
|
||||
after_resume = client.get(f"/api/batch/jobs/{job_id}").json()["status"]
|
||||
|
||||
# 验证结果
|
||||
assert paused.status_code == 200 and resumed.status_code == 200
|
||||
assert after_pause == "PAUSED"
|
||||
assert after_resume == "QUEUED"
|
||||
|
||||
|
||||
def test_delete_job_removes_records_and_keeps_media(client: TestClient, tmp_path: Path) -> None:
|
||||
"""删除任务清理记录与私有工作空间,保留用户视频。"""
|
||||
# 数据:一条批量任务。
|
||||
folder = tmp_path / "videos"
|
||||
video = _make_video(folder / "a.mp4")
|
||||
_publish_workflow(client)
|
||||
job_id = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
}).json()["id"]
|
||||
|
||||
# 测试过程
|
||||
response = client.delete(f"/api/batch/jobs/{job_id}")
|
||||
|
||||
# 验证结果:任务消失但视频保留。
|
||||
assert response.status_code == 200
|
||||
assert client.get(f"/api/batch/jobs/{job_id}").status_code == 404
|
||||
assert video.is_file()
|
||||
|
||||
|
||||
def test_delete_missing_job_returns_404(client: TestClient) -> None:
|
||||
"""删除不存在的任务返回 404。"""
|
||||
# 数据:未创建。
|
||||
# 测试过程与验证结果
|
||||
assert client.delete("/api/batch/jobs/nope").status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 产物下载
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_download_sidecar_product(client: TestClient, tmp_path: Path) -> None:
|
||||
"""下载接口返回视频旁的产物文件内容。"""
|
||||
# 数据:一条批量任务 + 视频旁的成品字幕。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "movie.mp4")
|
||||
_publish_workflow(client)
|
||||
body = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
}).json()
|
||||
job_id = body["id"]
|
||||
video_id = body["videos"][0]["id"]
|
||||
(folder / "movie.CN.srt").write_text("1\n00:00:01,000 --> 00:00:02,000\n你好\n", encoding="utf-8")
|
||||
|
||||
# 测试过程
|
||||
response = client.get(
|
||||
f"/api/batch/jobs/{job_id}/videos/{video_id}/download",
|
||||
params={"alias": "movie.CN.srt"},
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
assert "你好" in response.content.decode("utf-8")
|
||||
|
||||
|
||||
def test_download_missing_product_returns_404(client: TestClient, tmp_path: Path) -> None:
|
||||
"""产物不存在时返回 404。"""
|
||||
# 数据:一条批量任务(无产物)。
|
||||
folder = tmp_path / "videos"
|
||||
_make_video(folder / "movie.mp4")
|
||||
_publish_workflow(client)
|
||||
body = client.post("/api/batch/jobs", json={
|
||||
"folder": str(folder), "workflow_id": "wf", "recursive": False,
|
||||
}).json()
|
||||
|
||||
# 测试过程
|
||||
response = client.get(
|
||||
f"/api/batch/jobs/{body['id']}/videos/{body['videos'][0]['id']}/download",
|
||||
params={"alias": "movie.CN.srt"},
|
||||
)
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,311 @@
|
||||
"""src/wov_app/routers/workflows.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`src/wov_app/routers/workflows.py`(管理端 API:工作流 CRUD、
|
||||
校验、发布、版本历史),可独立调用。用例通过真实 TestClient 走 HTTP 链路,
|
||||
数据库为临时 SQLite。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from wov_app.main import app as fastapi_app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
"""构造隔离数据库与存储的真实 TestClient。"""
|
||||
monkeypatch.setattr("wov_app.config.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.config.STORAGE_DIR", tmp_path / "storage")
|
||||
monkeypatch.setattr("wov_app.main.DB_PATH", tmp_path / "wov.db")
|
||||
monkeypatch.setattr("wov_app.main.STORAGE_DIR", tmp_path / "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")
|
||||
with TestClient(fastapi_app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def _definition(name: str = "echo-flow", nodes: list[dict] | None = None, edges: list[dict] | None = None) -> dict:
|
||||
"""构造合法 DAG 定义(默认单 echo 节点)。"""
|
||||
return {
|
||||
"name": name,
|
||||
"version": 1,
|
||||
"nodes": nodes or [
|
||||
{"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}},
|
||||
],
|
||||
"edges": edges or [],
|
||||
"entry_inputs": {"video_uri": "file"},
|
||||
"final_outputs": {"result": "step.file_uri"},
|
||||
}
|
||||
|
||||
|
||||
def _create(client: TestClient, workflow_id: str = "wf", definition: dict | None = None) -> dict:
|
||||
"""创建一个工作流并返回响应体。"""
|
||||
response = client.post("/api/admin/workflows", json={
|
||||
"id": workflow_id, "name": "测试流程", "description": "说明",
|
||||
"definition": definition or _definition(),
|
||||
})
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 创建与读取
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_workflow_saves_first_version(client: TestClient) -> None:
|
||||
"""创建工作流保存为 v1,初始未发布。"""
|
||||
# 数据:合法定义。
|
||||
# 测试过程
|
||||
created = _create(client, "wf-1")
|
||||
|
||||
# 验证结果
|
||||
assert created["id"] == "wf-1"
|
||||
assert created["latest_version"] == 1
|
||||
assert created["published"] is False
|
||||
|
||||
|
||||
def test_create_workflow_appends_new_version(client: TestClient) -> None:
|
||||
"""对已存在工作流再次创建 → 追加新版本(保存即新版本)。"""
|
||||
# 数据:同一 ID 两次创建。
|
||||
_create(client, "wf-1")
|
||||
|
||||
# 测试过程
|
||||
second = _create(client, "wf-1")
|
||||
|
||||
# 验证结果:版本递增。
|
||||
assert second["latest_version"] == 2
|
||||
|
||||
|
||||
def test_get_workflow_returns_latest_definition(client: TestClient) -> None:
|
||||
"""详情返回最新版本定义(供编排页加载编辑)。"""
|
||||
# 数据:已创建工作流。
|
||||
_create(client, "wf-1")
|
||||
|
||||
# 测试过程
|
||||
detail = client.get("/api/admin/workflows/wf-1")
|
||||
|
||||
# 验证结果
|
||||
assert detail.status_code == 200
|
||||
body = detail.json()
|
||||
assert body["latest_version_data"]["version"] == 1
|
||||
assert body["latest_version_data"]["definition"]["nodes"][0]["id"] == "step"
|
||||
|
||||
|
||||
def test_get_missing_workflow_returns_404(client: TestClient) -> None:
|
||||
"""不存在的工作流返回 404。"""
|
||||
# 数据:未创建。
|
||||
# 测试过程与验证结果
|
||||
assert client.get("/api/admin/workflows/nope").status_code == 404
|
||||
|
||||
|
||||
def test_list_workflows(client: TestClient) -> None:
|
||||
"""列表返回全部工作流概要。"""
|
||||
# 数据:两个工作流。
|
||||
_create(client, "wf-1")
|
||||
_create(client, "wf-2")
|
||||
|
||||
# 测试过程
|
||||
listed = client.get("/api/admin/workflows").json()
|
||||
|
||||
# 验证结果
|
||||
assert {item["id"] for item in listed} == {"wf-1", "wf-2"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 校验:拒绝非法 DAG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_rejects_cycle(client: TestClient) -> None:
|
||||
"""环形 DAG 保存被拒(422),不产生工作流记录(R04)。"""
|
||||
# 数据:A→B→A 的环形定义。
|
||||
cyclic = _definition(nodes=[
|
||||
{"id": "a", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
|
||||
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
|
||||
], edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "a"}])
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows", json={
|
||||
"id": "wf-cycle", "name": "环形", "description": "", "definition": cyclic,
|
||||
})
|
||||
|
||||
# 验证结果:422,且未写库。
|
||||
assert response.status_code == 422
|
||||
assert client.get("/api/admin/workflows/wf-cycle").status_code == 404
|
||||
|
||||
|
||||
def test_create_rejects_self_loop(client: TestClient) -> None:
|
||||
"""自环(节点指向自己)同样被拒绝。"""
|
||||
# 数据:单节点自环。
|
||||
loop = _definition(
|
||||
nodes=[{"id": "a", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}}],
|
||||
edges=[{"from": "a", "to": "a"}],
|
||||
)
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows", json={
|
||||
"id": "wf-loop", "name": "自环", "description": "", "definition": loop,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_create_rejects_edge_to_unknown_node(client: TestClient) -> None:
|
||||
"""边引用不存在的节点被拒绝。"""
|
||||
# 数据:边指向 ghost 节点。
|
||||
bad = _definition(edges=[{"from": "step", "to": "ghost"}])
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows", json={
|
||||
"id": "wf-bad", "name": "坏边", "description": "", "definition": bad,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_create_rejects_duplicate_node_ids(client: TestClient) -> None:
|
||||
"""节点 ID 重复被拒绝。"""
|
||||
# 数据:两个同 ID 节点。
|
||||
dup = _definition(nodes=[
|
||||
{"id": "step", "node_type": "echo", "inputs": {}},
|
||||
{"id": "step", "node_type": "echo", "inputs": {}},
|
||||
])
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows", json={
|
||||
"id": "wf-dup", "name": "重复", "description": "", "definition": dup,
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_validate_endpoint_returns_node_ids(client: TestClient) -> None:
|
||||
"""校验接口对合法定义返回 valid 与节点 ID 列表(不保存)。"""
|
||||
# 数据:已创建工作流 + 待校验定义。
|
||||
_create(client, "wf-1")
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows/wf-1/validate", json=_definition())
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"valid": True, "node_ids": ["step"]}
|
||||
|
||||
|
||||
def test_validate_endpoint_rejects_cycle(client: TestClient) -> None:
|
||||
"""校验接口对环形定义返回 422。"""
|
||||
# 数据:环形定义。
|
||||
_create(client, "wf-1")
|
||||
cyclic = _definition(nodes=[
|
||||
{"id": "a", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
|
||||
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
|
||||
], edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "a"}])
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert client.post("/api/admin/workflows/wf-1/validate", json=cyclic).status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 发布与版本
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_publish_marks_workflow_published(client: TestClient) -> None:
|
||||
"""发布后工作流标记为已发布(出现在用户应用中心)。"""
|
||||
# 数据:已创建工作流。
|
||||
_create(client, "wf-1")
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows/wf-1/publish")
|
||||
detail = client.get("/api/admin/workflows/wf-1").json()
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
assert detail["published"] == 1
|
||||
|
||||
|
||||
def test_publish_rejects_legacy_cycle_version(client: TestClient) -> None:
|
||||
"""历史遗留的环形版本无法发布(发布前重新校验,R04)。"""
|
||||
# 数据:绕过创建校验,直接写入环形版本(模拟历史数据)。
|
||||
_create(client, "wf-1")
|
||||
db = client.app.state.db
|
||||
cyclic = _definition(nodes=[
|
||||
{"id": "a", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
|
||||
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
|
||||
], edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "a"}])
|
||||
db.create_workflow_version("wf-1", 9, cyclic)
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows/wf-1/publish")
|
||||
|
||||
# 验证结果:被拒绝。
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_publish_rejects_workflow_without_version(client: TestClient) -> None:
|
||||
"""无版本的工作流不能发布。"""
|
||||
# 数据:只有工作流记录,无版本(绕过创建接口)。
|
||||
db = client.app.state.db
|
||||
db.upsert_workflow({"id": "empty", "name": "空", "description": "", "latest_version": 0})
|
||||
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows/empty/publish")
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_list_versions_returns_history_desc(client: TestClient) -> None:
|
||||
"""版本历史按版本号倒序返回,供对比与回滚。"""
|
||||
# 数据:两个版本。
|
||||
_create(client, "wf-1")
|
||||
_create(client, "wf-1")
|
||||
|
||||
# 测试过程
|
||||
versions = client.get("/api/admin/workflows/wf-1/versions").json()
|
||||
|
||||
# 验证结果
|
||||
assert [item["version"] for item in versions] == [2, 1]
|
||||
|
||||
|
||||
def test_delete_workflow_removes_it(client: TestClient) -> None:
|
||||
"""删除工作流后查询 404。"""
|
||||
# 数据:已创建工作流。
|
||||
_create(client, "wf-1")
|
||||
|
||||
# 测试过程
|
||||
response = client.delete("/api/admin/workflows/wf-1")
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
assert client.get("/api/admin/workflows/wf-1").status_code == 404
|
||||
|
||||
|
||||
def test_delete_missing_workflow_returns_404(client: TestClient) -> None:
|
||||
"""删除不存在的工作流返回 404。"""
|
||||
# 数据:未创建。
|
||||
# 测试过程与验证结果
|
||||
assert client.delete("/api/admin/workflows/nope").status_code == 404
|
||||
|
||||
|
||||
def test_created_workflow_id_slugified_from_name_when_absent(client: TestClient) -> None:
|
||||
"""未给 ID 时由名称生成 slug 形式 ID(管理端便利行为)。"""
|
||||
# 数据:只给名称。
|
||||
# 测试过程
|
||||
response = client.post("/api/admin/workflows", json={
|
||||
"name": "My Cool Flow", "description": "", "definition": _definition(),
|
||||
})
|
||||
|
||||
# 验证结果
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == "my-cool-flow"
|
||||
Reference in New Issue
Block a user