Files
gaokao-volunteer-system/admin/tests/conftest.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

455 lines
17 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.
"""pytest 配置 (T6.1).
提供 fixture:
- settings: 内存 SQLite + 安全 JWT 密钥 + 短过期
- app: FastAPI 实例(lifespan 已运行 → admin 表已建 + bootstrap 用户)
- client: httpx TestClient
- auth_token: 登录后的 Bearer JWT
- auth_headers: {"Authorization": f"Bearer ..."}
"""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import cast, Literal
from urllib.parse import parse_qsl, urlsplit
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from starlette.requests import Request
# 确保项目根在 sys.path
_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
@dataclass
class RouteResponse:
status_code: int
headers: dict[str, str]
text: str
_json: object | None = None
def json(self):
if self._json is None:
raise ValueError("response does not contain json payload")
return self._json
class RouteClient:
"""基于直接路由调用的轻量测试 client绕开当前环境中的 TestClient 挂起。"""
def __init__(self, app):
self.app = app
self._started = False
def __enter__(self):
if not self._started:
from admin.app import _setup_database, _validate_and_log_settings
settings = self.app.state.settings
_validate_and_log_settings(settings)
_setup_database(settings)
self._started = True
return self
def __exit__(self, exc_type, exc, tb):
return False
@staticmethod
def _request(
path: str, *, method: str = "GET", headers: dict[str, str] | None = None
):
split = urlsplit(path)
raw_headers = [
(key.lower().encode("utf-8"), value.encode("utf-8"))
for key, value in (headers or {}).items()
]
return Request({
"type": "http",
"method": method,
"path": split.path,
"query_string": split.query.encode("utf-8"),
"headers": raw_headers,
})
@staticmethod
def _html_response(response) -> RouteResponse:
return RouteResponse(
status_code=response.status_code,
headers=dict(response.headers),
text=response.body.decode("utf-8"),
)
@staticmethod
def _json_response(
payload: object,
*,
status_code: int = 200,
headers: dict[str, str] | None = None,
) -> RouteResponse:
return RouteResponse(
status_code=status_code,
headers=headers or {"content-type": "application/json"},
text=json.dumps(payload, ensure_ascii=False),
_json=payload,
)
@staticmethod
def _redirect_response(response) -> RouteResponse:
return RouteResponse(
status_code=response.status_code,
headers=dict(response.headers),
text="",
)
@staticmethod
def _http_exception_response(exc: HTTPException) -> RouteResponse:
detail = exc.detail
if isinstance(detail, str):
payload = {"detail": {"reason": detail}}
else:
payload = {"detail": detail}
return RouteClient._json_response(
payload,
status_code=exc.status_code,
)
@staticmethod
def _validation_error_response(exc: ValidationError) -> RouteResponse:
from admin.errors.codes import DATA_VALIDATION_FAILED
from admin.errors.registry import get_message
msg = get_message(str(DATA_VALIDATION_FAILED))
payload = {
"code": str(DATA_VALIDATION_FAILED),
"message": msg.message,
"suggestion": msg.suggestion,
"severity": msg.severity,
"retryable": msg.retryable,
"detail": {
"fields": [
{
"field": "body." + ".".join(str(part) for part in err["loc"]),
"reason": err["msg"],
}
for err in exc.errors()
]
},
}
return RouteClient._json_response(payload, status_code=422)
def get(self, path: str, **kwargs):
from admin.routes.web_public import (
ServiceVersion,
checkout_page,
deletion_policy_page,
order_info_page,
order_status_page,
payment_return_page,
payment_success_page,
pricing_page,
privacy_page,
report_pdf_download,
report_view_page,
review_start_page,
service_terms_page,
)
split = urlsplit(path)
route_path = split.path
settings = self.app.state.settings
try:
if route_path == "/":
request = self._request(path, method="GET")
from admin.routes.web_public import landing_page
return self._html_response(landing_page(request, settings))
if route_path == "/pricing":
request = self._request(path, method="GET")
return self._html_response(pricing_page(request))
if route_path.startswith("/checkout/"):
service_version = cast(
ServiceVersion, route_path.split("/checkout/", 1)[1]
)
return self._html_response(checkout_page(service_version))
if route_path == "/portal/payment-return":
query = dict(parse_qsl(split.query, keep_blank_values=True))
return self._redirect_response(
payment_return_page(query["payment_id"], settings)
)
if route_path == "/review/start":
query = dict(parse_qsl(split.query, keep_blank_values=True))
return self._html_response(
review_start_page(
cast(
Literal["home", "status", "report", "direct"],
query.get("source") or "direct",
),
query.get("token"),
query.get("province"),
query.get("score"),
query.get("goal"),
query.get("consult"),
settings,
)
)
if route_path.startswith("/portal/") and route_path.endswith(
"/payment-success"
):
token = route_path.split("/portal/", 1)[1].rsplit(
"/payment-success", 1
)[0]
return self._html_response(payment_success_page(token, settings))
if route_path.startswith("/portal/") and route_path.endswith("/status"):
token = route_path.split("/portal/", 1)[1].rsplit("/status", 1)[0]
return self._html_response(order_status_page(token, settings))
if route_path.startswith("/portal/") and route_path.endswith("/info"):
token = route_path.split("/portal/", 1)[1].rsplit("/info", 1)[0]
return self._html_response(order_info_page(token, settings))
if route_path.startswith("/portal/") and route_path.endswith("/cwb"):
token = route_path.split("/portal/", 1)[1].rsplit("/cwb", 1)[0]
from admin.routes.web_public import cwb_placeholder_page
return self._html_response(cwb_placeholder_page(token, settings))
if route_path.startswith("/portal/") and route_path.endswith("/full-plan"):
token = route_path.split("/portal/", 1)[1].rsplit("/full-plan", 1)[0]
from admin.routes.web_public import full_plan_placeholder_page
return self._html_response(full_plan_placeholder_page(token, settings))
if route_path.startswith("/portal/") and route_path.endswith("/report.pdf"):
token = route_path.split("/portal/", 1)[1].rsplit("/report.pdf", 1)[0]
response = report_pdf_download(token, settings)
return RouteResponse(
status_code=response.status_code,
headers=dict(response.headers),
text="",
)
if route_path.startswith("/portal/") and route_path.endswith("/report"):
token = route_path.split("/portal/", 1)[1].rsplit("/report", 1)[0]
return self._html_response(report_view_page(token, settings))
if route_path == "/privacy":
query = dict(parse_qsl(split.query, keep_blank_values=True))
return self._html_response(privacy_page(query.get("token")))
if route_path == "/service-terms":
query = dict(parse_qsl(split.query, keep_blank_values=True))
return self._html_response(service_terms_page(query.get("token")))
if route_path == "/policy-center":
query = dict(parse_qsl(split.query, keep_blank_values=True))
from admin.routes.web_public import policy_center_page
return self._html_response(
policy_center_page(query.get("province") or "湖南")
)
if route_path == "/same-score-reference":
query = dict(parse_qsl(split.query, keep_blank_values=True))
from admin.routes.web_public import same_score_reference_page
score = int(query.get("score") or 0)
return self._html_response(
same_score_reference_page(query.get("province") or "湖南", score)
)
if route_path == "/deletion-policy":
return self._html_response(deletion_policy_page())
except HTTPException as exc:
return self._http_exception_response(exc)
raise NotImplementedError(f"unsupported GET path in RouteClient: {path}")
def post(self, path: str, **kwargs):
from admin.routes.web_public import (
complete_mock_payment,
create_public_order_endpoint,
mock_payment_webhook,
review_action_endpoint,
)
from data.orders.public_flow import PublicOrderCreate
split = urlsplit(path)
route_path = split.path
settings = self.app.state.settings
payload = kwargs.get("json")
form_data = kwargs.get("data") or {}
try:
if route_path == "/api/public/orders":
try:
model = PublicOrderCreate.model_validate(payload or {})
except ValidationError as exc:
return self._validation_error_response(exc)
created = create_public_order_endpoint(model, settings)
return self._json_response(created.model_dump(), status_code=201)
if route_path.startswith("/pay/mock/") and route_path.endswith("/complete"):
payment_id = route_path.split("/pay/mock/", 1)[1].rsplit(
"/complete", 1
)[0]
redirect = complete_mock_payment(payment_id, settings)
return self._redirect_response(redirect)
if route_path == "/api/public/payments/mock/webhook":
request = self._request(
path, method="POST", headers=kwargs.get("headers")
)
ack = mock_payment_webhook(payload or {}, request, settings)
return self._json_response(ack.model_dump())
if route_path == "/review/action":
result = review_action_endpoint(
token=str(
(payload or {}).get("token") or form_data.get("token") or ""
),
action=cast(
Literal["cwb", "step1", "full_plan"],
str(
(payload or {}).get("action")
or form_data.get("action")
or ""
),
),
settings=settings,
)
return self._redirect_response(result)
except HTTPException as exc:
return self._http_exception_response(exc)
raise NotImplementedError(f"unsupported POST path in RouteClient: {path}")
@pytest.fixture
def secure_secret() -> str:
"""64-char 安全 JWT 密钥。"""
return "x" * 64 # 确定性,便于测试断言
@pytest.fixture
def settings(tmp_path, secure_secret, monkeypatch):
"""隔离的 Settings 实例:tmp_path SQLite + 安全密钥 + 短过期。
用真实文件而不是 :memory: 是因为 admin/db.py 中每次 get_connection
都新建连接,:memory: 在 SQLite 下不共享状态。
"""
db_path = str(tmp_path / "admin.db")
orders_db_path = str(tmp_path / "orders.db")
share_db_path = str(tmp_path / "short_links.db")
share_report_dir = str(tmp_path / "share_reports")
portal_upload_dir = str(tmp_path / "portal_uploads")
ops_alert_log = str(tmp_path / "ops-alerts.jsonl")
deletion_request_log = str(tmp_path / "deletion-requests.jsonl")
monkeypatch.setenv("GAOKAO_ENV", "dev")
monkeypatch.setenv("GAOKAO_DB_PATH", db_path)
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", orders_db_path)
monkeypatch.setenv("GAOKAO_SHARE_DB_PATH", share_db_path)
monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", share_report_dir)
monkeypatch.setenv("GAOKAO_PORTAL_UPLOAD_DIR", portal_upload_dir)
monkeypatch.setenv("GAOKAO_PORTAL_UPLOAD_MAX_BYTES", "5242880")
monkeypatch.setenv("GAOKAO_PORTAL_UPLOAD_MAX_FILES", "5")
monkeypatch.setenv("GAOKAO_OPS_ALERT_LOG", ops_alert_log)
monkeypatch.setenv("GAOKAO_ALERT_RECIPIENTS", "")
monkeypatch.setenv("GAOKAO_ALERT_WEBHOOK_URLS", "")
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-web-self-service")
monkeypatch.setenv("GAOKAO_JWT_SECRET", secure_secret)
monkeypatch.setenv("GAOKAO_LLM_PROVIDER", "dashscope")
monkeypatch.setenv("GAOKAO_LLM_API_KEY", "sk-test")
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
monkeypatch.setenv("GAOKAO_OPS_ALERT_LOG", ops_alert_log)
monkeypatch.setenv("GAOKAO_DELETION_REQUEST_LOG", deletion_request_log)
from admin.config import load_settings
return load_settings()
@pytest.fixture
def orders_db(settings):
"""T6.2 起:管理后台统计端点会读 orders 表,因此 conftest 顺带建一个
空 orders DB (T4.1 schema),后续 dashboard 测试可在里面塞 fixture 数据。
T-pending-intake 起: 同时建 order_intakes 表,确保 summary.pending_missing_intake
的 LEFT JOIN 不会因为缺表而失败。
"""
from data.orders.intake_store import SCHEMA_SQL as INTAKE_SCHEMA_SQL
from data.orders.schema import apply_schema
conn = apply_schema(settings.orders_db_path)
conn.executescript(INTAKE_SCHEMA_SQL)
conn.commit()
conn.close()
return settings.orders_db_path
@pytest.fixture(autouse=True)
def _auto_orders_db(settings, orders_db):
"""所有测试都默认有空 orders DB 可用,避免 T6.2 端点在不相关测试里
'no such table: orders'。``orders_db`` 显式依赖确保已建。
"""
return orders_db
@pytest.fixture(autouse=True)
def _reset_login_rate_limit():
from admin.routes.auth import reset_login_rate_limit_for_tests
reset_login_rate_limit_for_tests()
yield
reset_login_rate_limit_for_tests()
@pytest.fixture
def app(settings):
"""FastAPI 实例lifespan 已运行)。"""
from admin.app import create_app
return create_app(settings)
@pytest.fixture
def client(app):
"""httpx TestClient。"""
from fastapi.testclient import TestClient
with TestClient(app) as c:
yield c
@pytest.fixture
def route_client(app):
"""直接调用路由函数的轻量 client。"""
with RouteClient(app) as c:
yield c
@pytest.fixture
def auth_token(client) -> str:
"""登录后返回 Bearer JWT。"""
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-pass-123"},
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
@pytest.fixture
def auth_headers(auth_token) -> dict:
return {"Authorization": f"Bearer {auth_token}"}
@pytest.fixture
def viewer_token(settings):
from admin.auth import encode_token
from admin.db import AdminUserRepo
repo = AdminUserRepo(settings.db_path)
viewer = repo.create("viewer", "viewer-pass-123", role="viewer")
return encode_token(viewer, settings)
@pytest.fixture
def viewer_headers(viewer_token) -> dict:
return {"Authorization": f"Bearer {viewer_token}"}