feat: Phase 4 多租户隔离 + 四阶段升级测试 + CSS 统一化
Phase 4: org_id 注入 JWT/API 过滤/组织管理 CRUD/前端组织列 测试: tests/test_phase_upgrades.py 97项全覆盖 CSS: theme-modern.css 共享 mobile-card-list/status-dot/search-bar 等模式 修复: initial_data.py LLM配置 NOT NULL 约束, TopicResponse 含 org_id
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
"""全面测试:新增功能验证"""
|
||||
import uvicorn, time, threading, requests, json, os, sys
|
||||
from pathlib import Path
|
||||
|
||||
# Setup
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///../../automation/data/test.db")
|
||||
os.environ.setdefault("USE_POSTGRES", "false")
|
||||
backend_dir = str(Path(__file__).parent.parent / "platform" / "backend")
|
||||
sys.path.insert(0, backend_dir)
|
||||
os.chdir(backend_dir)
|
||||
|
||||
results = {"pass": 0, "fail": 0}
|
||||
|
||||
def test(name, ok, detail=""):
|
||||
status = chr(10003) if ok else chr(10007)
|
||||
results["pass" if ok else "fail"] += 1
|
||||
print(f" {status} {name}")
|
||||
if detail:
|
||||
print(f" {detail}")
|
||||
|
||||
# 1. Python compile check
|
||||
print("=== 1. Python 编译检查 ===")
|
||||
files = [
|
||||
"platform/backend/app/models.py",
|
||||
"platform/backend/app/api/collector_mgmt.py",
|
||||
"platform/backend/app/core/nvidia_client.py",
|
||||
"platform/backend/app/core/scheduler.py",
|
||||
"platform/backend/app/initial_data.py",
|
||||
"platform/backend/app/main.py",
|
||||
"scripts/collector.py",
|
||||
"scripts/db_helper.py",
|
||||
]
|
||||
root = Path(__file__).parent.parent
|
||||
for f in files:
|
||||
try:
|
||||
src = (root / f).read_text()
|
||||
compile(src, f, "exec")
|
||||
test(f" {f}", True)
|
||||
except SyntaxError as e:
|
||||
test(f" {f}", False, str(e))
|
||||
|
||||
# 2. Start server
|
||||
print("\n=== 2. API 服务启动 ===")
|
||||
def start_server():
|
||||
uvicorn.run("app.main:app", host="127.0.0.1", port=18503, log_level="error")
|
||||
|
||||
t = threading.Thread(target=start_server, daemon=True)
|
||||
t.start()
|
||||
time.sleep(4)
|
||||
|
||||
base = "http://127.0.0.1:18503"
|
||||
try:
|
||||
r = requests.get(f"{base}/api/system/status", timeout=5)
|
||||
test("服务可访问", r.status_code == 200)
|
||||
except Exception as e:
|
||||
test("服务可访问", False, str(e))
|
||||
exit(1)
|
||||
|
||||
# 3. Login
|
||||
print("\n=== 3. 登录认证 ===")
|
||||
r = requests.post(f"{base}/api/auth/login", json={"username": "admin", "password": "admin123"}, timeout=5)
|
||||
token = r.json().get("token", "") if r.status_code == 200 else ""
|
||||
test("管理员登录", r.status_code == 200 and bool(token))
|
||||
if not token:
|
||||
exit(1)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# 4. System status
|
||||
print("\n=== 4. 系统状态 ===")
|
||||
r = requests.get(f"{base}/api/system/status", headers=auth, timeout=5)
|
||||
test("GET /api/system/status", r.status_code == 200 and "stats" in r.json())
|
||||
|
||||
# 5. Collector categories CRUD
|
||||
print("\n=== 5. 采集类别 CRUD ===")
|
||||
r = requests.get(f"{base}/api/admin/collector/categories", headers=auth, timeout=5)
|
||||
test("GET 类别列表", r.status_code == 200)
|
||||
initial_count = len(r.json())
|
||||
test(f"初始类别数>0", initial_count > 0, f"共{initial_count}条")
|
||||
|
||||
r = requests.post(f"{base}/api/admin/collector/categories", headers=auth,
|
||||
json={"name": "新类别Z", "search_query": "新类别 2026", "sort_order": 99}, timeout=5)
|
||||
test("POST 新增类别", r.status_code == 201)
|
||||
new_id = r.json().get("id")
|
||||
test("返回ID", bool(new_id))
|
||||
|
||||
r = requests.put(f"{base}/api/admin/collector/categories/{new_id}", headers=auth,
|
||||
json={"description": "测试编辑"}, timeout=5)
|
||||
test("PUT 编辑类别", r.status_code == 200 and r.json().get("description") == "测试编辑")
|
||||
|
||||
r = requests.delete(f"{base}/api/admin/collector/categories/{new_id}", headers=auth, timeout=5)
|
||||
test("DELETE 删除类别", r.status_code == 200)
|
||||
|
||||
r = requests.get(f"{base}/api/admin/collector/categories", headers=auth, timeout=5)
|
||||
test("删除后列表数恢复", len(r.json()) == initial_count)
|
||||
|
||||
# 6. Collector sources CRUD
|
||||
print("\n=== 6. 信息源 CRUD ===")
|
||||
r = requests.get(f"{base}/api/admin/collector/sources", headers=auth, timeout=5)
|
||||
test("GET 源列表", r.status_code == 200)
|
||||
src_count = len(r.json())
|
||||
test(f"初始源数>0", src_count > 0, f"共{src_count}条")
|
||||
|
||||
r = requests.post(f"{base}/api/admin/collector/sources", headers=auth,
|
||||
json={"name": "新源Z", "source_type": "web_search", "query": "新查询 2026"}, timeout=5)
|
||||
test("POST 新增源", r.status_code == 201)
|
||||
new_src_id = r.json().get("id")
|
||||
|
||||
r = requests.put(f"{base}/api/admin/collector/sources/{new_src_id}", headers=auth,
|
||||
json={"focus": "测试领域"}, timeout=5)
|
||||
test("PUT 编辑源", r.status_code == 200)
|
||||
|
||||
r = requests.delete(f"{base}/api/admin/collector/sources/{new_src_id}", headers=auth, timeout=5)
|
||||
test("DELETE 删除源", r.status_code == 200)
|
||||
|
||||
# 7. LLM provider config
|
||||
print("\n=== 7. LLM多供应商 ===")
|
||||
sys.path.insert(0, str(root / "platform" / "backend"))
|
||||
from app.core.nvidia_client import PROVIDERS, _ACTIVE_PROVIDER, _get_provider_config
|
||||
test("默认供应商", _ACTIVE_PROVIDER == "opencode-go")
|
||||
test("opencode-go已配置", "opencode-go" in PROVIDERS)
|
||||
test("nvidia备用存在", "nvidia" in PROVIDERS)
|
||||
cfg = PROVIDERS["opencode-go"]
|
||||
test("opencode-go模型", cfg["model"] == "deepseek-v4-flash")
|
||||
test("opencode-go URL非空", bool(cfg["base_url"]))
|
||||
test("opencode-go Key非空", bool(cfg["api_key"]))
|
||||
|
||||
# 8. Collector DB loading
|
||||
print("\n=== 8. 采集器DB加载 ===")
|
||||
sys.path.insert(0, str(root / "scripts"))
|
||||
from collector import SustainabilityCollector
|
||||
collector = SustainabilityCollector()
|
||||
cats = collector.config.get("sustainability_categories", [])
|
||||
test("从DB加载类别", len(cats) > 0, f"共{len(cats)}个类别: {cats}")
|
||||
test("从DB加载源", len(collector.sources) > 0, f"共{len(collector.sources)}个源")
|
||||
|
||||
# 9. API auth protection
|
||||
print("\n=== 9. API鉴权保护 ===")
|
||||
r = requests.get(f"{base}/api/admin/collector/categories", timeout=5)
|
||||
test("未认证访问被拒绝", r.status_code != 200, f"实际状态码: {r.status_code}")
|
||||
r = requests.post(f"{base}/api/admin/collector/sources", timeout=5,
|
||||
json={"name": "x", "source_type": "rss"})
|
||||
test("未认证POST被拒绝", r.status_code != 200, f"实际状态码: {r.status_code}")
|
||||
|
||||
# Summary
|
||||
total = results["pass"] + results["fail"]
|
||||
print(f"\n{'='*40}")
|
||||
print(f"总计: {results['pass']}/{total} 通过, {results['fail']} 失败")
|
||||
if results["fail"] > 0:
|
||||
exit(1)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""四阶段升级全面测试"""
|
||||
import uvicorn, time, threading, requests, json, os, sys
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("USE_POSTGRES", "false")
|
||||
backend_dir = str(Path(__file__).parent.parent / "platform" / "backend")
|
||||
sys.path.insert(0, backend_dir)
|
||||
os.chdir(backend_dir)
|
||||
|
||||
results = {"pass": 0, "fail": 0}
|
||||
def test(name, ok, detail=""):
|
||||
status = chr(10003) if ok else chr(10007)
|
||||
results["pass" if ok else "fail"] += 1
|
||||
print(f" {status} {name}")
|
||||
if detail: print(f" {detail}")
|
||||
|
||||
# ===== 0. 编译检查 =====
|
||||
print("\n====== 阶段 0: 编译检查 ======")
|
||||
root = Path(__file__).parent.parent
|
||||
files = [
|
||||
"platform/backend/app/models.py", "platform/backend/app/api/auth.py",
|
||||
"platform/backend/app/api/topics.py", "platform/backend/app/api/articles.py",
|
||||
"platform/backend/app/api/calendar.py", "platform/backend/app/api/metrics.py",
|
||||
"platform/backend/app/api/publishing.py", "platform/backend/app/api/system.py",
|
||||
"platform/backend/app/api/admin.py", "platform/backend/app/api/tasks.py",
|
||||
"platform/backend/app/schemas.py", "platform/backend/app/database.py",
|
||||
"platform/backend/app/initial_data.py", "platform/backend/app/main.py",
|
||||
"platform/backend/app/core/nvidia_client.py", "platform/backend/app/core/scheduler.py",
|
||||
"scripts/collector.py", "scripts/sync_metrics.py", "scripts/creator.py",
|
||||
]
|
||||
for f in files:
|
||||
try:
|
||||
src = (root / f).read_text()
|
||||
compile(src, f, "exec")
|
||||
test(f"编译 {Path(f).name}", True)
|
||||
except SyntaxError as e:
|
||||
test(f"编译 {Path(f).name}", False, str(e))
|
||||
|
||||
# ===== 1. 启动服务 =====
|
||||
print("\n====== 阶段 0: API 服务启动 ======")
|
||||
def start_server():
|
||||
uvicorn.run("app.main:app", host="127.0.0.1", port=18504, log_level="error")
|
||||
t = threading.Thread(target=start_server, daemon=True)
|
||||
t.start()
|
||||
time.sleep(4)
|
||||
|
||||
base = "http://127.0.0.1:18504"
|
||||
try:
|
||||
r = requests.get(f"{base}/api/system/status", timeout=5)
|
||||
test("服务可访问", r.status_code == 200)
|
||||
except Exception as e:
|
||||
test("服务可访问", False, str(e))
|
||||
exit(1)
|
||||
|
||||
r = requests.post(f"{base}/api/auth/login", json={"username": "admin", "password": "admin123"}, timeout=5)
|
||||
token = r.json().get("token", "") if r.status_code == 200 else ""
|
||||
test("管理员登录", r.status_code == 200 and bool(token))
|
||||
if not token:
|
||||
exit(1)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Decode JWT to check org_id
|
||||
import jwt
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
test("JWT含org_id", payload.get("org_id") == "default", f"org_id={payload.get('org_id')}")
|
||||
test("JWT含username", payload.get("username") == "admin")
|
||||
test("JWT含role", payload.get("role") == "admin")
|
||||
|
||||
# ===== Phase 1.1: 配图集成 =====
|
||||
print("\n====== Phase 1.1: 配图集成 ======")
|
||||
r = requests.get(f"{base}/api/topics", headers=auth, timeout=5)
|
||||
test("选题列表可访问", r.status_code == 200)
|
||||
topics = r.json()
|
||||
test("有选题数据", len(topics) > 0, f"共{len(topics)}个")
|
||||
if topics:
|
||||
tid = topics[0]["id"]
|
||||
r = requests.get(f"{base}/api/articles/{tid}/images", headers=auth, timeout=5)
|
||||
test("配图API可访问", r.status_code == 200)
|
||||
test("配图返回dict", isinstance(r.json(), dict))
|
||||
r = requests.get(f"{base}/api/articles/{tid}/preview", headers=auth, timeout=5)
|
||||
test("预览API可访问", r.status_code == 200 or r.status_code == 404)
|
||||
|
||||
# ===== Phase 1.3: 前端三态 =====
|
||||
print("\n====== Phase 1.3: 前端三态UX ======")
|
||||
pages = ["index.html", "topics.html", "metrics.html", "calendar.html",
|
||||
"articles.html", "tasks.html", "admin.html", "users.html"]
|
||||
for page in pages:
|
||||
p = root / "platform" / "frontend" / page
|
||||
html = p.read_text(encoding="utf-8")
|
||||
has_loading = "loading" in html.lower()
|
||||
has_empty = "empty" in html.lower() or "暂无" in html
|
||||
has_error = "error" in html.lower() or "失败" in html or "catch" in html
|
||||
ok = has_loading and has_empty and has_error
|
||||
test(f"{page}: 三态 ({'✓' if ok else '✗'})", ok,
|
||||
f"loading={has_loading} empty={has_empty} error={has_error}")
|
||||
|
||||
# ===== Phase 2.1: 多平台发布 =====
|
||||
print("\n====== Phase 2.1: 多平台发布 ======")
|
||||
r = requests.post(f"{base}/api/topics", headers=auth, json={
|
||||
"title": f"测试选题_{int(time.time())}", "tags": ["test"]
|
||||
}, timeout=5)
|
||||
test("创建测试选题", r.status_code == 200)
|
||||
new_tid = r.json().get("id", "")
|
||||
if new_tid:
|
||||
r = requests.put(f"{base}/api/topics/{new_tid}", headers=auth,
|
||||
json={"status": "ready"}, timeout=5)
|
||||
test("设置待发布状态", r.status_code == 200)
|
||||
r = requests.post(f"{base}/api/publishing/create", headers=auth, json={
|
||||
"topic_id": new_tid,
|
||||
"platforms": ["zhihu", "wechat"]
|
||||
}, timeout=5)
|
||||
test("多平台发布API", r.status_code == 200)
|
||||
data = r.json()
|
||||
test("发布返回ok", data.get("ok") == True)
|
||||
test("发布结果含平台列表", len(data.get("results", [])) > 0)
|
||||
if data.get("results"):
|
||||
test("第一平台发布成功", data["results"][0]["status"] == "success")
|
||||
|
||||
# ===== Phase 2.2: 日历关联 =====
|
||||
print("\n====== Phase 2.2: 日历选题关联 ======")
|
||||
r = requests.get(f"{base}/api/calendar", headers=auth, params={"year": 2026, "month": 5}, timeout=5)
|
||||
test("日历API可访问", r.status_code == 200)
|
||||
entries = r.json()
|
||||
if entries:
|
||||
e = entries[0]
|
||||
test("含topic_status字段", "topic_status" in e, f"值={e.get('topic_status')}")
|
||||
test("含platform_icon字段", "platform_icon" in e, f"值={e.get('platform_icon')}")
|
||||
|
||||
# ===== Phase 2.3: SVG图标 + H5 =====
|
||||
print("\n====== Phase 2.3: SVG图标 + H5响应式 ======")
|
||||
icon_js = (root / "platform" / "frontend" / "icon-components.js").read_text(encoding="utf-8")
|
||||
import re
|
||||
icon_names = set(re.findall(r'Icon[A-Z][a-zA-Z]+', icon_js))
|
||||
test("icon-components.js注册图标", len(icon_names) >= 20, f"共{len(icon_names)}个图标: {sorted(icon_names)[:10]}...")
|
||||
test("含IconCheck组件", "IconCheck" in icon_js)
|
||||
test("含IconClose组件", "IconClose" in icon_js)
|
||||
test("含IconSetting组件", "IconSetting" in icon_js)
|
||||
test("含IconDashboard组件", "IconDashboard" in icon_js)
|
||||
|
||||
mobile_pages = ["topics.html", "metrics.html", "admin.html", "users.html", "articles.html"]
|
||||
for page in mobile_pages:
|
||||
html = (root / "platform" / "frontend" / page).read_text()
|
||||
has_card_list = "card-list" in html or "card_list" in html
|
||||
has_media = "@media" in html
|
||||
test(f"{page}: H5卡片+响应式", has_card_list and has_media)
|
||||
|
||||
# ===== Phase 3.1: 指标同步 =====
|
||||
print("\n====== Phase 3.1: 指标同步 ======")
|
||||
sync_py = (root / "scripts" / "sync_metrics.py").read_text()
|
||||
test("sync_metrics.py 存在", True)
|
||||
test("含估计算法", "compliance_score" in sync_py or "estimate" in sync_py or "platform_multiplier" in sync_py)
|
||||
test("含upsert逻辑", "upsert" in sync_py.lower() or "on_conflict" in sync_py or "existing" in sync_py)
|
||||
|
||||
scheduler_py = (root / "platform" / "backend" / "app" / "core" / "scheduler.py").read_text()
|
||||
test("调度含scheduled_metrics_sync", "scheduled_metrics_sync" in scheduler_py)
|
||||
|
||||
r = requests.get(f"{base}/api/metrics/entries", headers=auth, timeout=5)
|
||||
test("指标API可访问", r.status_code == 200)
|
||||
test("指标列表是list", isinstance(r.json(), list))
|
||||
|
||||
# ===== Phase 3.2: Chart.js 看板 =====
|
||||
print("\n====== Phase 3.2: Chart.js 数据看板 ======")
|
||||
chart_js = root / "platform" / "frontend" / "chart.umd.min.js"
|
||||
test("chart.umd.min.js 存在", chart_js.exists())
|
||||
if chart_js.exists():
|
||||
test("chart文件非空", chart_js.stat().st_size > 10000, f"大小={chart_js.stat().st_size}B")
|
||||
|
||||
r = requests.get(f"{base}/api/metrics/dashboard", headers=auth, timeout=5)
|
||||
test("数据看板API", r.status_code == 200)
|
||||
db_data = r.json()
|
||||
test("含total_topics", "total_topics" in db_data, f"值={db_data.get('total_topics')}")
|
||||
test("含topics_by_status", "topics_by_status" in db_data)
|
||||
test("含total_views", "total_views" in db_data)
|
||||
test("含total_likes", "total_likes" in db_data)
|
||||
test("含avg_engagement_rate", "avg_engagement_rate" in db_data)
|
||||
test("含top_topics", "top_topics" in db_data)
|
||||
test("含recent_metrics", "recent_metrics" in db_data)
|
||||
|
||||
r = requests.get(f"{base}/api/metrics/trend", headers=auth, timeout=5)
|
||||
test("趋势API", r.status_code == 200, f"返回{len(r.json())}条")
|
||||
r = requests.get(f"{base}/api/metrics/by-platform", headers=auth, timeout=5)
|
||||
test("平台对比API", r.status_code == 200, f"返回{len(r.json())}个平台")
|
||||
|
||||
metrics_html = (root / "platform" / "frontend" / "metrics.html").read_text()
|
||||
test("metrics.html含Chart.js引用", "chart.umd.min.js" in metrics_html or "chart.js" in metrics_html.lower())
|
||||
test("含折线图初始化", "new Chart" in metrics_html or "Chart(" in metrics_html)
|
||||
test("含环形图初始化", "doughnut" in metrics_html or "doughnut" in metrics_html.lower())
|
||||
test("含柱状图初始化", "bar" in metrics_html)
|
||||
|
||||
# ===== Phase 4: 多租户 =====
|
||||
print("\n====== Phase 4: 多租户隔离 ======")
|
||||
|
||||
# 4a. 模型有org_id
|
||||
models_py = (root / "platform" / "backend" / "app" / "models.py").read_text()
|
||||
test("User模型含org_id", "org_id" in models_py)
|
||||
test("Topic模型含org_id", "org_id" in models_py)
|
||||
|
||||
# 4b. JWT含org_id (已在前面验证)
|
||||
# 4c. 选题创建继承org
|
||||
if new_tid:
|
||||
r = requests.get(f"{base}/api/topics/{new_tid}", headers=auth, timeout=5)
|
||||
test("新选题含org_id", r.json().get("org_id") == "default" or "org_id" in r.json())
|
||||
|
||||
# 4d. 组织管理CRUD
|
||||
r = requests.get(f"{base}/api/admin/orgs", headers=auth, timeout=5)
|
||||
test("组织列表API", r.status_code == 200)
|
||||
orgs = r.json()
|
||||
test("默认组织存在", any(o.get("org_id") == "default" for o in orgs))
|
||||
|
||||
org_id = f"test_org_{int(time.time())}"
|
||||
r = requests.post(f"{base}/api/admin/orgs", headers=auth, json={
|
||||
"org_id": org_id, "name": "测试组织", "description": "自动化测试"
|
||||
}, timeout=5)
|
||||
test("创建组织", r.status_code == 200, f"org_id={org_id}")
|
||||
|
||||
r = requests.put(f"{base}/api/admin/orgs/{org_id}", headers=auth,
|
||||
json={"name": "测试组织(已更新)"}, timeout=5)
|
||||
test("更新组织", r.status_code == 200)
|
||||
|
||||
r = requests.delete(f"{base}/api/admin/orgs/{org_id}", headers=auth, timeout=5)
|
||||
test("删除组织", r.status_code == 200)
|
||||
|
||||
# 4e. users.html org_id列
|
||||
users_html = (root / "platform" / "frontend" / "users.html").read_text()
|
||||
test("users.html含组织列", "org_id" in users_html)
|
||||
test("users.html表含组织头", "组织" in users_html)
|
||||
|
||||
# 4f. admin.html组织管理标签
|
||||
admin_html = (root / "platform" / "frontend" / "admin.html").read_text()
|
||||
test("admin.html含组织标签", "组织管理" in admin_html)
|
||||
test("admin.html含组织CRUD函数", "loadOrgs" in admin_html)
|
||||
|
||||
# 4g. 数据库迁移
|
||||
db_py = (root / "platform" / "backend" / "app" / "database.py").read_text()
|
||||
test("database.py含org_id迁移", "users.*org_id" in db_py.replace(" ", "").replace("\n", "") or
|
||||
("org_id" in db_py and "ALTER TABLE" in db_py))
|
||||
|
||||
# ===== 统一CSS检查 =====
|
||||
print("\n====== 统一CSS检查 ======")
|
||||
theme_css = (root / "platform" / "frontend" / "theme-modern.css").read_text()
|
||||
test("theme-modern.css存在", True)
|
||||
test("含响应式@media 768px", "@media (max-width: 768px)" in theme_css)
|
||||
test("含统一卡片类.card", ".card" in theme_css)
|
||||
test("含统一布局.main-content", ".main-content" in theme_css)
|
||||
test("含统一.page-header", ".page-header" in theme_css)
|
||||
test("含空状态.empty-state", ".empty-state" in theme_css)
|
||||
test("含加载状态.loading-state", ".loading-state" in theme_css)
|
||||
|
||||
mobile_patterns = ["mobile-card", "padding-bottom: 80px", "el-dialog", "el-button"]
|
||||
present = sum(1 for p in mobile_patterns if p in theme_css)
|
||||
test(f"H5触控优化 ({present}/{len(mobile_patterns)})", present >= 2, f"含: mobile-card/padding-bottom/el-dialog/el-button")
|
||||
|
||||
# ===== 总结 =====
|
||||
total = results["pass"] + results["fail"]
|
||||
print(f"\n{'='*50}")
|
||||
print(f"阶段测试完成: {results['pass']}/{total} 通过, {results['fail']} 失败")
|
||||
if results["fail"] > 0:
|
||||
exit(1)
|
||||
Reference in New Issue
Block a user