重构: 迁移后端代码到 app 目录,前端移动到 WebUI,添加完整测试套件

主要变更:
- 后端代码从根目录迁移到 app/ 目录
- 前端代码从 frontend/ 重命名为 WebUI/
- 更新所有导入路径以适配新结构
- 提取公共 API 响应函数到 app/api/common.py
- 精简验证器服务代码
- 更新启动脚本和文档

测试:
- 新增完整测试套件 (tests/)
- 单元测试: 模型、仓库层
- 集成测试: 覆盖所有 22+ API 端点
- E2E 测试: 4个完整工作流场景
- 添加 pytest 配置和测试运行脚本
This commit is contained in:
祀梦
2026-04-04 13:32:36 +08:00
parent df3cc87f88
commit 38bd66128b
109 changed files with 2017 additions and 548 deletions

View File

@@ -0,0 +1 @@
"""集成测试"""

View File

@@ -0,0 +1,47 @@
"""健康检查 API 测试"""
import pytest
class TestHealthAPI:
"""测试健康检查端点"""
@pytest.mark.asyncio
async def test_root_endpoint(self, client):
"""测试根端点 /"""
response = await client.get("/")
assert response.status_code == 200
data = response.json()
assert "message" in data
assert "status" in data
assert data["status"] == "running"
@pytest.mark.asyncio
async def test_health_endpoint(self, client):
"""测试健康检查端点 /health"""
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "timestamp" in data
assert "database" in data
assert "scheduler" in data
assert "version" in data
@pytest.mark.asyncio
async def test_health_endpoint_structure(self, client):
"""测试健康检查端点返回结构"""
response = await client.get("/health")
data = response.json()
assert "status" in data
assert "timestamp" in data
assert "database" in data
assert "scheduler" in data
assert "version" in data
# 验证数据类型
assert isinstance(data["status"], str)
assert isinstance(data["timestamp"], str)
assert isinstance(data["database"], str)
assert isinstance(data["scheduler"], str)
assert isinstance(data["version"], str)

View File

