Files
gaokao-volunteer-system/admin/tests/conftest.py

455 lines
17 KiB
Python
Raw Normal View History

2026-06-13 14:49:58 +08:00
"""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
2026-06-13 14:49:58 +08:00
import sys
from dataclasses import dataclass
2026-06-13 14:49:58 +08:00
from pathlib import Path
from typing import cast, Literal
from urllib.parse import parse_qsl, urlsplit
2026-06-13 14:49:58 +08:00
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from starlette.requests import Request
2026-06-13 14:49:58 +08:00
# 确保项目根在 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}")
2026-06-13 14:49:58 +08:00
@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")
2026-06-13 14:49:58 +08:00
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")
2026-06-13 14:49:58 +08:00
monkeypatch.setenv("GAOKAO_JWT_SECRET", secure_secret)
monkeypatch.setenv("GAOKAO_LLM_PROVIDER", "dashscope")
monkeypatch.setenv("GAOKAO_LLM_API_KEY", "sk-test")
2026-06-13 14:49:58 +08:00
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)
2026-06-13 14:49:58 +08:00
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 数据
feat(stats+dashboard): expand pending_orders into 3 actionable dimensions ## 后端 admin/stats.py - compute_summary 在单条聚合 SQL 中加两个新字段: - pending_overdue_24h: status='pending' AND created_at < (now - 24h) (超时未付,需主动催付) - pending_missing_intake: status='pending' AND (order_intakes 无记录 OR order_intakes.status='draft') (资料待补,需主动跟进) - LEFT JOIN order_intakes AS i ON i.order_id = orders.id - 关键 bug 修复: SQL ? 顺序与参数顺序必须匹配, (overdue_cutoff 在 IN 子句后) 之前误用 (overdue_cutoff, *_REVENUE_STATUSES) 导致 overdue_cutoff datetime 字符串误入 IN 子句, 'completed' 误入 created_at < ? 子句, 收入少算 30%。 已修正为 (*_REVENUE_STATUSES, overdue_cutoff) - 列名全部限定为 orders.status / orders.created_at,避免与 i.status 歧义 ## 前端 admin/static/dashboard.html - KPI 4 待处理订单卡加 .pending-breakdown 区域 - 两个 pill 标签: pending-tag-overdue(红色,超时) + pending-tag-missing(橙色,资料待补) - 仅在对应字段 > 0 时才显示 - CSS .pending-tag[hidden] { display: none; } 确保 hidden 属性生效 ## 前端 admin/static/dashboard.js - renderSummary 新增 pending_overdue_24h / pending_missing_intake 渲染 - 至少一个非零才展开 breakdown 区域 ## 测试 - conftest.py orders_db fixture 同时建 order_intakes 表,避免 LEFT JOIN 缺表 - test_routes_stats_dashboard.py: - test_dashboard_empty_db_shape 锁住 summary 必须含 12 字段 - test_dashboard_summary_pending_orders 更新 revenue 期望 (paid + serving 计入) - 新增 test_dashboard_summary_pending_overdue_24h (2天前 vs 12h前) - 新增 test_dashboard_summary_pending_intake (submitted/draft/无 intake 4 种组合) - test_app.py 加 pending-breakdown 结构断言 - pytest 52 passed ## 真实环境验证 - admin/test-pass-123 登录后: pending=7 overdue=0 missing_intake=7 - 视觉验证: KPI 4 显示 '7' 主值 + '资料待补 7' 橙色 pill '超时' pill 因 overdue=0 自动隐藏 - 数据一致性: by_status.pending=7 与 summary.pending_orders=7 一致
2026-06-16 21:48:18 +08:00
T-pending-intake : 同时建 order_intakes ,确保 summary.pending_missing_intake
LEFT JOIN 不会因为缺表而失败
2026-06-13 14:49:58 +08:00
"""
feat(stats+dashboard): expand pending_orders into 3 actionable dimensions ## 后端 admin/stats.py - compute_summary 在单条聚合 SQL 中加两个新字段: - pending_overdue_24h: status='pending' AND created_at < (now - 24h) (超时未付,需主动催付) - pending_missing_intake: status='pending' AND (order_intakes 无记录 OR order_intakes.status='draft') (资料待补,需主动跟进) - LEFT JOIN order_intakes AS i ON i.order_id = orders.id - 关键 bug 修复: SQL ? 顺序与参数顺序必须匹配, (overdue_cutoff 在 IN 子句后) 之前误用 (overdue_cutoff, *_REVENUE_STATUSES) 导致 overdue_cutoff datetime 字符串误入 IN 子句, 'completed' 误入 created_at < ? 子句, 收入少算 30%。 已修正为 (*_REVENUE_STATUSES, overdue_cutoff) - 列名全部限定为 orders.status / orders.created_at,避免与 i.status 歧义 ## 前端 admin/static/dashboard.html - KPI 4 待处理订单卡加 .pending-breakdown 区域 - 两个 pill 标签: pending-tag-overdue(红色,超时) + pending-tag-missing(橙色,资料待补) - 仅在对应字段 > 0 时才显示 - CSS .pending-tag[hidden] { display: none; } 确保 hidden 属性生效 ## 前端 admin/static/dashboard.js - renderSummary 新增 pending_overdue_24h / pending_missing_intake 渲染 - 至少一个非零才展开 breakdown 区域 ## 测试 - conftest.py orders_db fixture 同时建 order_intakes 表,避免 LEFT JOIN 缺表 - test_routes_stats_dashboard.py: - test_dashboard_empty_db_shape 锁住 summary 必须含 12 字段 - test_dashboard_summary_pending_orders 更新 revenue 期望 (paid + serving 计入) - 新增 test_dashboard_summary_pending_overdue_24h (2天前 vs 12h前) - 新增 test_dashboard_summary_pending_intake (submitted/draft/无 intake 4 种组合) - test_app.py 加 pending-breakdown 结构断言 - pytest 52 passed ## 真实环境验证 - admin/test-pass-123 登录后: pending=7 overdue=0 missing_intake=7 - 视觉验证: KPI 4 显示 '7' 主值 + '资料待补 7' 橙色 pill '超时' pill 因 overdue=0 自动隐藏 - 数据一致性: by_status.pending=7 与 summary.pending_orders=7 一致
2026-06-16 21:48:18 +08:00
from data.orders.intake_store import SCHEMA_SQL as INTAKE_SCHEMA_SQL
2026-06-13 14:49:58 +08:00
from data.orders.schema import apply_schema
conn = apply_schema(settings.orders_db_path)
feat(stats+dashboard): expand pending_orders into 3 actionable dimensions ## 后端 admin/stats.py - compute_summary 在单条聚合 SQL 中加两个新字段: - pending_overdue_24h: status='pending' AND created_at < (now - 24h) (超时未付,需主动催付) - pending_missing_intake: status='pending' AND (order_intakes 无记录 OR order_intakes.status='draft') (资料待补,需主动跟进) - LEFT JOIN order_intakes AS i ON i.order_id = orders.id - 关键 bug 修复: SQL ? 顺序与参数顺序必须匹配, (overdue_cutoff 在 IN 子句后) 之前误用 (overdue_cutoff, *_REVENUE_STATUSES) 导致 overdue_cutoff datetime 字符串误入 IN 子句, 'completed' 误入 created_at < ? 子句, 收入少算 30%。 已修正为 (*_REVENUE_STATUSES, overdue_cutoff) - 列名全部限定为 orders.status / orders.created_at,避免与 i.status 歧义 ## 前端 admin/static/dashboard.html - KPI 4 待处理订单卡加 .pending-breakdown 区域 - 两个 pill 标签: pending-tag-overdue(红色,超时) + pending-tag-missing(橙色,资料待补) - 仅在对应字段 > 0 时才显示 - CSS .pending-tag[hidden] { display: none; } 确保 hidden 属性生效 ## 前端 admin/static/dashboard.js - renderSummary 新增 pending_overdue_24h / pending_missing_intake 渲染 - 至少一个非零才展开 breakdown 区域 ## 测试 - conftest.py orders_db fixture 同时建 order_intakes 表,避免 LEFT JOIN 缺表 - test_routes_stats_dashboard.py: - test_dashboard_empty_db_shape 锁住 summary 必须含 12 字段 - test_dashboard_summary_pending_orders 更新 revenue 期望 (paid + serving 计入) - 新增 test_dashboard_summary_pending_overdue_24h (2天前 vs 12h前) - 新增 test_dashboard_summary_pending_intake (submitted/draft/无 intake 4 种组合) - test_app.py 加 pending-breakdown 结构断言 - pytest 52 passed ## 真实环境验证 - admin/test-pass-123 登录后: pending=7 overdue=0 missing_intake=7 - 视觉验证: KPI 4 显示 '7' 主值 + '资料待补 7' 橙色 pill '超时' pill 因 overdue=0 自动隐藏 - 数据一致性: by_status.pending=7 与 summary.pending_orders=7 一致
2026-06-16 21:48:18 +08:00
conn.executescript(INTAKE_SCHEMA_SQL)
conn.commit()
2026-06-13 14:49:58 +08:00
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
2026-06-13 14:49:58 +08:00
@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}"}