架构重构(候选 1-6): - storage/repository.py 按域拆分为包(target/scenario/run/campaign/result) - storage/db.py 按域拆分为包(eval/campaign/file/model_config/intelligent_eval) - intelligent_eval/lifecycle.py 按状态机阶段拆分为包 - services/runs.py 编排逻辑下沉 - Campaigns.tsx 拆分为 campaigns/ 子组件 测试补全(候选 7): 前端(+125 用例,107→232): - utils/ 纯函数:date/campaignTime/ruleLabels/fileTree/fileFormat/colors - stores/tabStore 状态管理 - 核心组件:FormDrawer/PageWrapper/ChatBubble/GeneratedMessages/SectionHeader/StatCard/TurnList - 业务组件:CaseBlock/CaseDetail/RuleOverview/WindowTimeline/RunList/TabBar/CampaignRunTimeline - 文件管理:FileCategoryTree/FileTable - hooks:sessionReducer/useFiles/useRunSession 后端(+38 用例,916→954): - targets API CRUD + 404 路径 - WebSocket 连接管理器 - proxy 头部重写(CSP/X-Frame-Options) - target 仓储 update 方法 - app 健康检查 + SPA 404 - scenarios 模板端点 + 404 - files API 边缘分支(404 场景 + 500 兜底) - files service update_category - 智能评估状态机迁移测试 门禁状态: - 前端:tsc 干净 + 232 passed - 后端:954 passed + ruff 全绿
309 lines
9.9 KiB
Python
309 lines
9.9 KiB
Python
"""Integration tests for the file management API."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from agenteval.config import get_settings
|
|
from agenteval.web.app import app
|
|
from agenteval.web.deps import get_db
|
|
from fastapi.testclient import TestClient
|
|
from sqlmodel import Session, SQLModel, create_engine
|
|
|
|
|
|
@pytest.fixture()
|
|
def files_client(tmp_path: Path, monkeypatch):
|
|
from agenteval.storage.db import ( # noqa: F401
|
|
EvalResultDB,
|
|
EvalRunDB,
|
|
EvalTargetDB,
|
|
FileCategoryDB,
|
|
FileRecordDB,
|
|
ScenarioDB,
|
|
TurnDB,
|
|
)
|
|
from agenteval.web.routers import files as files_module
|
|
|
|
engine = create_engine(
|
|
f"sqlite:///{tmp_path / 'files_api.db'}",
|
|
connect_args={"check_same_thread": False},
|
|
)
|
|
SQLModel.metadata.create_all(engine)
|
|
session = Session(engine)
|
|
files_dir = tmp_path / "files"
|
|
files_dir.mkdir()
|
|
|
|
monkeypatch.setattr(files_module, "FILES_DIR", files_dir)
|
|
settings = get_settings()
|
|
monkeypatch.setattr(settings, "allowed_extensions", "txt,json")
|
|
monkeypatch.setattr(settings, "max_upload_size_mb", 1)
|
|
|
|
def override_get_db():
|
|
yield session
|
|
|
|
app.dependency_overrides[get_db] = override_get_db
|
|
with TestClient(app) as client:
|
|
yield client, session, files_dir
|
|
|
|
app.dependency_overrides.clear()
|
|
session.close()
|
|
engine.dispose()
|
|
|
|
|
|
def _create_category(client: TestClient, name: str, parent_id: str | None = None) -> dict:
|
|
response = client.post(
|
|
"/api/files/categories",
|
|
json={"name": name, "parent_id": parent_id},
|
|
)
|
|
assert response.status_code == 200
|
|
return response.json()
|
|
|
|
|
|
def test_upload_config_uses_server_settings(files_client):
|
|
client, _, _ = files_client
|
|
|
|
response = client.get("/api/files/config")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"allowed_extensions": ["json", "txt"],
|
|
"max_upload_size_mb": 1,
|
|
}
|
|
|
|
|
|
def test_category_crud_returns_domain_fields_and_deep_tree(files_client):
|
|
client, _, _ = files_client
|
|
root = _create_category(client, "根分类")
|
|
child = _create_category(client, "二级", root["id"])
|
|
leaf = _create_category(client, "三级", child["id"])
|
|
|
|
tree = client.get("/api/files/categories").json()
|
|
|
|
assert tree[0]["id"] == root["id"]
|
|
assert tree[0]["name"] == "根分类"
|
|
assert tree[0]["children"][0]["id"] == child["id"]
|
|
assert tree[0]["children"][0]["children"][0]["id"] == leaf["id"]
|
|
|
|
updated = client.put(
|
|
f"/api/files/categories/{leaf['id']}",
|
|
json={"name": "三级分类"},
|
|
)
|
|
assert updated.status_code == 200
|
|
assert updated.json()["name"] == "三级分类"
|
|
|
|
|
|
def test_create_category_rejects_missing_parent(files_client):
|
|
client, _, _ = files_client
|
|
|
|
response = client.post(
|
|
"/api/files/categories",
|
|
json={"name": "孤立分类", "parent_id": "missing"},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == "父分类不存在"
|
|
|
|
|
|
def test_list_files_includes_all_descendant_categories(files_client):
|
|
client, _, _ = files_client
|
|
root = _create_category(client, "根分类")
|
|
child = _create_category(client, "二级", root["id"])
|
|
leaf = _create_category(client, "三级", child["id"])
|
|
|
|
upload = client.post(
|
|
"/api/files/upload",
|
|
data={"category_id": leaf["id"]},
|
|
files={"file": ("deep.txt", b"deep content", "text/plain")},
|
|
)
|
|
assert upload.status_code == 200
|
|
|
|
response = client.get("/api/files", params={"category_id": root["id"]})
|
|
|
|
assert response.status_code == 200
|
|
assert [item["original_name"] for item in response.json()] == ["deep.txt"]
|
|
|
|
|
|
def test_upload_download_and_delete_file(files_client):
|
|
client, _, files_dir = files_client
|
|
|
|
uploaded = client.post(
|
|
"/api/files/upload",
|
|
files={"file": ("notes.txt", b"hello", "text/plain")},
|
|
)
|
|
|
|
assert uploaded.status_code == 200
|
|
record = uploaded.json()
|
|
assert record["original_name"] == "notes.txt"
|
|
assert record["file_size"] == 5
|
|
assert record["storage_directory"] == "data/files"
|
|
assert client.get("/api/files").json()[0]["storage_directory"] == "data/files"
|
|
assert len(list(files_dir.iterdir())) == 1
|
|
|
|
downloaded = client.get(f"/api/files/{record['id']}/download")
|
|
assert downloaded.status_code == 200
|
|
assert downloaded.content == b"hello"
|
|
|
|
deleted = client.delete(f"/api/files/{record['id']}")
|
|
assert deleted.status_code == 200
|
|
assert list(files_dir.iterdir()) == []
|
|
assert client.get("/api/files").json() == []
|
|
|
|
|
|
def test_upload_rejects_invalid_category_and_extension(files_client):
|
|
client, _, files_dir = files_client
|
|
|
|
invalid_category = client.post(
|
|
"/api/files/upload",
|
|
data={"category_id": "missing"},
|
|
files={"file": ("notes.txt", b"hello", "text/plain")},
|
|
)
|
|
invalid_extension = client.post(
|
|
"/api/files/upload",
|
|
files={"file": ("notes.exe", b"hello", "application/octet-stream")},
|
|
)
|
|
|
|
assert invalid_category.status_code == 400
|
|
assert invalid_category.json()["detail"] == "分类不存在"
|
|
assert invalid_extension.status_code == 400
|
|
assert list(files_dir.iterdir()) == []
|
|
|
|
|
|
def test_upload_rejects_oversized_file_without_disk_artifact(files_client):
|
|
client, _, files_dir = files_client
|
|
|
|
response = client.post(
|
|
"/api/files/upload",
|
|
files={"file": ("large.txt", b"x" * (1024 * 1024 + 1), "text/plain")},
|
|
)
|
|
|
|
assert response.status_code == 413
|
|
assert list(files_dir.iterdir()) == []
|
|
assert client.get("/api/files").json() == []
|
|
|
|
|
|
def test_delete_category_removes_descendant_files(files_client):
|
|
client, _, files_dir = files_client
|
|
root = _create_category(client, "根分类")
|
|
child = _create_category(client, "子分类", root["id"])
|
|
uploaded = client.post(
|
|
"/api/files/upload",
|
|
data={"category_id": child["id"]},
|
|
files={"file": ("child.txt", b"content", "text/plain")},
|
|
)
|
|
assert uploaded.status_code == 200
|
|
assert len(list(files_dir.iterdir())) == 1
|
|
|
|
response = client.delete(f"/api/files/categories/{root['id']}")
|
|
|
|
assert response.status_code == 200
|
|
assert client.get("/api/files/categories").json() == []
|
|
assert client.get("/api/files").json() == []
|
|
assert list(files_dir.iterdir()) == []
|
|
|
|
|
|
# ── Edge-case error branches ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_update_nonexistent_category_returns_404(files_client):
|
|
"""update_category catches CategoryNotFoundError → 404."""
|
|
client, _, _ = files_client
|
|
|
|
response = client.put(
|
|
"/api/files/categories/nonexistent",
|
|
json={"name": "新名称"},
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "分类不存在"
|
|
|
|
|
|
def test_delete_nonexistent_category_returns_404(files_client):
|
|
"""delete_category catches CategoryNotFoundError → 404."""
|
|
client, _, _ = files_client
|
|
|
|
response = client.delete("/api/files/categories/nonexistent")
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "分类不存在"
|
|
|
|
|
|
def test_list_files_with_invalid_category_returns_404(files_client):
|
|
"""list_files catches CategoryNotFoundError → 404."""
|
|
client, _, _ = files_client
|
|
|
|
response = client.get("/api/files", params={"category_id": "nonexistent"})
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "分类不存在"
|
|
|
|
|
|
def test_download_nonexistent_file_returns_404(files_client):
|
|
"""download_file catches FileRecordNotFoundError → 404."""
|
|
client, _, _ = files_client
|
|
|
|
response = client.get("/api/files/nonexistent/download")
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "文件不存在"
|
|
|
|
|
|
def test_delete_nonexistent_file_returns_404(files_client):
|
|
"""delete_file catches FileRecordNotFoundError → 404."""
|
|
client, _, _ = files_client
|
|
|
|
response = client.delete("/api/files/nonexistent")
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "文件不存在"
|
|
|
|
|
|
def test_download_file_with_missing_physical_file_returns_404(files_client, monkeypatch):
|
|
"""download_file catches PhysicalFileNotFoundError → 404 when DB record exists but physical file is missing."""
|
|
client, session, files_dir = files_client
|
|
from agenteval.storage.db import FileRecordDB
|
|
|
|
# Upload a file first
|
|
uploaded = client.post(
|
|
"/api/files/upload",
|
|
files={"file": ("notes.txt", b"hello", "text/plain")},
|
|
)
|
|
assert uploaded.status_code == 200
|
|
record = uploaded.json()
|
|
file_id = record["id"]
|
|
|
|
# Get storage_name from DB (not exposed in API response)
|
|
db_record = session.get(FileRecordDB, file_id)
|
|
assert db_record is not None
|
|
physical_path = files_dir / db_record.storage_name
|
|
|
|
# Delete the physical file but keep the DB record
|
|
physical_path.unlink()
|
|
|
|
# Now try to download — should get 404 "物理文件不存在"
|
|
response = client.get(f"/api/files/{file_id}/download")
|
|
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "物理文件不存在"
|
|
|
|
|
|
def test_unknown_file_management_error_returns_500(files_client, monkeypatch):
|
|
"""_raise_http_error fallback: unknown FileManagementError subclass → 500."""
|
|
client, _, _ = files_client
|
|
from agenteval.services.files import FileManagementError, FileManagementService
|
|
|
|
class UnknownError(FileManagementError):
|
|
pass
|
|
|
|
def raise_unknown(self, category_id, name):
|
|
raise UnknownError("未知错误")
|
|
|
|
# Use update_category which has try/except FileManagementError
|
|
monkeypatch.setattr(FileManagementService, "update_category", raise_unknown)
|
|
|
|
response = client.put(
|
|
"/api/files/categories/some-id",
|
|
json={"name": "新名称"},
|
|
)
|
|
|
|
assert response.status_code == 500
|
|
assert response.json()["detail"] == "文件操作失败"
|