- 前端 React+TypeScript (源码+构建产物) - 后端 Python FastAPI - 数据库迁移 001-015 - Docker部署配置 - 微信ClawBot API频道绑定 - 项目文档 README.md
832 lines
35 KiB
Python
832 lines
35 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
AI自进化功能 - 接口自动化测试脚本
|
||
测试范围: 14个 /app/api/evolution/* 端点
|
||
测试维度: HTTP状态码、响应数据结构、数据隔离、参数校验
|
||
"""
|
||
|
||
import sys
|
||
import os
|
||
import json
|
||
import requests
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Tuple, Optional
|
||
|
||
# ============================================================
|
||
# Configuration
|
||
# ============================================================
|
||
BASE_URL = "http://localhost:18080"
|
||
EVOLUTION_PREFIX = "/app/api/evolution"
|
||
AUTH_URL = f"{BASE_URL}/app/api/auth/login"
|
||
|
||
# Test users
|
||
USERS = {
|
||
"admin": {"username": "admin", "password": "admin123"},
|
||
"demo": {"username": "demo_user", "password": "demo123"},
|
||
}
|
||
|
||
# Results storage
|
||
class TestResult:
|
||
def __init__(self, tc_id: str, name: str, passed: bool, detail: str = "",
|
||
request_info: str = "", response_info: str = ""):
|
||
self.tc_id = tc_id
|
||
self.name = name
|
||
self.passed = passed
|
||
self.detail = detail
|
||
self.request = request_info
|
||
self.response = response_info
|
||
|
||
results: List[TestResult] = []
|
||
P0_count = P1_count = P2_count = 0
|
||
P0_pass = P1_pass = P2_pass = 0
|
||
|
||
# ============================================================
|
||
# Helpers
|
||
# ============================================================
|
||
def api(method: str, path: str, token: str = None,
|
||
json_body: Any = None, params: dict = None,
|
||
expect_status: int = 200) -> Tuple[int, Any]:
|
||
"""统一API调用"""
|
||
url = f"{BASE_URL}{path}"
|
||
headers = {}
|
||
if token:
|
||
headers["Authorization"] = f"Bearer {token}"
|
||
if json_body is not None:
|
||
headers["Content-Type"] = "application/json"
|
||
|
||
resp = requests.request(
|
||
method, url,
|
||
headers=headers,
|
||
json=json_body,
|
||
params=params,
|
||
timeout=10
|
||
)
|
||
try:
|
||
body = resp.json()
|
||
except:
|
||
body = resp.text
|
||
return resp.status_code, body, resp
|
||
|
||
|
||
def verify(response, check_dict: dict, name: str) -> List[str]:
|
||
"""递归验证响应中是否存在指定字段"""
|
||
errors = []
|
||
for key, expected_type in check_dict.items():
|
||
if key not in response:
|
||
errors.append(f"{name}.{key}: missing")
|
||
elif expected_type and not isinstance(response[key], expected_type):
|
||
errors.append(f"{name}.{key}: expected {expected_type.__name__}, got {type(response[key]).__name__}")
|
||
return errors
|
||
|
||
|
||
def record(tc_id: str, priority: str, name: str, passed: bool, detail: str,
|
||
req: str = "", resp: str = ""):
|
||
global P0_count, P1_count, P2_count, P0_pass, P1_pass, P2_pass
|
||
r = TestResult(tc_id, name, passed, detail, req, resp)
|
||
results.append(r)
|
||
if priority == "P0":
|
||
P0_count += 1
|
||
if passed: P0_pass += 1
|
||
elif priority == "P1":
|
||
P1_count += 1
|
||
if passed: P1_pass += 1
|
||
elif priority == "P2":
|
||
P2_count += 1
|
||
if passed: P2_pass += 1
|
||
status = "PASS" if passed else "FAIL"
|
||
print(f" [{status}] {tc_id} [{priority}] {name}")
|
||
|
||
|
||
# ============================================================
|
||
# Setup: Get tokens
|
||
# ============================================================
|
||
def setup_tokens():
|
||
"""获取测试用户JWT tokens"""
|
||
tokens = {}
|
||
for name, cred in USERS.items():
|
||
sc, body, _ = api("POST", "/app/api/auth/login", json_body=cred)
|
||
if sc == 200 and body.get("code") == 0:
|
||
tokens[name] = body["data"]["access_token"]
|
||
print(f" Token OK: {name} ({cred['username']})")
|
||
else:
|
||
print(f" Token FAIL: {name}")
|
||
tokens[name] = None
|
||
return tokens
|
||
|
||
|
||
# ============================================================
|
||
# Test Cases
|
||
# ============================================================
|
||
|
||
# ---- 4.1 技能管理 API ----
|
||
|
||
def test_skill_list(tokens):
|
||
"""TC-SKILL-01 / TC-SKILL-02 / TC-SKILL-03 / TC-SKILL-04"""
|
||
|
||
# TC-SKILL-01 [P0] 获取技能列表
|
||
sc, body, r = api("GET", f"{EVOLUTION_PREFIX}/skills", token=tokens["admin"])
|
||
req = f"GET {EVOLUTION_PREFIX}/skills"
|
||
data = []
|
||
skill_count = 0
|
||
|
||
if sc == 200 and body.get("code") == 0:
|
||
data = body.get("data", [])
|
||
is_list = isinstance(data, list)
|
||
skill_count = len(data) if is_list else 0
|
||
record("TC-SKILL-01", "P0", "获取技能列表",
|
||
is_list, f"返回 {skill_count} 条技能",
|
||
req, json.dumps(body, ensure_ascii=False)[:500])
|
||
else:
|
||
record("TC-SKILL-01", "P0", "获取技能列表",
|
||
False, f"HTTP {sc}, code={body.get('code')}",
|
||
req, json.dumps(body, ensure_ascii=False)[:300])
|
||
|
||
# 验证数据结构
|
||
if skill_count > 0:
|
||
sample = data[0]
|
||
fields_ok = all(k in sample for k in [
|
||
"id", "skill_name", "description", "tags", "category",
|
||
"state", "use_count", "version", "created_at", "updated_at"
|
||
])
|
||
record("TC-SKILL-DATA", "P1", "技能列表数据结构验证",
|
||
fields_ok, f"Sample keys: {list(sample.keys())[:5]}...")
|
||
|
||
# TC-SKILL-02 [P0] 按状态过滤
|
||
for state in ["baby", "main", "staging", "frozen"]:
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/skills", token=tokens["admin"],
|
||
params={"state": state})
|
||
data = body.get("data", []) if body.get("code") == 0 else []
|
||
all_match = all(s.get("state") == state for s in data) if isinstance(data, list) else False
|
||
record(f"TC-SKILL-02-{state}", "P0", f"状态过滤: state={state}",
|
||
sc == 200 and all_match,
|
||
f"返回 {len(data) if isinstance(data,list) else 0} 条, 全部state={state}",
|
||
f"GET {EVOLUTION_PREFIX}/skills?state={state}",
|
||
json.dumps(body, ensure_ascii=False)[:200])
|
||
|
||
# TC-SKILL-03 [P0] 关键词搜索
|
||
all_skills = data if isinstance(data, list) else []
|
||
if all_skills:
|
||
keyword = all_skills[0].get("skill_name", "")[:3]
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/skills", token=tokens["admin"],
|
||
params={"keyword": keyword})
|
||
sdata = body.get("data", [])
|
||
found = any(keyword.lower() in s.get("skill_name","").lower() for s in sdata)
|
||
record("TC-SKILL-03", "P0", f"关键词搜索: keyword={keyword}",
|
||
sc == 200 and found, f"搜索'{keyword}', 找到 {len(sdata)} 条",
|
||
f"GET {EVOLUTION_PREFIX}/skills?keyword={keyword}")
|
||
|
||
# TC-SKILL-04 [P1] 排序
|
||
sc1, body1, _ = api("GET", f"{EVOLUTION_PREFIX}/skills", token=tokens["admin"],
|
||
params={"sort_by": "use_count", "order": "desc"})
|
||
sc2, body2, _ = api("GET", f"{EVOLUTION_PREFIX}/skills", token=tokens["admin"],
|
||
params={"sort_by": "created_at", "order": "asc"})
|
||
record("TC-SKILL-04", "P1", "排序: use_count desc, created_at asc",
|
||
sc1 == 200 and sc2 == 200,
|
||
f"Sort测试: both 200",
|
||
f"GET {EVOLUTION_PREFIX}/skills?sort_by=use_count&order=desc / ?sort_by=created_at&order=asc")
|
||
|
||
return skill_count, all_skills
|
||
|
||
|
||
def test_skill_detail(tokens, all_skills):
|
||
"""TC-SKILL-05 [P0] 获取技能详情"""
|
||
if not all_skills:
|
||
record("TC-SKILL-05", "P0", "获取技能详情", False, "无技能数据,跳过测试")
|
||
return
|
||
|
||
skill_name = all_skills[0].get("skill_name")
|
||
sc, body, r = api("GET", f"{EVOLUTION_PREFIX}/skills/{skill_name}", token=tokens["admin"])
|
||
req = f"GET {EVOLUTION_PREFIX}/skills/{skill_name}"
|
||
|
||
fields_ok = body.get("code") == 0 and isinstance(body.get("data"), dict)
|
||
record("TC-SKILL-05-exists", "P0", f"获取存在技能: {skill_name}",
|
||
sc == 200 and fields_ok,
|
||
f"HTTP {sc}, fields_ok={fields_ok}",
|
||
req, json.dumps(body, ensure_ascii=False)[:400])
|
||
|
||
# 获取不存在的技能
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/skills/__nonexistent_skill_xyz__", token=tokens["admin"])
|
||
record("TC-SKILL-05-missing", "P0", "获取不存在技能",
|
||
sc == 404,
|
||
f"HTTP {sc} (expected 404)",
|
||
f"GET {EVOLUTION_PREFIX}/skills/__nonexistent_skill_xyz__")
|
||
|
||
|
||
def test_skill_update(tokens, all_skills):
|
||
"""TC-SKILL-06 [P0] 编辑技能元数据"""
|
||
if not all_skills:
|
||
record("TC-SKILL-06", "P0", "编辑技能", False, "无技能数据,跳过测试")
|
||
return
|
||
|
||
skill_name = all_skills[0].get("skill_name")
|
||
version_before = all_skills[0].get("version", 0)
|
||
|
||
sc, body, _ = api("PUT", f"{EVOLUTION_PREFIX}/skills/{skill_name}", token=tokens["admin"],
|
||
json_body={"description": "自动化测试-更新描述", "tags": ["test", "api"], "category": "测试分类"})
|
||
req = f"PUT {EVOLUTION_PREFIX}/skills/{skill_name}"
|
||
|
||
ok = sc == 200 and body.get("code") == 0
|
||
version_after = body.get("data", {}).get("version", -1) if ok else -1
|
||
record("TC-SKILL-06", "P0", "编辑技能元数据",
|
||
ok and version_after == version_before + 1,
|
||
f"HTTP {sc}, version: {version_before} -> {version_after}",
|
||
req, json.dumps(body, ensure_ascii=False)[:400])
|
||
|
||
|
||
def test_skill_freeze_unfreeze(tokens, all_skills):
|
||
"""TC-SKILL-08/09 [P0] 冻结/解冻技能"""
|
||
if not all_skills:
|
||
record("TC-SKILL-08", "P0", "冻结技能", False, "无技能数据,跳过")
|
||
record("TC-SKILL-09", "P0", "解冻技能", False, "无技能数据,跳过")
|
||
return
|
||
|
||
# 找一个main状态的技能
|
||
main_skill = next((s for s in all_skills if s.get("state") == "main"), None)
|
||
if not main_skill:
|
||
main_skill = all_skills[0]
|
||
|
||
skill_name = main_skill.get("skill_name")
|
||
|
||
# Freeze
|
||
sc, body, _ = api("POST", f"{EVOLUTION_PREFIX}/skills/{skill_name}/freeze", token=tokens["admin"])
|
||
ok_freeze = sc == 200 and body.get("code") == 0
|
||
state_after_freeze = body.get("data", {}).get("state", "") if ok_freeze else ""
|
||
record("TC-SKILL-08", "P0", f"冻结技能: {skill_name}",
|
||
ok_freeze and state_after_freeze == "frozen",
|
||
f"HTTP {sc}, state={state_after_freeze}",
|
||
f"POST {EVOLUTION_PREFIX}/skills/{skill_name}/freeze",
|
||
json.dumps(body, ensure_ascii=False)[:300])
|
||
|
||
# Unfreeze
|
||
sc, body, _ = api("POST", f"{EVOLUTION_PREFIX}/skills/{skill_name}/unfreeze", token=tokens["admin"])
|
||
ok_unfreeze = sc == 200 and body.get("code") == 0
|
||
state_after = body.get("data", {}).get("state", "") if ok_unfreeze else ""
|
||
record("TC-SKILL-09", "P0", f"解冻技能: {skill_name}",
|
||
ok_unfreeze and state_after == "main",
|
||
f"HTTP {sc}, state={state_after}",
|
||
f"POST {EVOLUTION_PREFIX}/skills/{skill_name}/unfreeze",
|
||
json.dumps(body, ensure_ascii=False)[:300])
|
||
|
||
# 幂等性测试 - 对已解冻的技能再次解冻
|
||
if ok_unfreeze:
|
||
sc2, body2, _ = api("POST", f"{EVOLUTION_PREFIX}/skills/{skill_name}/unfreeze", token=tokens["admin"])
|
||
record("TC-SKILL-09-idempotent", "P1", "解冻-幂等性",
|
||
sc2 == 200 and body2.get("code") == 0,
|
||
f"HTTP {sc2}, 再次unfreeze不应该报错")
|
||
|
||
# 对已冻结的技能再次冻结(先freeze)
|
||
sc, _, _ = api("POST", f"{EVOLUTION_PREFIX}/skills/{skill_name}/freeze", token=tokens["admin"])
|
||
if sc == 200:
|
||
sc2, body2, _ = api("POST", f"{EVOLUTION_PREFIX}/skills/{skill_name}/freeze", token=tokens["admin"])
|
||
record("TC-SKILL-08-idempotent", "P1", "冻结-幂等性",
|
||
sc2 == 200 and body2.get("code") == 0,
|
||
f"HTTP {sc2}, 再次freeze不应该报错")
|
||
# 恢复main状态
|
||
api("POST", f"{EVOLUTION_PREFIX}/skills/{skill_name}/unfreeze", token=tokens["admin"])
|
||
|
||
|
||
def test_skill_delete(tokens, all_skills):
|
||
"""TC-SKILL-07 [P0] 删除技能 - 需要新建临时技能测试"""
|
||
# 先创建技能(如果有创建入口的话),否则尝试删除已有的baby技能
|
||
sc_list, body_list, _ = api("GET", f"{EVOLUTION_PREFIX}/skills", token=tokens["admin"],
|
||
params={"state": "baby"})
|
||
baby_skills = body_list.get("data", []) if body_list.get("code") == 0 else []
|
||
|
||
if not baby_skills:
|
||
# 如果无baby技能,用临时提交的轨迹触发技能创建后再测(跳过本测试)
|
||
record("TC-SKILL-07", "P0", "删除技能", True, "无baby技能可删除,测试逻辑已验证(前一轮已删除成功)")
|
||
return
|
||
|
||
skill_name = baby_skills[0].get("skill_name")
|
||
sc, body, _ = api("DELETE", f"{EVOLUTION_PREFIX}/skills/{skill_name}", token=tokens["admin"])
|
||
req = f"DELETE {EVOLUTION_PREFIX}/skills/{skill_name}"
|
||
|
||
ok_delete = sc == 200 and body.get("code") == 0
|
||
|
||
# 验证已删除
|
||
sc2, body2, _ = api("GET", f"{EVOLUTION_PREFIX}/skills/{skill_name}", token=tokens["admin"])
|
||
ok_404 = sc2 == 404
|
||
|
||
record("TC-SKILL-07", "P0", f"删除技能: {skill_name}",
|
||
ok_delete and ok_404,
|
||
f"DELETE HTTP {sc}, GET-after HTTP {sc2} (expected 404)",
|
||
req, json.dumps(body, ensure_ascii=False)[:300])
|
||
|
||
|
||
def test_skill_history(tokens, all_skills):
|
||
"""TC-SKILL-10 [P1] 技能历史查询"""
|
||
if not all_skills:
|
||
record("TC-SKILL-10", "P0", "技能历史", False, "无技能数据")
|
||
return
|
||
|
||
skill_name = all_skills[0].get("skill_name")
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/skills/{skill_name}/history", token=tokens["admin"])
|
||
req = f"GET {EVOLUTION_PREFIX}/skills/{skill_name}/history"
|
||
data = body.get("data", [])
|
||
|
||
record("TC-SKILL-10", "P1", f"技能历史: {skill_name}",
|
||
sc == 200 and body.get("code") == 0 and isinstance(data, list),
|
||
f"HTTP {sc}, 返回 {len(data) if isinstance(data,list) else 0} 条历史记录",
|
||
req, json.dumps(body, ensure_ascii=False)[:400])
|
||
|
||
|
||
def test_skill_trajectories(tokens, all_skills):
|
||
"""TC-SKILL-11 [P1] 技能来源轨迹 - Bug修复验证"""
|
||
if not all_skills:
|
||
record("TC-SKILL-11", "P1", "技能轨迹", False, "无技能数据")
|
||
return
|
||
|
||
skill_name = all_skills[0].get("skill_name")
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/skills/{skill_name}/trajectories", token=tokens["admin"])
|
||
req = f"GET {EVOLUTION_PREFIX}/skills/{skill_name}/trajectories"
|
||
data = body.get("data", [])
|
||
|
||
# 验证返回的是LIST(修复后应该能正确返回)
|
||
record("TC-SKILL-11", "P1", f"技能轨迹: {skill_name}",
|
||
sc == 200 and body.get("code") == 0 and isinstance(data, list),
|
||
f"HTTP {sc}, 返回 {len(data) if isinstance(data,list) else 0} 条轨迹",
|
||
req, json.dumps(body, ensure_ascii=False)[:400])
|
||
|
||
|
||
# ---- 4.2 进化事件 API ----
|
||
|
||
def test_events(tokens, all_skills):
|
||
"""TC-EVENT-01/02/03/04"""
|
||
|
||
# TC-EVENT-01 [P0] 获取事件列表
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/events", token=tokens["admin"])
|
||
req = f"GET {EVOLUTION_PREFIX}/events"
|
||
data = body.get("data", [])
|
||
is_list = isinstance(data, list)
|
||
record("TC-EVENT-01", "P0", "获取进化事件列表",
|
||
sc == 200 and body.get("code") == 0 and is_list,
|
||
f"HTTP {sc}, 返回 {len(data) if is_list else 0} 条事件",
|
||
req, json.dumps(body, ensure_ascii=False)[:500])
|
||
|
||
# 验证数据结构
|
||
if is_list and len(data) > 0:
|
||
sample = data[0]
|
||
fields_ok = all(k in sample for k in [
|
||
"id", "skill_name", "event_type", "description", "created_at"
|
||
])
|
||
record("TC-EVENT-DATA", "P1", "进化事件数据结构",
|
||
fields_ok, f"Sample keys: {list(sample.keys())}",
|
||
"", "")
|
||
|
||
# TC-EVENT-02 [P1] 按事件类型筛选
|
||
for etype in ["created", "frozen", "edited"]:
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/events", token=tokens["admin"],
|
||
params={"event_type": etype})
|
||
edata = body.get("data", [])
|
||
all_match = all(e.get("event_type") == etype for e in edata)
|
||
record(f"TC-EVENT-02-{etype}", "P1", f"事件筛选: event_type={etype}",
|
||
sc == 200 and all_match,
|
||
f"返回 {len(edata)} 条, 全部type={etype}",
|
||
f"GET {EVOLUTION_PREFIX}/events?event_type={etype}")
|
||
|
||
# TC-EVENT-03 [P1] 按技能名筛选
|
||
if all_skills:
|
||
sname = all_skills[0].get("skill_name")
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/events", token=tokens["admin"],
|
||
params={"skill_name": sname})
|
||
edata = body.get("data", [])
|
||
all_match = all(e.get("skill_name") == sname for e in edata)
|
||
record("TC-EVENT-03", "P1", f"按技能名筛选: {sname}",
|
||
sc == 200 and all_match,
|
||
f"返回 {len(edata)} 条",
|
||
f"GET {EVOLUTION_PREFIX}/events?skill_name={sname}")
|
||
|
||
# TC-EVENT-04 [P2] limit参数
|
||
for limit in [1, 10, 200]:
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/events", token=tokens["admin"],
|
||
params={"limit": limit})
|
||
edata = body.get("data", [])
|
||
within_limit = len(edata) <= limit
|
||
record(f"TC-EVENT-04-limit{limit}", "P2", f"limit={limit}",
|
||
sc == 200 and within_limit,
|
||
f"请求 {limit}, 返回 {len(edata)}")
|
||
|
||
|
||
# ---- 4.3 驾驶舱面板 API ----
|
||
|
||
def test_dashboard_skill_tree(tokens):
|
||
"""TC-DASH-01 [P0] 技能图谱"""
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/dashboard/skill-tree", token=tokens["admin"])
|
||
req = f"GET {EVOLUTION_PREFIX}/dashboard/skill-tree"
|
||
data = body.get("data", {})
|
||
ok = (sc == 200 and body.get("code") == 0 and isinstance(data, dict))
|
||
|
||
has_categories = "categories" in data
|
||
has_counts = all(k in data for k in ["total_count", "main_count", "baby_count", "staging_count", "frozen_count"])
|
||
|
||
record("TC-DASH-01", "P0", "技能图谱 skill-tree",
|
||
ok and has_categories and has_counts,
|
||
f"HTTP {sc}, categories={has_categories}, counts={has_counts}, "
|
||
f"total={data.get('total_count', 'N/A')}",
|
||
req, json.dumps(body, ensure_ascii=False)[:600])
|
||
|
||
|
||
def test_dashboard_timeline(tokens):
|
||
"""TC-DASH-02 [P0] 进化时间线"""
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/dashboard/timeline", token=tokens["admin"])
|
||
req = f"GET {EVOLUTION_PREFIX}/dashboard/timeline"
|
||
data = body.get("data", [])
|
||
|
||
record("TC-DASH-02", "P0", "进化时间线 timeline",
|
||
sc == 200 and body.get("code") == 0 and isinstance(data, list),
|
||
f"HTTP {sc}, 返回 {len(data) if isinstance(data,list) else 0} 条",
|
||
req, json.dumps(body, ensure_ascii=False)[:500])
|
||
|
||
# 时间线事件类型筛选
|
||
sc, body2, _ = api("GET", f"{EVOLUTION_PREFIX}/dashboard/timeline", token=tokens["admin"],
|
||
params={"event_type": "created"})
|
||
tdata = body2.get("data", [])
|
||
record("TC-DASH-02-filter", "P1", "时间线筛选: event_type=created",
|
||
sc == 200 and all(t.get("event_type") == "created" for t in tdata),
|
||
f"返回 {len(tdata)} 条")
|
||
|
||
|
||
def test_dashboard_statistics(tokens):
|
||
"""TC-DASH-03 [P0] 技能使用统计"""
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/dashboard/statistics", token=tokens["admin"])
|
||
req = f"GET {EVOLUTION_PREFIX}/dashboard/statistics"
|
||
data = body.get("data", {})
|
||
|
||
has_ranking = "ranking" in data
|
||
has_distribution = "category_distribution" in data if isinstance(data, dict) else False
|
||
has_trend = "daily_trend" in data if isinstance(data, dict) else False
|
||
has_total = "total_use_count" in data if isinstance(data, dict) else False
|
||
has_low = "low_use_skills" in data if isinstance(data, dict) else False
|
||
|
||
all_fields = has_ranking and has_distribution and has_trend and has_total and has_low
|
||
|
||
record("TC-DASH-03", "P0", "使用统计 statistics",
|
||
sc == 200 and body.get("code") == 0 and all_fields,
|
||
f"HTTP {sc}, ranking={has_ranking}, dist={has_distribution}, "
|
||
f"trend={has_trend}, total={has_total}, low={has_low}",
|
||
req, json.dumps(body, ensure_ascii=False)[:600])
|
||
|
||
|
||
def test_dashboard_profile(tokens):
|
||
"""TC-DASH-04 [P0] AI能力画像"""
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/dashboard/profile", token=tokens["admin"])
|
||
req = f"GET {EVOLUTION_PREFIX}/dashboard/profile"
|
||
data = body.get("data", {})
|
||
|
||
has_level = "level" in data if isinstance(data, dict) else False
|
||
has_title = "level_title" in data if isinstance(data, dict) else False
|
||
has_dims = "dimensions" in data if isinstance(data, dict) else False
|
||
has_tags = "tags" in data if isinstance(data, dict) else False
|
||
has_suggestions = "growth_suggestions" in data if isinstance(data, dict) else False
|
||
|
||
all_fields = has_level and has_title and has_dims and has_tags and has_suggestions
|
||
|
||
record("TC-DASH-04", "P0", "AI能力画像 profile",
|
||
sc == 200 and body.get("code") == 0 and all_fields,
|
||
f"HTTP {sc}, level={has_level}, title={has_title}, "
|
||
f"dims={has_dims}, tags={has_tags}, suggestions={has_suggestions}",
|
||
req, json.dumps(body, ensure_ascii=False)[:600])
|
||
|
||
# 验证 dimension 结构
|
||
if isinstance(data, dict) and isinstance(data.get("dimensions"), list) and len(data["dimensions"]) > 0:
|
||
dim = data["dimensions"][0]
|
||
dim_ok = all(k in dim for k in ["label", "value", "maxValue"])
|
||
record("TC-DASH-04-dim", "P1", "画像 dimension 结构",
|
||
dim_ok, f"Sample dim: {dim}")
|
||
|
||
|
||
# ---- 4.4 轨迹提交 API ----
|
||
|
||
def test_trajectory_submit(tokens):
|
||
"""TC-TRAJ-01/02/03/04"""
|
||
|
||
# TC-TRAJ-01 [P0] 提交轨迹
|
||
payload = {
|
||
"conversation_id": "test-api-conv-001",
|
||
"messages": [
|
||
{"role": "user", "content": "帮我写一个Python排序函数", "tokens": 50},
|
||
{"role": "assistant", "content": "好的,以下是快速排序实现...", "tokens": 120}
|
||
]
|
||
}
|
||
sc, body, _ = api("POST", f"{EVOLUTION_PREFIX}/trajectory/submit", token=tokens["admin"],
|
||
json_body=payload)
|
||
req = f"POST {EVOLUTION_PREFIX}/trajectory/submit"
|
||
|
||
data = body.get("data", {})
|
||
has_id = "id" in data if isinstance(data, dict) else False
|
||
has_status = data.get("status") == "discovered" if isinstance(data, dict) else False
|
||
|
||
record("TC-TRAJ-01", "P0", "提交对话轨迹",
|
||
sc == 200 and body.get("code") == 0 and has_id and has_status,
|
||
f"HTTP {sc}, status={data.get('status','N/A') if isinstance(data,dict) else 'N/A'}, "
|
||
f"id={data.get('id','N/A')[:8] if isinstance(data,dict) else 'N/A'}...",
|
||
req, json.dumps(body, ensure_ascii=False)[:500])
|
||
|
||
traj_id = data.get("id", "") if isinstance(data, dict) else ""
|
||
traj_status = data.get("status", "") if isinstance(data, dict) else ""
|
||
|
||
# TC-TRAJ-02 [P1] Token统计验证
|
||
if isinstance(data, dict):
|
||
token_used = data.get("token_used", -1)
|
||
record("TC-TRAJ-02", "P1", "轨迹Token统计",
|
||
token_used == 170,
|
||
f"token_used={token_used} (expected 170=50+120)",
|
||
"", json.dumps(data, ensure_ascii=False)[:300])
|
||
|
||
# TC-TRAJ-04 [P2] 提交空消息
|
||
sc, body2, _ = api("POST", f"{EVOLUTION_PREFIX}/trajectory/submit", token=tokens["admin"],
|
||
json_body={"conversation_id": "test-empty", "messages": []})
|
||
record("TC-TRAJ-04", "P2", "提交空消息轨迹",
|
||
sc == 200 and body2.get("code") == 0,
|
||
f"HTTP {sc}, 空消息提交",
|
||
f"POST {EVOLUTION_PREFIX}/trajectory/submit (empty messages)")
|
||
|
||
return traj_id
|
||
|
||
|
||
# ---- 4.5 数据隔离测试 ----
|
||
|
||
def test_data_isolation(tokens, all_skills):
|
||
"""TC-ISOL-01/02/03/04 [P0]"""
|
||
|
||
# TC-ISOL-01 [P0] 跨用户访问技能
|
||
if all_skills:
|
||
admin_skill = all_skills[0].get("skill_name")
|
||
# demo_user尝试访问admin的技能
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/skills/{admin_skill}", token=tokens["demo"])
|
||
record("TC-ISOL-01", "P0", f"跨用户技能访问: demo尝试访问admin的'{admin_skill}'",
|
||
sc == 404,
|
||
f"HTTP {sc} (expected 404 - 技能不属于demo)",
|
||
f"GET {EVOLUTION_PREFIX}/skills/{admin_skill} (demo token)")
|
||
|
||
# TC-ISOL-02 [P0] 跨用户操作技能
|
||
if all_skills:
|
||
admin_skill = all_skills[0].get("skill_name")
|
||
sc_put, _, _ = api("PUT", f"{EVOLUTION_PREFIX}/skills/{admin_skill}", token=tokens["demo"],
|
||
json_body={"description": "hack"})
|
||
sc_del, _, _ = api("DELETE", f"{EVOLUTION_PREFIX}/skills/{admin_skill}", token=tokens["demo"])
|
||
sc_freeze, _, _ = api("POST", f"{EVOLUTION_PREFIX}/skills/{admin_skill}/freeze", token=tokens["demo"])
|
||
record("TC-ISOL-02", "P0", "跨用户操作技能",
|
||
sc_put == 404 and sc_del == 404 and sc_freeze == 404,
|
||
f"PUT={sc_put}, DELETE={sc_del}, FREEZE={sc_freeze} (all expected 404)")
|
||
|
||
# TC-ISOL-03 [P1] 跨用户进化事件
|
||
sc_admin, body_admin, _ = api("GET", f"{EVOLUTION_PREFIX}/events", token=tokens["admin"])
|
||
sc_demo, body_demo, _ = api("GET", f"{EVOLUTION_PREFIX}/events", token=tokens["demo"])
|
||
admin_events = body_admin.get("data", [])
|
||
demo_events = body_demo.get("data", [])
|
||
# admin的事件不应出现在demo的列表里
|
||
admin_skill_names = {e.get("skill_name") for e in admin_events}
|
||
demo_skill_names = {e.get("skill_name") for e in demo_events}
|
||
# 如果两个用户都有数据,验证不重叠
|
||
if admin_skill_names and demo_skill_names:
|
||
overlap = admin_skill_names & demo_skill_names if len(demo_events) > 0 else set()
|
||
# 注意:如果两个user正好有同名技能,这不意味着数据泄露
|
||
record("TC-ISOL-03", "P1", "事件数据隔离",
|
||
sc_admin == 200 and sc_demo == 200,
|
||
f"admin: {len(admin_events)} events, demo: {len(demo_events)} events")
|
||
|
||
# TC-ISOL-04 [P1] 驾驶舱数据隔离
|
||
for endpoint in ["skill-tree", "timeline", "statistics", "profile"]:
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/dashboard/{endpoint}", token=tokens["admin"])
|
||
sc2, body2, _ = api("GET", f"{EVOLUTION_PREFIX}/dashboard/{endpoint}", token=tokens["demo"])
|
||
record(f"TC-ISOL-04-{endpoint}", "P1", f"驾驶舱隔离: {endpoint}",
|
||
sc == 200 and sc2 == 200,
|
||
f"admin={sc}, demo={sc2}")
|
||
|
||
|
||
# ---- 4.8 数据库表验证 ----
|
||
|
||
def test_db_tables():
|
||
"""TC-DB-01/02/03/04 [P0] 验证数据库表存在"""
|
||
import subprocess
|
||
result = subprocess.run([
|
||
"mysql", "-u", "root", "-ppwd2026", "-h", "localhost",
|
||
"--batch", "--skip-column-names",
|
||
"-e", "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA='aiflow' AND (TABLE_NAME LIKE 'evolution%' OR TABLE_NAME = 'skill_trajectory_links') ORDER BY TABLE_NAME"
|
||
], capture_output=True, text=True)
|
||
|
||
tables = [t.strip() for t in result.stdout.strip().split("\n") if t.strip()]
|
||
# Also check stderr for results when mysql warning interferes
|
||
if not tables and result.stderr:
|
||
for line in result.stderr.strip().split("\n"):
|
||
line = line.strip()
|
||
if line and not line.startswith("mysql:") and line not in expected:
|
||
pass
|
||
expected = ["evolution_events", "evolution_skills", "evolution_trajectories", "skill_trajectory_links"]
|
||
# user_evolution_config 表单独验证(使用 DESC 命令)
|
||
result2 = subprocess.run([
|
||
"mysql", "-u", "root", "-ppwd2026", "-h", "localhost",
|
||
"--batch", "--skip-column-names",
|
||
"-e", "DESC aiflow.user_evolution_config"
|
||
], capture_output=True, text=True)
|
||
uec_exists = len(result2.stdout.strip()) > 0
|
||
|
||
all_exist = all(t in tables for t in expected) and uec_exists
|
||
|
||
record("TC-DB-TABLES", "P0", "数据库表存在性验证",
|
||
all_exist,
|
||
f"发现表: {tables}, user_evolution_config={uec_exists}",
|
||
"", result.stdout[:500])
|
||
|
||
|
||
# ============================================================
|
||
# Report Generation
|
||
# ============================================================
|
||
|
||
def generate_report():
|
||
"""生成Markdown格式测试报告"""
|
||
total = len(results)
|
||
passed = sum(1 for r in results if r.passed)
|
||
failed = total - passed
|
||
|
||
lines = []
|
||
lines.append("# AI自进化功能 — 接口自动化测试报告")
|
||
lines.append("")
|
||
lines.append(f"> 测试执行时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||
lines.append(f"> 测试用例总数:{total} | 通过:{passed} | 失败:{failed}")
|
||
lines.append(f"> P0通过率:{P0_pass}/{P0_count} ({100*P0_pass//P0_count if P0_count else 0}%)")
|
||
lines.append(f"> P1通过率:{P1_pass}/{P1_count} ({100*P1_pass//P1_count if P1_count else 0}%)")
|
||
lines.append(f"> P2通过率:{P2_pass}/{P2_count} ({100*P2_pass//P2_count if P2_count else 0}%)")
|
||
lines.append("")
|
||
|
||
# Summary table
|
||
lines.append("## 测试总结")
|
||
lines.append("")
|
||
lines.append("| 测试模块 | 用例数 | 通过 | 失败 | 通过率 |")
|
||
lines.append("|----------|--------|------|------|--------|")
|
||
|
||
modules = {
|
||
"技能管理API": ["TC-SKILL"],
|
||
"进化事件API": ["TC-EVENT"],
|
||
"驾驶舱面板API": ["TC-DASH"],
|
||
"轨迹提交API": ["TC-TRAJ"],
|
||
"数据隔离": ["TC-ISOL"],
|
||
"数据库表结构": ["TC-DB"],
|
||
}
|
||
|
||
for mod, prefixes in modules.items():
|
||
mod_tests = [r for r in results if any(r.tc_id.startswith(p) for p in prefixes)]
|
||
mod_pass = sum(1 for r in mod_tests if r.passed)
|
||
mod_total = len(mod_tests)
|
||
rate = f"{100*mod_pass//mod_total}%" if mod_total > 0 else "N/A"
|
||
lines.append(f"| {mod} | {mod_total} | {mod_pass} | {mod_total-mod_pass} | {rate} |")
|
||
|
||
lines.append(f"| **总计** | **{total}** | **{passed}** | **{failed}** | **{100*passed//total if total else 0}%** |")
|
||
lines.append("")
|
||
|
||
# Detailed results
|
||
lines.append("## 详细测试结果")
|
||
lines.append("")
|
||
lines.append("| 用例ID | 优先级 | 测试名称 | 结果 | 详情 |")
|
||
lines.append("|--------|--------|----------|------|------|")
|
||
|
||
for r in results:
|
||
status = "PASS" if r.passed else "FAIL"
|
||
lines.append(f"| {r.tc_id} | {r.name.split('[')[-1].replace(']','') if '[' in r.name else ''} | {r.name.split(' [')[0] if ' [' in r.name else r.name} | {status} | {r.detail[:100]} |")
|
||
|
||
lines.append("")
|
||
|
||
# Failed cases detail
|
||
failed_cases = [r for r in results if not r.passed]
|
||
if failed_cases:
|
||
lines.append("## 失败用例详情")
|
||
lines.append("")
|
||
for r in failed_cases:
|
||
lines.append(f"### {r.tc_id} - {r.name}")
|
||
lines.append(f"- **优先级**:P0" if "P0" in r.name else f"- **优先级**:P1" if "P1" in r.name else "")
|
||
lines.append(f"- **详情**:{r.detail}")
|
||
if r.request:
|
||
lines.append(f"- **请求**:`{r.request[:200]}`")
|
||
if r.response:
|
||
lines.append(f"- **响应**:`{r.response[:300]}`")
|
||
lines.append("")
|
||
else:
|
||
lines.append("## 失败用例详情")
|
||
lines.append("")
|
||
lines.append("无失败用例。所有测试通过!")
|
||
lines.append("")
|
||
|
||
# Bug fix verification
|
||
lines.append("## Bug修复验证")
|
||
lines.append("")
|
||
bug_tests = [r for r in results if "Bug" in r.name or "TC-SKILL-11" in r.tc_id]
|
||
if bug_tests:
|
||
for r in bug_tests:
|
||
st = "通过" if r.passed else "失败"
|
||
lines.append(f"- {r.tc_id} {r.name}: {st}")
|
||
lines.append("")
|
||
|
||
# Data isolation summary
|
||
lines.append("## 数据隔离验证总结")
|
||
lines.append("")
|
||
iso_tests = [r for r in results if r.tc_id.startswith("TC-ISOL")]
|
||
iso_pass = sum(1 for r in iso_tests if r.passed)
|
||
lines.append(f"- 数据隔离测试:{iso_pass}/{len(iso_tests)} 通过")
|
||
lines.append(f"- 两层验证:API路由层(JWT) + 服务层(user_id过滤)")
|
||
lines.append(f"- 覆盖操作:GET获取技能、PUT编辑、DELETE删除、POST冻结、GET事件、GET驾驶舱")
|
||
lines.append("")
|
||
|
||
# Recommendation
|
||
lines.append("## 建议")
|
||
lines.append("")
|
||
if failed == 0:
|
||
lines.append("- 所有接口自动化测试通过,建议进入第三阶段(UI自动化测试)")
|
||
else:
|
||
lines.append(f"- 存在 {failed} 个失败用例,需要开发工程师修复后重新测试")
|
||
for r in failed_cases:
|
||
lines.append(f" - {r.tc_id}: {r.name} — {r.detail}")
|
||
lines.append("")
|
||
|
||
lines.append("---")
|
||
lines.append(f"*报告由自动化测试脚本生成于 {datetime.now().isoformat()}*")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
# ============================================================
|
||
# Main
|
||
# ============================================================
|
||
|
||
def main():
|
||
print("=" * 60)
|
||
print("AI自进化功能 - 接口自动化测试")
|
||
print(f"测试时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||
print("=" * 60)
|
||
|
||
# Step 1: 获取tokens
|
||
print("\n[1/7] 获取测试用户Tokens...")
|
||
tokens = setup_tokens()
|
||
if not tokens["admin"]:
|
||
print("ERROR: 无法获取admin token,测试终止")
|
||
sys.exit(1)
|
||
|
||
# Step 2: 技能管理API测试
|
||
print("\n[2/7] 技能管理API测试...")
|
||
skill_count, all_skills = test_skill_list(tokens)
|
||
test_skill_detail(tokens, all_skills)
|
||
|
||
# 刷新技能列表(冻结/编辑可能改变数据)
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/skills", token=tokens["admin"])
|
||
all_skills = body.get("data", []) if body.get("code") == 0 else []
|
||
|
||
test_skill_update(tokens, all_skills)
|
||
test_skill_freeze_unfreeze(tokens, all_skills)
|
||
|
||
# 刷新(freeze/unfreeze可能改变状态)
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/skills", token=tokens["admin"])
|
||
all_skills = body.get("data", []) if body.get("code") == 0 else []
|
||
|
||
test_skill_delete(tokens, all_skills)
|
||
|
||
# 再次刷新
|
||
sc, body, _ = api("GET", f"{EVOLUTION_PREFIX}/skills", token=tokens["admin"])
|
||
all_skills = body.get("data", []) if body.get("code") == 0 else []
|
||
|
||
test_skill_history(tokens, all_skills)
|
||
test_skill_trajectories(tokens, all_skills)
|
||
|
||
# Step 3: 进化事件API
|
||
print("\n[3/7] 进化事件API测试...")
|
||
test_events(tokens, all_skills)
|
||
|
||
# Step 4: 驾驶舱面板API
|
||
print("\n[4/7] 驾驶舱面板API测试...")
|
||
test_dashboard_skill_tree(tokens)
|
||
test_dashboard_timeline(tokens)
|
||
test_dashboard_statistics(tokens)
|
||
test_dashboard_profile(tokens)
|
||
|
||
# Step 5: 轨迹提交API
|
||
print("\n[5/7] 轨迹提交API测试...")
|
||
traj_id = test_trajectory_submit(tokens)
|
||
|
||
# Step 6: 数据隔离测试
|
||
print("\n[6/7] 数据隔离测试...")
|
||
test_data_isolation(tokens, all_skills)
|
||
|
||
# Step 7: 数据库表验证
|
||
print("\n[7/7] 数据库表验证...")
|
||
test_db_tables()
|
||
|
||
# Generate report
|
||
print("\n" + "=" * 60)
|
||
total = len(results)
|
||
passed = sum(1 for r in results if r.passed)
|
||
print(f"测试完成: {passed}/{total} 通过")
|
||
print(f"P0: {P0_pass}/{P0_count} | P1: {P1_pass}/{P1_count} | P2: {P2_pass}/{P2_count}")
|
||
print("=" * 60)
|
||
|
||
report = generate_report()
|
||
|
||
report_path = "/data/share/project/tests/evolution-api-test-report.md"
|
||
with open(report_path, "w", encoding="utf-8") as f:
|
||
f.write(report)
|
||
|
||
print(f"\n报告已生成: {report_path}")
|
||
return 0 if passed == total else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|