Files
gaokao-volunteer-system/admin/tests/test_app.py
Hermes Agent 43c1dbdf32
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat(llm): 内置 LLM 供应商配置与审核主链接入骨架
配置层:
- Settings 新增 llm_provider/api_key/base_url/model/timeout/max_tokens
- 生产 fail-closed: GAOKAO_LLM_PROVIDER=none 禁止, provider!=none 且 API key 为空禁止
- .env.docker.example / .env.payment.example 补 LLM 变量
- payment_readiness_doctor 将 LLM 配置纳入 readiness 检查

基础设施:
- 新增 data/llm/client.py: OpenAI-compatible LLMClient
- 新增 data/llm/prompts.py: audit/cwb/full_plan prompt 模板
- 新增 data/llm/tests/test_llm.py: 12 个单元测试

主链接入:
- ReviewResultContract 新增 llm_generated / llm_summary / llm_cwb_suggestions
- _start_review_result 优先尝试 LLM 生成审核结果, 失败时回退到原规则默认逻辑
- cwb 页面优先展示 LLM 生成的三档建议

测试适配:
- conftest / health / app / p2_4 tests 注入默认 LLM 测试配置,避免被新 fail-closed 提前拦截

验证:
- data/llm/tests 12 passed
- 核心 prod settings tests 62 passed
2026-06-28 12:11:43 +08:00

