按"测试规则"重写 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。
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""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"
|