@@ -0,0 +1,147 @@
"""插件 API 集成测试 - 测试 /api/plugins/* 所有接口"""
import pytest
class TestPluginsAPI:
"""测试插件相关 API"""
@pytest.mark.asyncio
async def test_list_plugins(self, client):
"""测试 GET /api/plugins"""
response = await client.get("/api/plugins")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "plugins" in data["data"]
assert isinstance(data["data"]["plugins"], list)
@pytest.mark.asyncio
async def test_list_plugins_structure(self, client):
"""测试 GET /api/plugins 返回结构"""
response = await client.get("/api/plugins")
data = response.json()
if data["data"]["plugins"]:
plugin = data["data"]["plugins"][0]
assert "id" in plugin
assert "name" in plugin
assert "display_name" in plugin
assert "description" in plugin
assert "enabled" in plugin
@pytest.mark.asyncio
async def test_toggle_plugin_enable(self, client):
"""测试 PUT /api/plugins/{id}/toggle - 启用"""
# 先获取一个插件 ID
response = await client.get("/api/plugins")
plugins = response.json()["data"]["plugins"]
if not plugins:
pytest.skip("没有可用的插件")
plugin_id = plugins[0]["id"]
response = await client.put(f"/api/plugins/{plugin_id}/toggle", json={"enabled": True})
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert data["data"]["enabled"] is True
@pytest.mark.asyncio
async def test_toggle_plugin_disable(self, client):
"""测试 PUT /api/plugins/{id}/toggle - 禁用"""
response = await client.get("/api/plugins")
plugins = response.json()["data"]["plugins"]
if not plugins:
pytest.skip("没有可用的插件")
plugin_id = plugins[0]["id"]
response = await client.put(f"/api/plugins/{plugin_id}/toggle", json={"enabled": False})
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert data["data"]["enabled"] is False
@pytest.mark.asyncio
async def test_toggle_plugin_missing_enabled(self, client):
"""测试 PUT /api/plugins/{id}/toggle - 缺少 enabled 参数"""
response = await client.get("/api/plugins")
plugins = response.json()["data"]["plugins"]
if not plugins:
pytest.skip("没有可用的插件")
plugin_id = plugins[0]["id"]
response = await client.put(f"/api/plugins/{plugin_id}/toggle", json={})
assert response.status_code == 400
@pytest.mark.asyncio
async def test_toggle_nonexistent_plugin(self, client):
"""测试 PUT /api/plugins/{id}/toggle - 不存在的插件"""
response = await client.put("/api/plugins/nonexistent_plugin/toggle", json={"enabled": True})
assert response.status_code == 404
@pytest.mark.asyncio
async def test_get_plugin_config(self, client):
"""测试 GET /api/plugins/{id}/config"""
response = await client.get("/api/plugins")
plugins = response.json()["data"]["plugins"]
if not plugins:
pytest.skip("没有可用的插件")
plugin_id = plugins[0]["id"]
response = await client.get(f"/api/plugins/{plugin_id}/config")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "config" in data["data"]
@pytest.mark.asyncio
async def test_get_nonexistent_plugin_config(self, client):
"""测试 GET /api/plugins/{id}/config - 不存在的插件"""
response = await client.get("/api/plugins/nonexistent_plugin/config")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_plugin_config(self, client):
"""测试 POST /api/plugins/{id}/config"""
response = await client.get("/api/plugins")
plugins = response.json()["data"]["plugins"]
if not plugins:
pytest.skip("没有可用的插件")
plugin_id = plugins[0]["id"]
response = await client.post(
f"/api/plugins/{plugin_id}/config",
json={"config": {"max_pages": 3}}
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
@pytest.mark.asyncio
async def test_crawl_plugin(self, client):
"""测试 POST /api/plugins/{id}/crawl"""
response = await client.get("/api/plugins")
plugins = response.json()["data"]["plugins"]
if not plugins:
pytest.skip("没有可用的插件")
plugin_id = plugins[0]["id"]
# 这个测试可能需要较长时间,设置较短的超时
response = await client.post(f"/api/plugins/{plugin_id}/crawl")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "proxy_count" in data["data"]
@pytest.mark.asyncio
async def test_crawl_nonexistent_plugin(self, client):
"""测试 POST /api/plugins/{id}/crawl - 不存在的插件"""
response = await client.post("/api/plugins/nonexistent_plugin/crawl")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_crawl_all_plugins(self, client):
"""测试 POST /api/plugins/crawl-all"""
response = await client.post("/api/plugins/crawl-all")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "total_crawled" in data["data"]

View File

@@ -0,0 +1,144 @@
"""代理 API 集成测试 - 测试 /api/proxies/* 所有接口"""
import pytest
class TestProxiesAPI:
"""测试代理相关 API"""
@pytest.mark.asyncio
async def test_get_stats(self, client):
"""测试 GET /api/proxies/stats"""
response = await client.get("/api/proxies/stats")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "data" in data
assert "total" in data["data"]
assert "available" in data["data"]
assert "scheduler_running" in data["data"]
@pytest.mark.asyncio
async def test_list_proxies(self, client):
"""测试 POST /api/proxies"""
request_data = {
"page": 1,
"page_size": 10,
"protocol": None,
"min_score": 0,
"sort_by": "last_check",
"sort_order": "DESC"
}
response = await client.post("/api/proxies", json=request_data)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "list" in data["data"]
assert "total" in data["data"]
assert "page" in data["data"]
@pytest.mark.asyncio
async def test_list_proxies_with_protocol_filter(self, client):
"""测试 POST /api/proxies 带协议过滤"""
request_data = {
"page": 1,
"page_size": 10,
"protocol": "http",
"min_score": 0,
}
response = await client.post("/api/proxies", json=request_data)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
@pytest.mark.asyncio
async def test_list_proxies_invalid_protocol(self, client):
"""测试 POST /api/proxies 无效协议"""
request_data = {
"page": 1,
"page_size": 10,
"protocol": "invalid",
}
response = await client.post("/api/proxies", json=request_data)
assert response.status_code == 422 # 验证错误
@pytest.mark.asyncio
async def test_get_random_proxy_empty(self, client):
"""测试 GET /api/proxies/random - 空数据库"""
response = await client.get("/api/proxies/random")
# 可能返回 200(有数据) 或 404(无数据)
assert response.status_code in [200, 404]
@pytest.mark.asyncio
async def test_delete_proxy(self, client, sample_proxy):
"""测试 DELETE /api/proxies/{ip}/{port}"""
response = await client.delete(f"/api/proxies/{sample_proxy['ip']}/{sample_proxy['port']}")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
@pytest.mark.asyncio
async def test_delete_nonexistent_proxy(self, client):
"""测试 DELETE /api/proxies/{ip}/{port} - 不存在的代理"""
response = await client.delete("/api/proxies/999.999.999.999/99999")
# 即使代理不存在也返回成功(幂等删除)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_batch_delete_proxies(self, client):
"""测试 POST /api/proxies/batch-delete"""
request_data = {
"proxies": [
{"ip": "192.168.1.1", "port": 8080},
{"ip": "192.168.1.2", "port": 8081},
]
}
response = await client.post("/api/proxies/batch-delete", json=request_data)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "deleted_count" in data["data"]
@pytest.mark.asyncio
async def test_batch_delete_too_many(self, client):
"""测试 POST /api/proxies/batch-delete - 超过限制"""
request_data = {
"proxies": [{"ip": f"192.168.1.{i}", "port": 8080} for i in range(1001)]
}
response = await client.post("/api/proxies/batch-delete", json=request_data)
assert response.status_code == 422 # 验证错误
@pytest.mark.asyncio
async def test_clean_invalid_proxies(self, client):
"""测试 DELETE /api/proxies/clean-invalid"""
response = await client.delete("/api/proxies/clean-invalid")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "deleted_count" in data["data"]
@pytest.mark.asyncio
async def test_export_proxies_csv(self, client):
"""测试 GET /api/proxies/export/csv"""
response = await client.get("/api/proxies/export/csv")
assert response.status_code == 200
assert response.headers["content-type"] == "text/csv"
@pytest.mark.asyncio
async def test_export_proxies_txt(self, client):
"""测试 GET /api/proxies/export/txt"""
response = await client.get("/api/proxies/export/txt")
assert response.status_code == 200
assert response.headers["content-type"] == "text/plain"
@pytest.mark.asyncio
async def test_export_proxies_json(self, client):
"""测试 GET /api/proxies/export/json"""
response = await client.get("/api/proxies/export/json")
assert response.status_code == 200
assert response.headers["content-type"] == "application/json"
@pytest.mark.asyncio
async def test_export_proxies_invalid_format(self, client):
"""测试 GET /api/proxies/export/invalid - 无效格式"""
response = await client.get("/api/proxies/export/invalid")
assert response.status_code == 400 # 错误响应

View File

@@ -0,0 +1,101 @@
"""调度器 API 集成测试 - 测试 /api/scheduler/* 所有接口"""
import pytest
class TestSchedulerAPI:
"""测试调度器相关 API"""
@pytest.mark.asyncio
async def test_get_scheduler_status(self, client):
"""测试 GET /api/scheduler/status"""
response = await client.get("/api/scheduler/status")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "running" in data["data"]
assert "interval_minutes" in data["data"]
assert isinstance(data["data"]["running"], bool)
@pytest.mark.asyncio
async def test_start_scheduler(self, client):
"""测试 POST /api/scheduler/start"""
response = await client.post("/api/scheduler/start")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert data["data"]["running"] is True
@pytest.mark.asyncio
async def test_start_scheduler_already_running(self, client):
"""测试 POST /api/scheduler/start - 已经运行"""
# 先启动
await client.post("/api/scheduler/start")
# 再次启动
response = await client.post("/api/scheduler/start")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "已在运行" in data["message"] or data["data"]["running"] is True
@pytest.mark.asyncio
async def test_stop_scheduler(self, client):
"""测试 POST /api/scheduler/stop"""
# 先确保调度器在运行
await client.post("/api/scheduler/start")
response = await client.post("/api/scheduler/stop")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert data["data"]["running"] is False
@pytest.mark.asyncio
async def test_stop_scheduler_already_stopped(self, client):
"""测试 POST /api/scheduler/stop - 已经停止"""
# 先停止
await client.post("/api/scheduler/stop")
# 再次停止
response = await client.post("/api/scheduler/stop")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "未运行" in data["message"] or data["data"]["running"] is False
@pytest.mark.asyncio
async def test_validate_now(self, client):
"""测试 POST /api/scheduler/validate-now"""
# 先启动调度器
await client.post("/api/scheduler/start")
response = await client.post("/api/scheduler/validate-now")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert data["data"]["started"] is True
@pytest.mark.asyncio
async def test_scheduler_full_workflow(self, client):
"""测试调度器完整工作流"""
# 1. 获取初始状态
response = await client.get("/api/scheduler/status")
initial_status = response.json()["data"]
# 2. 启动调度器
response = await client.post("/api/scheduler/start")
assert response.json()["data"]["running"] is True
# 3. 验证状态
response = await client.get("/api/scheduler/status")
assert response.json()["data"]["running"] is True
# 4. 触发立即验证
response = await client.post("/api/scheduler/validate-now")
assert response.json()["data"]["started"] is True
# 5. 停止调度器
response = await client.post("/api/scheduler/stop")
assert response.json()["data"]["running"] is False
# 6. 验证最终状态
response = await client.get("/api/scheduler/status")
assert response.json()["data"]["running"] is False

View File

@@ -0,0 +1,137 @@
"""设置 API 集成测试 - 测试 /api/settings 接口"""
import pytest
class TestSettingsAPI:
"""测试设置相关 API"""
@pytest.mark.asyncio
async def test_get_settings(self, client):
"""测试 GET /api/settings"""
response = await client.get("/api/settings")
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
assert "crawl_timeout" in data["data"]
assert "validation_timeout" in data["data"]
assert "auto_validate" in data["data"]
@pytest.mark.asyncio
async def test_get_settings_structure(self, client):
"""测试 GET /api/settings 返回结构"""
response = await client.get("/api/settings")
data = response.json()["data"]
# 验证所有预期的设置项
expected_keys = [
"crawl_timeout",
"validation_timeout",
"max_retries",
"default_concurrency",
"min_proxy_score",
"proxy_expiry_days",
"auto_validate",
"validate_interval_minutes",
]
for key in expected_keys:
assert key in data, f"缺少设置项: {key}"
@pytest.mark.asyncio
async def test_save_settings(self, client):
"""测试 POST /api/settings"""
settings = {
"crawl_timeout": 45,
"validation_timeout": 15,
"max_retries": 5,
"default_concurrency": 100,
"min_proxy_score": 10,
"proxy_expiry_days": 14,
"auto_validate": True,
"validate_interval_minutes": 60,
}
response = await client.post("/api/settings", json=settings)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
# 验证返回的数据与提交的一致
for key, value in settings.items():
assert data["data"][key] == value
@pytest.mark.asyncio
async def test_save_settings_partial(self, client):
"""测试 POST /api/settings - 部分更新(实际上会替换所有)"""
# 先获取当前设置
response = await client.get("/api/settings")
current_settings = response.json()["data"]
# 修改部分设置
new_settings = current_settings.copy()
new_settings["crawl_timeout"] = 60
new_settings["auto_validate"] = False
response = await client.post("/api/settings", json=new_settings)
assert response.status_code == 200
data = response.json()
assert data["data"]["crawl_timeout"] == 60
assert data["data"]["auto_validate"] is False
@pytest.mark.asyncio
async def test_save_settings_validation_error(self, client):
"""测试 POST /api/settings - 验证错误"""
# crawl_timeout 必须在 5-120 之间
invalid_settings = {
"crawl_timeout": 200, # 超出范围
"validation_timeout": 10,
"max_retries": 3,
"default_concurrency": 50,
"min_proxy_score": 0,
"proxy_expiry_days": 7,
"auto_validate": True,
"validate_interval_minutes": 30,
}
response = await client.post("/api/settings", json=invalid_settings)
assert response.status_code == 422 # 验证错误
@pytest.mark.asyncio
async def test_save_settings_invalid_type(self, client):
"""测试 POST /api/settings - 无效类型"""
invalid_settings = {
"crawl_timeout": "invalid", # 应该是整数
"validation_timeout": 10,
"max_retries": 3,
"default_concurrency": 50,
"min_proxy_score": 0,
"proxy_expiry_days": 7,
"auto_validate": True,
"validate_interval_minutes": 30,
}
response = await client.post("/api/settings", json=invalid_settings)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_settings_roundtrip(self, client):
"""测试设置读写一致性"""
# 生成随机但有效的设置
import random
test_settings = {
"crawl_timeout": random.randint(10, 60),
"validation_timeout": random.randint(5, 30),
"max_retries": random.randint(1, 5),
"default_concurrency": random.randint(20, 100),
"min_proxy_score": random.randint(0, 50),
"proxy_expiry_days": random.randint(1, 14),
"auto_validate": random.choice([True, False]),
"validate_interval_minutes": random.randint(10, 120),
}
# 写入设置
response = await client.post("/api/settings", json=test_settings)
assert response.status_code == 200
# 读取设置
response = await client.get("/api/settings")
saved_settings = response.json()["data"]
# 验证一致性
for key, value in test_settings.items():
assert saved_settings[key] == value, f"设置项 {key} 不一致"