配置层: - 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
455 lines
17 KiB
Python
455 lines
17 KiB
Python
"""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}"}
|