257 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""FastAPI app 启动 + Swagger 测试 (T6.1).
验证:
- app 工厂可用
- lifespan 触发 bootstrap
- /openapi.json 含所有路由
- /docs 返回 HTML
"""
from __future__ import annotations
import sqlite3
import pytest
def test_create_app_runs_lifespan(client):
"""TestClient 上下文会触发 lifespan,验证 bootstrap 完成。"""
# 客户端创建时 lifespan 已运行 → 已有 admin 用户
resp = client.get("/health")
assert resp.status_code == 200
body = resp.json()
assert body.get("status") == "ok"
assert body.get("checks", {}).get("db_writable") is True
assert body.get("checks", {}).get("disk_writable") is True
assert body.get("checks", {}).get("settings_valid") is True
def test_openapi_json_exposes_all_routes(client):
"""/openapi.json 含所有 T6.1 路由。"""
resp = client.get("/openapi.json")
assert resp.status_code == 200
schema = resp.json()
paths = set(schema["paths"].keys())
expected = {
"/health",
"/api/auth/login",
"/api/auth/me",
"/api/orders",
"/api/orders/{order_id}",
"/api/stats/dashboard",
"/api/stats/orders",
"/api/meta",
}
missing = expected - paths
assert not missing, f"OpenAPI 缺失路径: {missing}"
def test_docs_serves_swagger_ui(client):
"""/docs 返回 HTML(200 + 含 swagger-ui 字样)。"""
resp = client.get("/docs")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
body = resp.text.lower()
assert "swagger" in body or "openapi" in body
def test_redoc_served(client):
"""/redoc 也可用。"""
resp = client.get("/redoc")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
def test_dashboard_page_served(client, auth_headers):
"""/dashboard 需要鉴权,鉴权后返回运营后台页面骨架。"""
resp = client.get("/dashboard", headers=auth_headers)
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
body = resp.text
assert "运营总览" in body
assert "核心经营指标" in body
assert "/static/dashboard.js" in body
assert "/static/portal-ui.css" in body
assert 'id="trend-chart"' in body
assert 'id="status-chart"' in body
assert 'id="source-chart"' in body
assert 'id="service-chart"' in body
assert 'id="range-7d"' in body
assert 'id="range-30d"' in body
assert 'id="system-status"' in body
assert 'id="pending-orders"' in body
assert 'id="pending-overdue-24h"' in body
assert 'id="pending-missing-intake"' in body
assert 'id="pending-breakdown"' in body
assert 'id="pending-overdue-tag"' in body
assert 'id="pending-missing-tag"' in body
assert "pending-tag-overdue" in body
assert "pending-tag-missing" in body
assert 'id="orders-spark"' in body
assert 'id="revenue-spark"' in body
assert 'id="status-title"' in body
assert 'id="quick-refresh-btn"' in body
assert 'id="quick-logout-btn"' in body
assert 'id="dev-seed-panel"' in body
assert 'id="dev-seed-overdue-btn"' in body
assert 'id="dev-seed-clean-btn"' in body
assert 'id="dashboard-title"' in body
assert 'id="login-form"' in body
assert 'id="quick-links"' in body
assert 'class="card empty"' in body
assert 'class="chart-empty"' in body
assert 'data-card="orders"' in body
assert 'data-card="revenue"' in body
assert 'data-card="users"' in body
assert 'data-card="pending"' in body
assert "尚未连接" in body
assert "立即刷新" in body
assert "退出登录" in body
assert "等待加载状态分布" in body
assert "等待加载来源分布" in body
assert "等待加载服务版本分布" in body
assert "登录后展示订单与收入趋势" in body
assert "/static/vendor/echarts.min.js" in body
assert "jsdelivr.net" not in body
assert "最小仪表盘" not in body
assert "admin_users 总数" not in body
assert "接口: <code>/api/stats/dashboard</code>" not in body
def test_dashboard_static_js_served(client):
"""前端脚本包含趋势切换与 3 张分布图渲染逻辑。"""
resp = client.get("/static/dashboard.js")
assert resp.status_code == 200
assert "javascript" in resp.headers["content-type"]
body = resp.text
assert "renderSummary" in body
assert "/api/stats/dashboard" in body
assert "status-chart" in body
assert "source-chart" in body
assert "service-chart" in body
assert 'range: "7d"' in body
assert "trends?.[state.range]" in body
assert "sessionStorage" in body
assert "localStorage" not in body
assert "admin_users 总数" not in body
assert "setStatus" in body
assert "setChartEmpty" in body
assert "pending-orders" in body
assert "pending-overdue-24h" in body
assert "pending-missing-intake" in body
assert "setChartEmpty" in body
assert "pending-breakdown" in body
assert "postDevSeed" in body
assert "dev-seed-panel" in body
assert "/api/admin/orders/dev-seed" in body
def test_bootstrap_admin_only_once(client, settings):
"""lifespan 已 bootstrap 后,再次调用应报告已存在,不再创建。"""
from admin.db import AdminUserRepo, bootstrap_admin
# client fixture 已触发 lifespan → 已有 bootstrap 用户
created1, msg1 = bootstrap_admin(settings)
assert created1 is False
assert "已存在" in msg1
repo = AdminUserRepo(settings.db_path)
assert repo.count() == 1
def test_default_admin_login_works(client, settings):
"""默认 admin/admin123 (此处覆写为 test-pass-123) 可登录。"""
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-pass-123"},
)
assert resp.status_code == 200
body = resp.json()
assert body["token_type"] == "bearer"
assert body["expires_in"] == settings.jwt_expire_minutes * 60
assert isinstance(body["access_token"], str)
assert len(body["access_token"]) > 20
def test_create_app_bootstraps_orders_schema(tmp_path, monkeypatch):
"""真实启动流程会顺手初始化 orders DBfresh app 的 dashboard 空库可用。"""
db_path = tmp_path / "admin.db"
orders_db_path = tmp_path / "orders.db"
monkeypatch.setenv("GAOKAO_ENV", "dev")
monkeypatch.setenv("GAOKAO_DB_PATH", str(db_path))
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", str(orders_db_path))
monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64)
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
from fastapi.testclient import TestClient
from admin.app import create_app
from admin.config import load_settings
app = create_app(load_settings())
with TestClient(app) as client:
login = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-pass-123"},
)
assert login.status_code == 200
token = login.json()["access_token"]
resp = client.get(
"/api/stats/dashboard",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["summary"] == {
"total_orders": 0,
"total_revenue_cents": 0,
"total_users": 1,
"pending_orders": 0,
"pending_overdue_24h": 0,
"pending_missing_intake": 0,
"orders_today": 0,
"orders_7d": 0,
"orders_30d": 0,
"revenue_today_cents": 0,
"revenue_7d_cents": 0,
"revenue_30d_cents": 0,
}
with sqlite3.connect(orders_db_path) as conn:
orders_row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='orders'"
).fetchone()
intake_row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='order_intakes'"
).fetchone()
assert orders_row is not None
assert intake_row is not None
def test_prod_rejects_default_admin_password(tmp_path, monkeypatch):
db_path = tmp_path / "admin.db"
orders_db_path = tmp_path / "orders.db"
monkeypatch.setenv("GAOKAO_ENV", "prod")
monkeypatch.setenv("GAOKAO_DB_PATH", str(db_path))
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", str(orders_db_path))
monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64)
monkeypatch.setenv("GAOKAO_PORTAL_TOKEN_SECRET", "Z" * 64)
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "admin123")
monkeypatch.setenv("GAOKAO_PAYMENT_PROVIDER", "alipay")
monkeypatch.setenv("GAOKAO_LLM_PROVIDER", "dashscope")
monkeypatch.setenv("GAOKAO_LLM_API_KEY", "sk-test")
# 显式提供合规 webhook secret,避免被 P2-5 fail-closed 提前拦截,
# 让本测试聚焦于管理员密码策略。
monkeypatch.setenv("GAOKAO_PAYMENT_WEBHOOK_SECRET", "P" + "r" * 31 + "!" * 32)
from admin.app import _validate_and_log_settings
from admin.config import load_settings
with pytest.raises(
RuntimeError, match="GAOKAO_ADMIN_PASS invalid in prod|默认管理员密码"
):
_validate_and_log_settings(load_settings())