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:
2026-09-13 15:40:56 +08:00
parent 966f3e6b4b
commit 8a715a8064
139 changed files with 20810 additions and 10733 deletions
+319
View File
@@ -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