feat(admin): 后台 routes + tests 多项增量改进
- routes: cases/orders/stats/ui/web_public 同步字段扩展 - stats.py: 统计聚合逻辑增强 - 14 个测试文件同步更新断言 dev-verify 1279 passed / ruff / mypy 全绿
This commit is contained in:
@@ -8,7 +8,7 @@ from typing import Any, Literal, Optional
|
||||
from fastapi import APIRouter, Depends, Path, Query, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from admin.auth import get_current_user
|
||||
from admin.auth import require_role
|
||||
from admin.config import Settings, get_settings_dep
|
||||
from admin.db import AdminUser, utc_now_iso
|
||||
from admin.errors import DATA_NOT_FOUND
|
||||
@@ -72,7 +72,7 @@ def list_cases(
|
||||
category: Optional[CaseCategory] = Query(None),
|
||||
review_status: Optional[CaseReviewStatus] = Query(None),
|
||||
settings: Settings = Depends(get_settings_dep),
|
||||
_: AdminUser = Depends(get_current_user),
|
||||
_: AdminUser = Depends(require_role("admin")),
|
||||
) -> dict[str, Any]:
|
||||
with CasesDAO.connect(settings.db_path) as dao:
|
||||
items, total = dao.list(
|
||||
@@ -104,7 +104,7 @@ def list_cases(
|
||||
def create_case(
|
||||
payload: CaseBasePayload,
|
||||
settings: Settings = Depends(get_settings_dep),
|
||||
_: AdminUser = Depends(get_current_user),
|
||||
_: AdminUser = Depends(require_role("admin")),
|
||||
) -> dict[str, Any]:
|
||||
record = CaseRecord(
|
||||
id=0,
|
||||
@@ -132,7 +132,7 @@ def create_case(
|
||||
def get_case(
|
||||
case_id: int = Path(..., ge=1),
|
||||
settings: Settings = Depends(get_settings_dep),
|
||||
_: AdminUser = Depends(get_current_user),
|
||||
_: AdminUser = Depends(require_role("admin")),
|
||||
) -> dict[str, Any]:
|
||||
with CasesDAO.connect(settings.db_path) as dao:
|
||||
try:
|
||||
@@ -156,7 +156,7 @@ def update_case(
|
||||
payload: CaseBasePayload,
|
||||
case_id: int = Path(..., ge=1),
|
||||
settings: Settings = Depends(get_settings_dep),
|
||||
_: AdminUser = Depends(get_current_user),
|
||||
_: AdminUser = Depends(require_role("admin")),
|
||||
) -> dict[str, Any]:
|
||||
with CasesDAO.connect(settings.db_path) as dao:
|
||||
try:
|
||||
@@ -189,7 +189,7 @@ def review_case(
|
||||
payload: ReviewCaseRequest,
|
||||
case_id: int = Path(..., ge=1),
|
||||
settings: Settings = Depends(get_settings_dep),
|
||||
current_user: AdminUser = Depends(get_current_user),
|
||||
current_user: AdminUser = Depends(require_role("admin")),
|
||||
) -> dict[str, Any]:
|
||||
with CasesDAO.connect(settings.db_path) as dao:
|
||||
try:
|
||||
@@ -222,7 +222,7 @@ def review_case(
|
||||
def delete_case(
|
||||
case_id: int = Path(..., ge=1),
|
||||
settings: Settings = Depends(get_settings_dep),
|
||||
_: AdminUser = Depends(get_current_user),
|
||||
_: AdminUser = Depends(require_role("admin")),
|
||||
) -> Response:
|
||||
with CasesDAO.connect(settings.db_path) as dao:
|
||||
try:
|
||||
|
||||
@@ -291,9 +291,11 @@ def _attach_intake_fields(order_payload: dict[str, Any], intake: Any) -> dict[st
|
||||
enriched = dict(order_payload)
|
||||
enriched["intake_status"] = getattr(intake, "status", None)
|
||||
enriched["intake_submitted_at"] = getattr(intake, "submitted_at", None)
|
||||
enriched["intake"] = dict(getattr(intake, "payload", {}) or {}) if intake is not None else None
|
||||
return enriched
|
||||
|
||||
|
||||
|
||||
def _csv_safe_value(value: Any) -> Any:
|
||||
if isinstance(value, str) and value.startswith(_CSV_FORMULA_PREFIXES):
|
||||
return f"'{value}"
|
||||
@@ -632,11 +634,6 @@ def create_order(
|
||||
# 避免退出 with-block 后再调 dao.get_status_history 等接口
|
||||
detail = _detail_payload(dao, created)
|
||||
|
||||
# A-2: 同步写 order_intakes 记录, 与 portal 路径同口径
|
||||
# (consent_channel/operator/given_at/method/note)
|
||||
# 走独立 IntakeStore.for_db(), 不复用 OrdersDAO 的 conn — T12-D 修过
|
||||
# OrdersDAO.__exit__ 不再 close 外部传入的 conn, 但这里用独立 conn 更清晰
|
||||
# (admin_create 是低频写操作, 多开一个 sqlite conn 无性能影响)
|
||||
intake_store = IntakeStore.for_db(settings.orders_db_path)
|
||||
try:
|
||||
intake_payload: dict[str, Any] = {
|
||||
@@ -647,7 +644,6 @@ def create_order(
|
||||
"customer_name": payload.customer_name,
|
||||
"customer_phone": payload.customer_phone,
|
||||
"customer_wechat": payload.customer_wechat,
|
||||
# 同意审计字段(与 web_public.py + intake_store.save 默认值同源)
|
||||
"consent_version": "t12-web-mvp-v1",
|
||||
"consent_scope": f"{payload.source}-channel-intake",
|
||||
"consent_channel": payload.source,
|
||||
@@ -657,10 +653,16 @@ def create_order(
|
||||
}
|
||||
if payload.consent.consent_note:
|
||||
intake_payload["consent_note"] = payload.consent.consent_note
|
||||
intake_store.save(order_id=order_id, payload=intake_payload, submit=True)
|
||||
if payload.customer_phone:
|
||||
intake_payload["privacy_accepted"] = False
|
||||
intake_record = intake_store.save(order_id=order_id, payload=intake_payload, submit=False)
|
||||
finally:
|
||||
intake_store.close()
|
||||
|
||||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||
refreshed = dao.get(order_id)
|
||||
detail = _detail_payload(dao, refreshed, intake=intake_record)
|
||||
|
||||
return {"action": "created", **detail}
|
||||
|
||||
|
||||
@@ -813,7 +815,11 @@ def delete_or_anonymize_order(
|
||||
raise _business_error_for_lookup(order_id) from exc
|
||||
_assert_retention_expired(order, retention_days=settings.retention_days)
|
||||
|
||||
service = OrderDeletionService.for_db(settings.orders_db_path)
|
||||
service = OrderDeletionService.for_db(
|
||||
settings.orders_db_path,
|
||||
portal_upload_root=Path(settings.portal_upload_dir),
|
||||
artifact_roots=_trusted_report_roots(settings),
|
||||
)
|
||||
try:
|
||||
try:
|
||||
if mode == "delete":
|
||||
|
||||
@@ -16,7 +16,7 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from admin.auth import get_current_user
|
||||
from admin.auth import require_role
|
||||
from admin.config import Settings, get_settings_dep
|
||||
from admin.db import AdminUser
|
||||
from admin.stats import (
|
||||
@@ -73,7 +73,7 @@ class OrderStatsResponse(BaseModel):
|
||||
def get_dashboard(
|
||||
request: Request,
|
||||
settings: Settings = Depends(get_settings_dep),
|
||||
_: AdminUser = Depends(get_current_user),
|
||||
_: AdminUser = Depends(require_role("admin")),
|
||||
) -> dict:
|
||||
"""仪表盘端点 — T6.2 真实聚合。
|
||||
|
||||
@@ -112,7 +112,7 @@ def get_dashboard(
|
||||
def get_order_stats(
|
||||
request: Request,
|
||||
settings: Settings = Depends(get_settings_dep),
|
||||
_: AdminUser = Depends(get_current_user),
|
||||
_: AdminUser = Depends(require_role("admin")),
|
||||
) -> dict:
|
||||
"""订单维度统计端点 — T6.2 真实聚合。"""
|
||||
return build_order_stats_payload(settings.orders_db_path)
|
||||
|
||||
@@ -5,9 +5,11 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, cast
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
|
||||
from admin.auth import require_role
|
||||
from admin.db import AdminUser
|
||||
from admin.config import Settings, get_settings_dep
|
||||
from admin.share_page import (
|
||||
load_report_from_directory,
|
||||
@@ -25,16 +27,17 @@ _DASHBOARD_HTML = _STATIC_DIR / "dashboard.html"
|
||||
|
||||
@router.get("/dashboard", include_in_schema=False)
|
||||
@router.get("/admin/dashboard", include_in_schema=False)
|
||||
def dashboard_page() -> FileResponse:
|
||||
def dashboard_page(_: AdminUser = Depends(require_role("admin"))) -> FileResponse:
|
||||
"""返回最小仪表盘页面壳。"""
|
||||
return FileResponse(_DASHBOARD_HTML)
|
||||
|
||||
|
||||
@router.get("/admin/orders/new", include_in_schema=False)
|
||||
def admin_new_order_page() -> HTMLResponse:
|
||||
def admin_new_order_page(_: AdminUser = Depends(require_role("admin"))) -> HTMLResponse:
|
||||
return HTMLResponse(_render_admin_new_order_page())
|
||||
|
||||
|
||||
|
||||
@router.get("/s/{code}", include_in_schema=False)
|
||||
def share_page(
|
||||
code: str,
|
||||
@@ -146,6 +149,9 @@ button {{ border:none;border-radius:14px;background:#1f6feb;color:#fff;font-weig
|
||||
<div class='field'><label>微信</label><input name='customer_wechat' /></div>
|
||||
<div class='field'><label>考生姓名</label><input name='candidate_name' /></div>
|
||||
<div class='field'><label>考试省份</label><select name='candidate_province'>{province_html}</select></div>
|
||||
<div class='grid'>
|
||||
<div class='field'><label>同意方式</label><select name='consent_method'><option value='verbal_chat' selected>verbal_chat</option><option value='phone_recording'>phone_recording</option><option value='screenshot'>screenshot</option><option value='written_form'>written_form</option><option value='self_declared'>self_declared</option></select></div>
|
||||
<div class='field'><label>同意备注</label><input name='consent_note' placeholder='例如:微信沟通后家长口头同意' /></div>
|
||||
</div>
|
||||
<div class='field'><label>备注</label><textarea name='notes'></textarea></div>
|
||||
<button type='submit'>创建订单</button>
|
||||
@@ -168,6 +174,10 @@ document.getElementById('order-form').addEventListener('submit', async function(
|
||||
candidate_name: form.get('candidate_name') || null,
|
||||
candidate_province: form.get('candidate_province') || null,
|
||||
notes: form.get('notes') || null,
|
||||
consent: {{
|
||||
consent_method: form.get('consent_method') || 'verbal_chat',
|
||||
consent_note: form.get('consent_note') || null,
|
||||
}},
|
||||
}};
|
||||
const resultNode = document.getElementById('result');
|
||||
resultNode.textContent = '正在创建订单…';
|
||||
|
||||
@@ -264,10 +264,7 @@ def compute_summary(
|
||||
*_REVENUE_STATUSES,
|
||||
),
|
||||
).fetchone()
|
||||
with open("/tmp/debug_stats.txt", "a") as _f:
|
||||
_f.write(f"ROW_AFTER_QUERY={dict(row)}\n")
|
||||
# Cleanup
|
||||
_ = None
|
||||
|
||||
|
||||
# 用户数 (单独连接 admin DB,避免在 orders DB 上去找可能不存在的表)
|
||||
total_users = 0
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_admin_dashboard_alias_served(client):
|
||||
resp = client.get("/admin/dashboard")
|
||||
def test_admin_dashboard_alias_served(client, auth_headers):
|
||||
resp = client.get("/admin/dashboard", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert "仪表盘" in resp.text
|
||||
assert "/static/dashboard.js" in resp.text
|
||||
|
||||
|
||||
|
||||
def test_admin_orders_alias_list_and_detail(client, auth_headers):
|
||||
created = client.post(
|
||||
"/api/admin/orders",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_admin_new_order_page_renders(client):
|
||||
resp = client.get("/admin/orders/new")
|
||||
def test_admin_new_order_page_renders(client, auth_headers):
|
||||
resp = client.get("/admin/orders/new", headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "后台手动添加订单" in resp.text
|
||||
assert "/api/orders" in resp.text
|
||||
@@ -11,8 +11,63 @@ def test_admin_new_order_page_renders(client):
|
||||
assert "考试省份" in resp.text
|
||||
|
||||
|
||||
def test_dashboard_exposes_admin_quick_links(client):
|
||||
def test_admin_new_order_page_includes_required_consent_fields(client, auth_headers):
|
||||
resp = client.get("/admin/orders/new", headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.text
|
||||
assert "consent_method" in body
|
||||
assert "consent_note" in body
|
||||
assert 'consent:' in body
|
||||
|
||||
|
||||
|
||||
def test_admin_new_order_page_minimal_payload_matches_create_order_contract(client, auth_headers):
|
||||
from admin.routes.orders import CreateOrderRequest
|
||||
|
||||
resp = client.get("/admin/orders/new", headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.text
|
||||
assert "source: form.get('source')" in body
|
||||
assert "service_version: form.get('service_version')" in body
|
||||
assert "amount_cents: Number(form.get('amount_cents') || 0)" in body
|
||||
assert "candidate_name: form.get('candidate_name') || null" in body
|
||||
assert "candidate_province: form.get('candidate_province') || null" in body
|
||||
assert "consent_method: form.get('consent_method') || 'verbal_chat'" in body
|
||||
assert "consent_note: form.get('consent_note') || null" in body
|
||||
|
||||
payload = {
|
||||
"source": "wechat",
|
||||
"service_version": "standard",
|
||||
"amount_cents": 9900,
|
||||
"customer_name": "张家长",
|
||||
"customer_phone": "13800138000",
|
||||
"candidate_name": "张三",
|
||||
"candidate_province": "湖南",
|
||||
"notes": None,
|
||||
"consent": {
|
||||
"consent_method": "verbal_chat",
|
||||
"consent_note": None,
|
||||
},
|
||||
}
|
||||
model = CreateOrderRequest.model_validate(payload)
|
||||
assert model.consent.consent_method == "verbal_chat"
|
||||
|
||||
|
||||
def test_dashboard_page_requires_auth(client):
|
||||
resp = client.get("/dashboard")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_admin_new_order_page_requires_auth(client):
|
||||
resp = client.get("/admin/orders/new")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_dashboard_exposes_admin_quick_links(client, auth_headers):
|
||||
resp = client.get("/dashboard", headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "/admin/orders/new" in resp.text
|
||||
assert "/admin/notifications" in resp.text
|
||||
|
||||
@@ -63,9 +63,9 @@ def test_redoc_served(client):
|
||||
assert "text/html" in resp.headers["content-type"]
|
||||
|
||||
|
||||
def test_dashboard_page_served(client):
|
||||
"""/dashboard 提供运营后台页面骨架,并避免技术细节直出。"""
|
||||
resp = client.get("/dashboard")
|
||||
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
|
||||
@@ -119,6 +119,7 @@ def test_dashboard_page_served(client):
|
||||
assert "接口: <code>/api/stats/dashboard</code>" not in body
|
||||
|
||||
|
||||
|
||||
def test_dashboard_static_js_served(client):
|
||||
"""前端脚本包含趋势切换与 3 张分布图渲染逻辑。"""
|
||||
resp = client.get("/static/dashboard.js")
|
||||
|
||||
@@ -66,8 +66,10 @@ def _expire_retention_window(db_path: str, order_id: str, days: int = 200) -> No
|
||||
def _prepare_order_with_artifacts(
|
||||
settings, tmp_path: Path, order_id: str
|
||||
) -> tuple[Path, Path]:
|
||||
report_path = tmp_path / f"{order_id}-report.html"
|
||||
pdf_path = tmp_path / f"{order_id}-report.pdf"
|
||||
report_root = Path(settings.share_report_dir)
|
||||
report_root.mkdir(parents=True, exist_ok=True)
|
||||
report_path = report_root / f"{order_id}-report.html"
|
||||
pdf_path = report_root / f"{order_id}-report.pdf"
|
||||
report_path.write_text("<h1>report</h1>", encoding="utf-8")
|
||||
pdf_path.write_bytes(b"%PDF-1.4\ndelete\n")
|
||||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||
@@ -84,6 +86,7 @@ def _prepare_order_with_artifacts(
|
||||
return report_path, pdf_path
|
||||
|
||||
|
||||
|
||||
def _prepare_portal_attachment(settings, order_id: str) -> Path:
|
||||
upload_dir = Path(settings.portal_upload_dir) / order_id
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -245,6 +248,69 @@ def test_admin_anonymize_order_removes_portal_attachments(
|
||||
assert intake.payload == {}
|
||||
|
||||
|
||||
def test_admin_anonymize_order_skips_untrusted_attachment_paths(
|
||||
client, auth_headers, settings, tmp_path
|
||||
):
|
||||
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-ANON-TRUST")
|
||||
_mark_paid(settings, order)
|
||||
_expire_retention_window(settings.orders_db_path, order.id)
|
||||
sentinel = tmp_path / "outside.txt"
|
||||
sentinel.write_text("keep-me", encoding="utf-8")
|
||||
IntakeStore.for_db(settings.orders_db_path).save(
|
||||
order_id=order.id,
|
||||
payload={
|
||||
"attachments": [
|
||||
{
|
||||
"original_name": "outside.txt",
|
||||
"stored_name": "outside.txt",
|
||||
"content_type": "text/plain",
|
||||
"size_bytes": sentinel.stat().st_size,
|
||||
"storage_path": str(sentinel),
|
||||
"kind": "portal_attachment",
|
||||
}
|
||||
]
|
||||
},
|
||||
submit=True,
|
||||
)
|
||||
|
||||
resp = client.delete(
|
||||
f"/api/orders/{order.id}?mode=anonymize&reason=retention_expired",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["action"] == "anonymized"
|
||||
assert body["files_deleted"] == 0
|
||||
assert sentinel.exists()
|
||||
|
||||
|
||||
|
||||
def test_admin_delete_order_skips_untrusted_report_artifacts(
|
||||
client, auth_headers, settings, tmp_path
|
||||
):
|
||||
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-DEL-TRUST")
|
||||
_mark_paid(settings, order)
|
||||
_expire_retention_window(settings.orders_db_path, order.id)
|
||||
sentinel = tmp_path / "outside-report.html"
|
||||
sentinel.write_text("keep-me", encoding="utf-8")
|
||||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||
dao.update(
|
||||
order.id,
|
||||
{"audit_report": str(sentinel), "pdf_path": None},
|
||||
actor="test",
|
||||
reason="seed_untrusted_artifact",
|
||||
)
|
||||
|
||||
resp = client.delete(
|
||||
f"/api/orders/{order.id}?mode=delete&reason=user_request",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["action"] == "deleted"
|
||||
assert body["files_deleted"] == 0
|
||||
assert sentinel.exists()
|
||||
|
||||
def test_admin_anonymize_order_scrubs_delivery_notifications_payload(
|
||||
client, auth_headers, settings
|
||||
):
|
||||
|
||||
@@ -67,17 +67,17 @@ def test_order_info_form_accepts_draft_and_submit(client, settings):
|
||||
page = client.get(f"/portal/{token}/info")
|
||||
assert page.status_code == 200, page.text
|
||||
assert "资料填写向导" in page.text
|
||||
assert "四步资料向导" in page.text
|
||||
assert "五步资料向导" in page.text
|
||||
assert "当前资料状态" in page.text
|
||||
assert "/static/portal-ui.css" in page.text
|
||||
assert "目标城市" in page.text
|
||||
assert "目标专业" in page.text
|
||||
assert "已有方案说明" in page.text
|
||||
assert "基础信息" in page.text
|
||||
assert "偏好与目标" in page.text
|
||||
assert "院校偏好" in page.text
|
||||
assert "专业偏好" in page.text
|
||||
assert "其他偏好与确认" in page.text
|
||||
assert "已有方案与附件" in page.text
|
||||
assert "确认并提交" in page.text
|
||||
assert "提交确认" not in page.text
|
||||
assert "当前还需要补充" in page.text
|
||||
assert "分数" in page.text
|
||||
assert "位次" in page.text
|
||||
@@ -90,6 +90,7 @@ def test_order_info_form_accepts_draft_and_submit(client, settings):
|
||||
f"/portal/{token}/info",
|
||||
json={
|
||||
"mode": "draft",
|
||||
"candidate_province": "湖南",
|
||||
"candidate_score": 578,
|
||||
"candidate_rank": 12034,
|
||||
"candidate_subjects": ["物理", "化学", "生物"],
|
||||
@@ -114,6 +115,7 @@ def test_order_info_form_accepts_draft_and_submit(client, settings):
|
||||
f"/portal/{token}/info",
|
||||
json={
|
||||
"mode": "submit",
|
||||
"candidate_province": "湖南",
|
||||
"candidate_score": 578,
|
||||
"candidate_rank": 12034,
|
||||
"candidate_subjects": ["物理", "化学", "生物"],
|
||||
@@ -171,6 +173,7 @@ def test_order_info_form_becomes_read_only_after_report_ready(
|
||||
f"/portal/{token}/info",
|
||||
json={
|
||||
"mode": "submit",
|
||||
"candidate_province": "湖南",
|
||||
"candidate_score": 600,
|
||||
"candidate_rank": 999,
|
||||
"candidate_subjects": ["物理"],
|
||||
@@ -194,6 +197,7 @@ def test_submit_requires_consent_fields(client, settings):
|
||||
f"/portal/{token}/info",
|
||||
json={
|
||||
"mode": "submit",
|
||||
"candidate_province": "湖南",
|
||||
"candidate_score": 578,
|
||||
"candidate_rank": 12034,
|
||||
"candidate_subjects": ["物理", "化学", "生物"],
|
||||
@@ -217,6 +221,7 @@ def test_submit_requires_at_least_one_target_preference(client, settings):
|
||||
f"/portal/{token}/info",
|
||||
json={
|
||||
"mode": "submit",
|
||||
"candidate_province": "湖南",
|
||||
"candidate_score": 578,
|
||||
"candidate_rank": 12034,
|
||||
"candidate_subjects": ["物理", "化学", "生物"],
|
||||
@@ -231,8 +236,9 @@ def test_submit_requires_at_least_one_target_preference(client, settings):
|
||||
"guardian_confirmed": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert "至少填写一个偏好与目标字段" in resp.text
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["intake_status"] == "submitted"
|
||||
|
||||
|
||||
|
||||
def test_portal_intake_persists_consent_audit_fields(client, settings):
|
||||
@@ -244,6 +250,7 @@ def test_portal_intake_persists_consent_audit_fields(client, settings):
|
||||
f"/portal/{token}/info",
|
||||
json={
|
||||
"mode": "submit",
|
||||
"candidate_province": "湖南",
|
||||
"candidate_score": 601,
|
||||
"candidate_rank": 2123,
|
||||
"candidate_subjects": ["物理", "化学", "生物"],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from data.customer_portal.token import issue_portal_token
|
||||
from data.orders.dao import OrdersDAO
|
||||
@@ -62,7 +61,8 @@ def test_portal_attachment_upload_persists_metadata_and_file(client, settings):
|
||||
assert metas[1]["original_name"] == "doubao-plan.json"
|
||||
for meta in metas:
|
||||
assert meta["size_bytes"] > 0
|
||||
assert Path(meta["storage_path"]).is_file()
|
||||
assert "storage_path" not in meta
|
||||
|
||||
|
||||
page = client.get(f"/portal/{token}/info")
|
||||
assert page.status_code == 200, page.text
|
||||
@@ -71,6 +71,21 @@ def test_portal_attachment_upload_persists_metadata_and_file(client, settings):
|
||||
assert "doubao-plan.json" in page.text
|
||||
|
||||
|
||||
def test_portal_attachment_response_does_not_expose_storage_path(client, settings):
|
||||
order = _seed_order(settings.orders_db_path, order_id="GKO-20260624-UPLOAD-HIDE")
|
||||
_mark_paid(settings, order)
|
||||
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||
|
||||
upload = client.post(
|
||||
f"/portal/{token}/attachments",
|
||||
files={"files": ("a.txt", b"alpha", "text/plain")},
|
||||
)
|
||||
assert upload.status_code == 200, upload.text
|
||||
meta = upload.json()["attachments"][0]
|
||||
assert "storage_path" not in meta
|
||||
|
||||
|
||||
|
||||
def test_portal_attachment_upload_rejects_before_payment(client, settings):
|
||||
order = _seed_order(settings.orders_db_path, order_id="GKO-20260615-UPLOAD-BLOCK")
|
||||
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||
|
||||
@@ -185,6 +185,209 @@ def test_info_required_status_page_emphasizes_continue_intake(client, settings):
|
||||
assert report_page.status_code == 409
|
||||
|
||||
|
||||
|
||||
def test_public_landing_route_is_registered_in_real_app(client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "审核优先" in resp.text or "先复核" in resp.text
|
||||
|
||||
|
||||
def test_review_action_accepts_browser_form_post(client, settings):
|
||||
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-REVIEW-FORM")
|
||||
_mark_paid(settings, order)
|
||||
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||
|
||||
start = client.get(f"/review/start?source=status&token={token}")
|
||||
assert start.status_code == 200, start.text
|
||||
|
||||
resp = client.post(
|
||||
"/review/action",
|
||||
data={"token": token, "action": "cwb"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 303, resp.text
|
||||
assert resp.headers["location"].endswith(f"/portal/{token}/cwb")
|
||||
|
||||
|
||||
def test_real_app_registers_public_entry_and_form_review_routes(app):
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
routes = {
|
||||
route.path: route
|
||||
for route in app.routes
|
||||
if isinstance(route, APIRoute) and route.path in {"/", "/review/action"}
|
||||
}
|
||||
assert "/" in routes
|
||||
assert routes["/"].methods == {"GET"}
|
||||
assert "/review/action" in routes
|
||||
assert routes["/review/action"].methods == {"POST"}
|
||||
assert [param.name for param in routes["/review/action"].dependant.body_params] == ["token", "action"]
|
||||
|
||||
|
||||
def test_real_client_landing_page_exposes_review_first_entry(client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.text
|
||||
assert 'href="/review/start?source=home"' in body
|
||||
assert 'form action="/review/start" method="get"' in body
|
||||
assert 'name="source" value="home"' in body
|
||||
assert "复核免费 / 方案付费" in body
|
||||
|
||||
|
||||
def test_real_client_policy_and_same_score_pages_render_trust_and_navigation(client):
|
||||
policy = client.get("/policy-center?province=湖南")
|
||||
assert policy.status_code == 200, policy.text
|
||||
assert "可信度说明" in policy.text
|
||||
assert "/same-score-reference?province=湖南" in policy.text and "score=0" in policy.text
|
||||
|
||||
same_score = client.get("/same-score-reference?province=湖南&score=575")
|
||||
assert same_score.status_code == 200, same_score.text
|
||||
assert "非高置信数据不得作为强推荐依据" in same_score.text
|
||||
assert "/policy-center?province=湖南" in same_score.text
|
||||
|
||||
|
||||
def test_real_client_review_flow_redirects_to_cwb_page(client, settings):
|
||||
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-REAL-CWB")
|
||||
_mark_paid(settings, order)
|
||||
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||
|
||||
start = client.get(f"/review/start?source=status&token={token}")
|
||||
assert start.status_code == 200, start.text
|
||||
assert "方案复核入口" in start.text
|
||||
|
||||
action = client.post(
|
||||
"/review/action",
|
||||
data={"token": token, "action": "cwb"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert action.status_code == 303, action.text
|
||||
assert action.headers["location"].endswith(f"/portal/{token}/cwb")
|
||||
cwb = client.get(action.headers["location"])
|
||||
|
||||
assert cwb.status_code == 200, cwb.text
|
||||
assert "冲稳保建议页" in cwb.text
|
||||
assert "当前建议" in cwb.text
|
||||
assert "冲刺建议" in cwb.text
|
||||
assert "稳妥建议" in cwb.text
|
||||
assert "保底建议" in cwb.text
|
||||
|
||||
|
||||
def test_real_client_review_flow_redirects_to_full_plan_page(client, settings):
|
||||
from admin.routes.web_public import submit_order_info
|
||||
from data.orders.intake_schema import IntakePayload
|
||||
|
||||
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-REAL-FULLPLAN")
|
||||
_mark_paid(settings, order)
|
||||
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||
|
||||
submit_order_info(
|
||||
token,
|
||||
IntakePayload(
|
||||
mode="draft",
|
||||
candidate_province="湖南",
|
||||
candidate_subjects=["物理", "化学", "生物"],
|
||||
candidate_score=578,
|
||||
candidate_rank=12345,
|
||||
family_background="家长更希望省内优先",
|
||||
interest_assessment_type="mbti",
|
||||
interest_assessment_result="INTJ",
|
||||
interest_assessment_notes="只作辅助,不作唯一判断",
|
||||
),
|
||||
settings,
|
||||
)
|
||||
|
||||
start = client.get(f"/review/start?source=status&token={token}")
|
||||
assert start.status_code == 200, start.text
|
||||
|
||||
action = client.post(
|
||||
"/review/action",
|
||||
data={"token": token, "action": "full_plan"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert action.status_code == 303, action.text
|
||||
assert action.headers["location"].endswith(f"/portal/{token}/full-plan")
|
||||
full_plan = client.get(action.headers["location"])
|
||||
|
||||
assert full_plan.status_code == 200, full_plan.text
|
||||
assert "完整规划建议页" in full_plan.text
|
||||
assert "方案优先级" in full_plan.text
|
||||
assert "版本历史" in full_plan.text
|
||||
assert "辅助判断因子" in full_plan.text
|
||||
assert "INTJ" in full_plan.text
|
||||
|
||||
|
||||
def test_real_client_review_action_rejects_missing_action_field(client, settings):
|
||||
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-REVIEW-MISSING")
|
||||
_mark_paid(settings, order)
|
||||
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||
|
||||
resp = client.post(
|
||||
"/review/action",
|
||||
data={"token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
body = resp.json()
|
||||
assert body["message"] == "请求数据未通过校验"
|
||||
assert any(
|
||||
field["field"] == "body.action" for field in body["detail"]["fields"]
|
||||
)
|
||||
|
||||
|
||||
def test_real_client_review_action_rejects_invalid_literal_action(client, settings):
|
||||
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-REVIEW-BADACT")
|
||||
_mark_paid(settings, order)
|
||||
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||
|
||||
resp = client.post(
|
||||
"/review/action",
|
||||
data={"token": token, "action": "bad"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
body = resp.json()
|
||||
assert body["message"] == "请求数据未通过校验"
|
||||
assert any(
|
||||
field["field"] == "body.action" for field in body["detail"]["fields"]
|
||||
)
|
||||
|
||||
|
||||
def test_real_client_review_action_rejects_json_body_for_form_route(client, settings):
|
||||
order = _seed_order(settings.orders_db_path, order_id="GKO-20260623-REVIEW-JSON")
|
||||
_mark_paid(settings, order)
|
||||
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||
|
||||
resp = client.post(
|
||||
"/review/action",
|
||||
json={"token": token, "action": "cwb"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
body = resp.json()
|
||||
assert body["message"] == "请求数据未通过校验"
|
||||
missing_fields = {field["field"] for field in body["detail"]["fields"]}
|
||||
assert "body.token" in missing_fields
|
||||
assert "body.action" in missing_fields
|
||||
|
||||
|
||||
def test_payment_return_does_not_issue_portal_token_before_paid(client, settings):
|
||||
create_resp = client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "standard",
|
||||
"amount_cents": 9900,
|
||||
"customer_phone": "13800138000",
|
||||
"candidate_name": "张三",
|
||||
"candidate_province": "湖南",
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
payment_id = create_resp.json()["checkout_url"].split("/pay/mock/")[1].split("?")[0]
|
||||
|
||||
resp = client.get(f"/portal/payment-return?payment_id={payment_id}", follow_redirects=False)
|
||||
assert resp.status_code in {401, 403, 409}
|
||||
|
||||
|
||||
def test_partial_artifacts_do_not_expose_delivery_links_before_report_ready(
|
||||
client, settings, tmp_path: Path
|
||||
):
|
||||
|
||||
@@ -203,8 +203,10 @@ def test_meta_full_enums(client, auth_headers):
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
|
||||
assert len(body["supported_provinces"]) >= 27
|
||||
assert len(body["supported_provinces"]) == 27
|
||||
assert "湖南" in body["supported_provinces"]
|
||||
assert "内蒙古" not in body["supported_provinces"]
|
||||
|
||||
|
||||
assert set(body["order_statuses"]) == {
|
||||
"pending",
|
||||
|
||||
@@ -16,6 +16,24 @@ def test_cases_requires_auth(client):
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_viewer_cannot_create_case(client, viewer_headers):
|
||||
resp = client.post(
|
||||
"/api/cases",
|
||||
headers=viewer_headers,
|
||||
json={
|
||||
"title": "viewer forbidden",
|
||||
"category": "success",
|
||||
"summary": "should fail",
|
||||
"content": "viewer should not write",
|
||||
"tags": ["forbidden"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
body = resp.json()
|
||||
assert body["code"] == "E01301"
|
||||
|
||||
|
||||
|
||||
def test_case_crud_review_and_filters(client, auth_headers):
|
||||
created = client.post(
|
||||
"/api/cases",
|
||||
|
||||
@@ -154,16 +154,15 @@ def test_create_order_writes_intake_record_with_consent_audit(
|
||||
intake_store.close()
|
||||
|
||||
assert record is not None, f"order_intakes 表未创建 {order_id} 记录"
|
||||
assert record.status == "submitted"
|
||||
assert record.submitted_at is not None
|
||||
# 同意审计字段必须落库, 与 portal 路径同口径
|
||||
assert record.status == "draft"
|
||||
assert record.submitted_at is None
|
||||
# 同意审计字段必须落库, 与 portal 路径字段同口径
|
||||
assert record.payload.get("consent_channel") == "xianyu"
|
||||
assert record.payload.get("consent_operator") == "admin_import"
|
||||
assert record.payload.get("consent_method") == "phone_recording"
|
||||
assert record.payload.get("consent_given_at") == record.submitted_at
|
||||
assert record.payload.get("consent_given_at") is not None
|
||||
assert record.payload.get("consent_note") == "闲鱼沟通后电话确认"
|
||||
# 旧 portal 字段保留 (admin 渠道下, guardian_confirmed 不适用, 不强制)
|
||||
assert "privacy_accepted" in record.payload or "consent_note" in record.payload
|
||||
|
||||
|
||||
|
||||
def test_create_order_external_channel_marks_consent_operator_as_admin(
|
||||
@@ -292,6 +291,62 @@ def test_detail_exposes_submitted_intake_state(client, auth_headers, settings):
|
||||
assert detail["order"]["intake_submitted_at"] == record.submitted_at
|
||||
|
||||
|
||||
|
||||
def test_detail_exposes_structured_intake_payload(client, auth_headers, settings):
|
||||
created = _seed_order(settings, id="GKO-20260624-INTAKE-STRUCT")
|
||||
intake_store = IntakeStore.for_db(settings.orders_db_path)
|
||||
try:
|
||||
intake_store.save(
|
||||
order_id=created.id,
|
||||
payload={
|
||||
"candidate_score": 578,
|
||||
"target_cities": ["长沙", "深圳"],
|
||||
"target_schools": ["湖南大学"],
|
||||
"family_background": "家长更希望省内优先",
|
||||
"interest_assessment_result": "INTJ",
|
||||
},
|
||||
submit=True,
|
||||
)
|
||||
finally:
|
||||
intake_store.close()
|
||||
|
||||
detail_resp = client.get(f"/api/orders/{created.id}", headers=auth_headers)
|
||||
assert detail_resp.status_code == 200, detail_resp.text
|
||||
detail = detail_resp.json()
|
||||
assert detail["order"]["intake_status"] == "submitted"
|
||||
assert detail["order"]["intake"] is not None
|
||||
assert detail["order"]["intake"]["target_cities"] == ["长沙", "深圳"]
|
||||
assert detail["order"]["intake"]["target_schools"] == ["湖南大学"]
|
||||
assert detail["order"]["intake"]["family_background"] == "家长更希望省内优先"
|
||||
assert detail["order"]["intake"]["interest_assessment_result"] == "INTJ"
|
||||
|
||||
|
||||
def test_admin_create_order_defaults_to_draft_intake_state(client, auth_headers, settings):
|
||||
resp = client.post(
|
||||
"/api/orders",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"source": "wechat",
|
||||
"external_id": "ADMIN-DRAFT-001",
|
||||
"service_version": "standard",
|
||||
"amount_cents": 9900,
|
||||
"customer_name": "张家长",
|
||||
"customer_phone": "13800138000",
|
||||
"candidate_name": "张三",
|
||||
"candidate_province": "湖南",
|
||||
"consent": {
|
||||
"consent_method": "verbal_chat",
|
||||
"consent_note": "后台补录同意",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["order"]["intake_status"] == "draft"
|
||||
assert body["order"]["intake_submitted_at"] is None
|
||||
|
||||
|
||||
|
||||
def test_patch_updates_business_fields_and_status_transition(
|
||||
client, auth_headers, settings
|
||||
):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
"""T6.2 仪表盘端点测试。
|
||||
|
||||
覆盖目标
|
||||
@@ -19,6 +20,8 @@
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List
|
||||
@@ -89,6 +92,13 @@ def test_dashboard_with_token_returns_200(client, auth_headers):
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_viewer_cannot_read_order_stats(client, viewer_headers):
|
||||
resp = client.get("/api/stats/orders", headers=viewer_headers)
|
||||
assert resp.status_code == 403
|
||||
body = resp.json()
|
||||
assert body["code"] == "E01301"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 形状契约 (空库)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -530,6 +540,17 @@ def test_dashboard_summary_pending_missing_intake(client, auth_headers, settings
|
||||
assert body["by_status"]["pending"] == 3
|
||||
|
||||
|
||||
|
||||
def test_dashboard_request_does_not_write_debug_stats_file(client, auth_headers):
|
||||
debug_path = Path("/tmp/debug_stats.txt")
|
||||
if debug_path.exists():
|
||||
debug_path.unlink()
|
||||
|
||||
resp = client.get("/api/stats/dashboard", headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert not debug_path.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/stats/orders 兼容老契约
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
"""用户端 Web 自助入口页面测试(T12.2/T12.3)。"""
|
||||
"""用户端 Web 自助入口核心测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from admin.tests.conftest import RouteClient
|
||||
from admin.routes.web_public import (
|
||||
alipay_sim_payment_page,
|
||||
complete_alipay_sim_payment,
|
||||
complete_mock_payment,
|
||||
mock_payment_page,
|
||||
mock_payment_webhook,
|
||||
)
|
||||
from fastapi.testclient import TestClient
|
||||
from data.orders.dao import OrdersDAO
|
||||
|
||||
|
||||
def test_public_landing_page_served(route_client):
|
||||
resp = route_client.get("/")
|
||||
def test_public_landing_page_served(client):
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "text/html" in resp.headers["content-type"]
|
||||
body = resp.text
|
||||
@@ -18,41 +26,31 @@ def test_public_landing_page_served(route_client):
|
||||
assert "湖南新高考志愿填报" not in body
|
||||
assert "为什么选择我们" in body
|
||||
assert 'href="/pricing"' in body
|
||||
# Plan B: hero CTAs 改成 "立即咨询 / 查看套餐" 两个主 CTA
|
||||
assert "先做快速审核" not in body
|
||||
assert "立即咨询" in body
|
||||
assert "先做方案复核" in body
|
||||
assert "查看套餐" in body
|
||||
assert 'btn-primary" href="#consult-box"' in body
|
||||
assert 'href="/review/start?source=home"' in body
|
||||
assert 'btn-primary" href="/pricing"' in body
|
||||
# 业务铁律: 复核免费 / 方案付费 — 旧"咨询本身免费"已被替换
|
||||
assert "咨询本身免费" not in body
|
||||
assert "咨询入口" not in body # 不应再承诺"咨询免费"
|
||||
assert "咨询入口" not in body
|
||||
assert "复核现有方案本身免费" in body
|
||||
assert "新方案生成与深度辅导在支付后启动" in body
|
||||
# hero-trust 1 号卡片: 复核免费 / 方案付费
|
||||
assert "复核免费 / 方案付费" in body
|
||||
# 复核/付费套餐按钮
|
||||
assert "获取复核与推荐" in body
|
||||
assert "直接看付费套餐" in body
|
||||
# 服务流程 01 步: 明确标注复核免费 / 方案付费
|
||||
assert "方案复核(免费)" in body
|
||||
assert "深度辅导(付费)" in body
|
||||
# 底部 CTA: 区分复核路径与付费路径
|
||||
assert "已有方案?先免费复核" in body
|
||||
assert "先告诉我们你的基本情况" not in body # 旧标题已替换
|
||||
assert "告诉我们你的基本情况" in body # 新标题存在
|
||||
# 旧 CTA/按钮文案不应再出现
|
||||
assert "先告诉我们你的基本情况" not in body
|
||||
assert "告诉我们你的基本情况" in body
|
||||
assert "获取推荐路径" not in body
|
||||
assert "直接看套餐</a>" not in body
|
||||
assert "先看套餐,再决定是否立即下单" not in body
|
||||
assert "先审计后规划" not in body
|
||||
# 咨询表单隐私说明 (输入仅用于判断, 不留底)
|
||||
assert "不会留底" in body
|
||||
assert "不会用于生成方案" in body
|
||||
assert "不会发邮件推销" in body
|
||||
assert "不会收到营销短信" in body
|
||||
# 还保留的核心元素
|
||||
assert "为什么选择我们" in body
|
||||
assert "了解服务流程" in body
|
||||
assert "最常见的不是“不会选”,而是先选错方向" in body
|
||||
assert "先把方案看清,再决定要不要重做" in body
|
||||
@@ -67,12 +65,11 @@ def test_public_landing_page_served(route_client):
|
||||
assert "家长决策支持" not in body
|
||||
assert "家长联系方式" not in body
|
||||
assert "把风险解释给家长听懂" not in body
|
||||
|
||||
assert '/static/portal-ui.css' in body
|
||||
|
||||
|
||||
def test_public_pricing_page_served(route_client):
|
||||
resp = route_client.get("/pricing")
|
||||
def test_public_pricing_page_served(client):
|
||||
resp = client.get("/pricing")
|
||||
assert resp.status_code == 200
|
||||
assert "text/html" in resp.headers["content-type"]
|
||||
body = resp.text
|
||||
@@ -86,30 +83,24 @@ def test_public_pricing_page_served(route_client):
|
||||
assert "推荐方案" in body
|
||||
assert "你可以先从 99 元完整志愿方案开始" in body
|
||||
assert "支付接入建设中" not in body
|
||||
# 业务铁律: 套餐页口径与首页一致 — 复核免费 / 方案付费
|
||||
assert "复核现有方案本身免费" in body
|
||||
assert "复核免费 / 方案付费" in body
|
||||
# 套餐页应有 1 处明确引导回首页做免费复核
|
||||
assert 'href="/#consult-box"' in body
|
||||
assert "先做一次免费复核" in body
|
||||
# 49/99/199 三档文案
|
||||
assert "先做付费审核" in body # 49 元档 CTA
|
||||
assert "支付并启动方案生成" in body # 99 元档 CTA
|
||||
assert "了解深度辅导" in body # 199 元档 CTA
|
||||
# 旧 CTA/文案已替换
|
||||
assert "先做付费审核" in body
|
||||
assert "支付并启动方案生成" in body
|
||||
assert "了解深度辅导" in body
|
||||
assert "先做快速审核" not in body
|
||||
assert "立即开始完整规划" not in body
|
||||
assert "先审计再决定" not in body
|
||||
assert "快速校验" not in body
|
||||
# FAQ 加了"复核是免费的吗"
|
||||
assert "复核是免费的吗?包含什么?" in body
|
||||
# notice 提示明确引导免费复核
|
||||
assert "还没决定" in body
|
||||
assert '/static/portal-ui.css' in body
|
||||
|
||||
|
||||
def test_public_checkout_page_served(route_client):
|
||||
resp = route_client.get("/checkout/standard")
|
||||
def test_public_checkout_page_served(client):
|
||||
resp = client.get("/checkout/standard")
|
||||
assert resp.status_code == 200
|
||||
assert "text/html" in resp.headers["content-type"]
|
||||
body = resp.text
|
||||
@@ -124,8 +115,8 @@ def test_public_checkout_page_served(route_client):
|
||||
assert '/static/portal-ui.css' in body
|
||||
|
||||
|
||||
def test_public_create_order_endpoint(route_client):
|
||||
resp = route_client.post(
|
||||
def test_public_create_order_endpoint(client, app):
|
||||
resp = client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "standard",
|
||||
@@ -146,15 +137,14 @@ def test_public_create_order_endpoint(route_client):
|
||||
assert body["next_step"] == "payment"
|
||||
assert body["checkout_url"].startswith("/pay/mock/")
|
||||
assert "/portal/" in body["portal_status_url"]
|
||||
with OrdersDAO.connect(route_client.app.state.settings.orders_db_path) as dao:
|
||||
with OrdersDAO.connect(app.state.settings.orders_db_path) as dao:
|
||||
created = dao.get(body["order_id"])
|
||||
assert created.customer_email == "parent@example.com"
|
||||
assert created.candidate_name == "张三"
|
||||
|
||||
|
||||
def test_public_create_order_returns_503_with_friendly_message_when_encryption_key_missing(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
|
||||
def test_public_create_order_returns_503_with_friendly_message_when_encryption_key_missing(tmp_path, monkeypatch):
|
||||
admin_db = tmp_path / "admin.db"
|
||||
orders_db = tmp_path / "orders.db"
|
||||
share_db = tmp_path / "share.db"
|
||||
@@ -181,8 +171,8 @@ def test_public_create_order_returns_503_with_friendly_message_when_encryption_k
|
||||
settings = load_settings()
|
||||
app = create_app(settings)
|
||||
|
||||
with RouteClient(app) as route_client:
|
||||
resp = route_client.post(
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "standard",
|
||||
@@ -198,8 +188,8 @@ def test_public_create_order_returns_503_with_friendly_message_when_encryption_k
|
||||
assert "当前暂时无法创建订单" in body["detail"]["reason"]
|
||||
|
||||
|
||||
def test_public_create_order_rejects_missing_minimal_fields(route_client):
|
||||
resp = route_client.post(
|
||||
def test_public_create_order_rejects_missing_minimal_fields(client):
|
||||
resp = client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "audit",
|
||||
@@ -217,15 +207,15 @@ def test_public_create_order_rejects_missing_minimal_fields(route_client):
|
||||
)
|
||||
|
||||
|
||||
def test_public_create_order_rejects_price_tampering(route_client):
|
||||
resp = route_client.post(
|
||||
|
||||
def test_public_create_order_rejects_price_tampering(client):
|
||||
resp = client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "standard",
|
||||
"amount_cents": 1,
|
||||
"service_version": "audit",
|
||||
"amount_cents": 4999,
|
||||
"customer_name": "张家长",
|
||||
"customer_phone": "13800138000",
|
||||
"customer_email": "parent@example.com",
|
||||
"candidate_name": "张三",
|
||||
"candidate_province": "湖南",
|
||||
},
|
||||
@@ -233,40 +223,126 @@ def test_public_create_order_rejects_price_tampering(route_client):
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
assert body["message"] == "请求数据未通过校验"
|
||||
assert (
|
||||
body["detail"]["fields"][0]["reason"]
|
||||
== "Value error, amount_cents 与套餐价格不一致"
|
||||
assert any(
|
||||
"amount_cents 与套餐价格不一致" in field["reason"]
|
||||
for field in body["detail"]["fields"]
|
||||
)
|
||||
|
||||
|
||||
def test_checkout_url_does_not_expose_portal_token_in_query(route_client):
|
||||
create_resp = route_client.post(
|
||||
|
||||
def test_public_create_order_persists_candidate_province(client, app):
|
||||
resp = client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "audit",
|
||||
"amount_cents": 4900,
|
||||
"customer_name": "李家长",
|
||||
"customer_phone": "13800138000",
|
||||
"candidate_name": "张三",
|
||||
"candidate_province": "广东",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
with OrdersDAO.connect(app.state.settings.orders_db_path) as dao:
|
||||
created = dao.get(body["order_id"])
|
||||
assert created.candidate_province == "广东"
|
||||
|
||||
|
||||
def test_public_create_order_rejects_unsupported_province(client):
|
||||
resp = client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "standard",
|
||||
"amount_cents": 9900,
|
||||
"customer_name": "张家长",
|
||||
"customer_name": "李家长",
|
||||
"customer_phone": "13800138000",
|
||||
"customer_email": "parent@example.com",
|
||||
"candidate_name": "张三",
|
||||
"candidate_province": "内蒙古",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
assert body["message"] == "请求数据未通过校验"
|
||||
assert "candidate_province" in resp.text
|
||||
|
||||
|
||||
|
||||
|
||||
def test_public_create_order_supports_optional_contact_email(client, app):
|
||||
resp = client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "standard",
|
||||
"amount_cents": 9900,
|
||||
"customer_name": "李家长",
|
||||
"customer_phone": "13800138000",
|
||||
"customer_email": "guardian@example.com",
|
||||
"candidate_name": "张三",
|
||||
"candidate_province": "湖南",
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
body = create_resp.json()
|
||||
|
||||
assert "token=" not in body["checkout_url"]
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
with OrdersDAO.connect(app.state.settings.orders_db_path) as dao:
|
||||
created = dao.get(body["order_id"])
|
||||
assert created.customer_email == "guardian@example.com"
|
||||
|
||||
|
||||
def test_payment_return_redirects_to_payment_success_page(route_client):
|
||||
create_resp = route_client.post(
|
||||
|
||||
def test_public_checkout_page_renders_audit_package(client):
|
||||
resp = client.get("/checkout/audit")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.text
|
||||
assert "49元 AI方案审核" in body
|
||||
assert "适合已经拿到其他方案、希望先快速校验风险的家庭。" in body
|
||||
assert "当前建议" in body
|
||||
assert "当前这一步只收会影响下单与后续联系的必要信息" in body
|
||||
assert "支付成功后,再进入资料向导补充分数、位次、偏好和已有方案附件。" in body
|
||||
assert "service_version: 'audit'" in body
|
||||
assert "amount_cents: 4900" in body
|
||||
|
||||
|
||||
def test_public_checkout_page_renders_premium_package(client):
|
||||
resp = client.get("/checkout/premium")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.text
|
||||
assert "199元 深度辅导版" in body
|
||||
assert "适合需要更多沟通、补充说明与深度修订支持的家庭。" in body
|
||||
assert "199元 深度辅导版" in body
|
||||
assert "service_version: 'premium'" in body
|
||||
assert "amount_cents: 19900" in body
|
||||
|
||||
|
||||
def test_public_create_order_rejects_unknown_service_version(client):
|
||||
resp = client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "unknown",
|
||||
"amount_cents": 9999,
|
||||
"customer_name": "李家长",
|
||||
"customer_phone": "13800138000",
|
||||
"candidate_name": "张三",
|
||||
"candidate_province": "湖南",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
assert body["message"] == "请求数据未通过校验"
|
||||
assert any(
|
||||
field["field"] == "body.service_version"
|
||||
for field in body["detail"]["fields"]
|
||||
)
|
||||
|
||||
|
||||
def test_public_payment_flow_returns_payment_success_page(client, settings):
|
||||
create_resp = client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "standard",
|
||||
"amount_cents": 9900,
|
||||
"customer_name": "张家长",
|
||||
"customer_phone": "13800138000",
|
||||
"customer_email": "parent@example.com",
|
||||
"candidate_name": "张三",
|
||||
"candidate_province": "湖南",
|
||||
},
|
||||
@@ -275,117 +351,45 @@ def test_payment_return_redirects_to_payment_success_page(route_client):
|
||||
body = create_resp.json()
|
||||
payment_id = body["checkout_url"].split("/pay/mock/")[1].split("?")[0]
|
||||
|
||||
route_client.post(f"/pay/mock/{payment_id}/complete", follow_redirects=False)
|
||||
resp = route_client.get(
|
||||
f"/portal/payment-return?payment_id={payment_id}", follow_redirects=False
|
||||
)
|
||||
assert resp.status_code == 303, resp.text
|
||||
assert resp.headers["location"].startswith("/portal/")
|
||||
assert resp.headers["location"].endswith("/payment-success")
|
||||
|
||||
|
||||
def test_payment_success_page_served_after_paid_order(route_client, settings):
|
||||
create_resp = route_client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "standard",
|
||||
"amount_cents": 9900,
|
||||
"customer_name": "张家长",
|
||||
"customer_phone": "13800138000",
|
||||
"customer_email": "parent@example.com",
|
||||
"candidate_name": "张三",
|
||||
"candidate_province": "湖南",
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
body = create_resp.json()
|
||||
payment_id = body["checkout_url"].split("/pay/mock/")[1].split("?")[0]
|
||||
token = body["portal_status_url"].split("/portal/")[1].split("/status")[0]
|
||||
|
||||
complete = route_client.post(
|
||||
f"/pay/mock/{payment_id}/complete", follow_redirects=False
|
||||
)
|
||||
complete = client.post(f"/pay/mock/{payment_id}/complete", follow_redirects=False)
|
||||
assert complete.status_code == 303, complete.text
|
||||
assert complete.headers["location"] == f"/portal/{token}/payment-success"
|
||||
assert complete.headers["location"].endswith("/payment-success")
|
||||
token = complete.headers["location"].split("/portal/")[1].split("/payment-success")[0]
|
||||
|
||||
page = route_client.get(f"/portal/{token}/payment-success")
|
||||
page = client.get(f"/portal/{token}/payment-success")
|
||||
assert page.status_code == 200, page.text
|
||||
assert "支付成功" in page.text
|
||||
assert "订单已创建,下一步继续补资料" in page.text
|
||||
assert "支付状态:paid" in page.text
|
||||
assert "立即补充资料" in page.text
|
||||
assert "查看订单进度" in page.text
|
||||
|
||||
|
||||
def test_portal_report_rejects_untrusted_report_path(route_client, settings):
|
||||
from data.orders.dao import OrdersDAO
|
||||
from data.orders.models import Order
|
||||
from data.customer_portal.token import issue_portal_token
|
||||
|
||||
order = Order(
|
||||
id="GKO-20260614-TRUST",
|
||||
source="web",
|
||||
service_version="standard",
|
||||
amount_cents=9900,
|
||||
status="pending",
|
||||
customer_name="张家长",
|
||||
customer_phone="13800138000",
|
||||
candidate_name="张三",
|
||||
candidate_province="湖南",
|
||||
audit_report="/etc/hosts",
|
||||
pdf_path="/etc/hosts",
|
||||
)
|
||||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||
dao.create(order, actor="test", reason="seed")
|
||||
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||
|
||||
report_page = route_client.get(f"/portal/{token}/report")
|
||||
assert report_page.status_code == 409
|
||||
assert "report not ready" in report_page.text
|
||||
|
||||
pdf_resp = route_client.get(f"/portal/{token}/report.pdf")
|
||||
assert pdf_resp.status_code == 409
|
||||
assert "report not ready" in pdf_resp.text
|
||||
|
||||
|
||||
def test_portal_status_page_does_not_fall_back_to_pending_after_paid_order(
|
||||
route_client, settings
|
||||
):
|
||||
from data.payments.service import PaymentService
|
||||
|
||||
create_resp = route_client.post(
|
||||
def test_payment_success_page_shows_audit_copy_for_audit_orders(client, settings):
|
||||
create_resp = client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "standard",
|
||||
"amount_cents": 9900,
|
||||
"customer_name": "张家长",
|
||||
"service_version": "audit",
|
||||
"amount_cents": 4900,
|
||||
"customer_phone": "13800138000",
|
||||
"customer_email": "parent@example.com",
|
||||
"candidate_name": "张三",
|
||||
"candidate_name": "李同学",
|
||||
"candidate_province": "湖南",
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
body = create_resp.json()
|
||||
payment_id = body["checkout_url"].split("/pay/mock/")[1].split("?")[0]
|
||||
token = body["portal_status_url"].split("/portal/")[1].split("/status")[0]
|
||||
order_id = body["order_id"]
|
||||
|
||||
complete = route_client.post(
|
||||
f"/pay/mock/{payment_id}/complete", follow_redirects=False
|
||||
)
|
||||
complete = client.post(f"/pay/mock/{payment_id}/complete", follow_redirects=False)
|
||||
assert complete.status_code == 303, complete.text
|
||||
assert complete.headers["location"].endswith("/payment-success")
|
||||
token = complete.headers["location"].split("/portal/")[1].split("/payment-success")[0]
|
||||
|
||||
service = PaymentService.for_db(
|
||||
settings.orders_db_path,
|
||||
base_url=settings.payment_base_url,
|
||||
webhook_secret=settings.payment_webhook_secret,
|
||||
)
|
||||
redundant = service.create_checkout(order_id)
|
||||
assert redundant.payment_id == payment_id
|
||||
|
||||
page = route_client.get(f"/portal/{token}/status")
|
||||
page = client.get(f"/portal/{token}/payment-success")
|
||||
assert page.status_code == 200, page.text
|
||||
assert "待填写资料" in page.text
|
||||
assert "待支付" not in page.text
|
||||
assert "订单已创建,下一步继续补资料" in page.text
|
||||
assert "补充基础信息" in page.text
|
||||
assert "填写偏好目标" in page.text
|
||||
assert "持续查看进度" in page.text
|
||||
|
||||
|
||||
|
||||
def test_prod_hides_simulated_payment_entrypoints(tmp_path, monkeypatch):
|
||||
@@ -402,23 +406,15 @@ def test_prod_hides_simulated_payment_entrypoints(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", str(share_reports))
|
||||
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-web-self-service")
|
||||
monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64)
|
||||
monkeypatch.setenv("GAOKAO_PORTAL_TOKEN_SECRET", "Z" * 64)
|
||||
monkeypatch.setenv("GAOKAO_PAYMENT_WEBHOOK_SECRET", "prod-secret-ok-123456")
|
||||
monkeypatch.setenv("GAOKAO_PORTAL_TOKEN_SECRET", "prod-portal-secret-ok-1234567890")
|
||||
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
|
||||
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
|
||||
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "Prod-pass-123!")
|
||||
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
|
||||
monkeypatch.setenv("GAOKAO_PAYMENT_PROVIDER", "alipay")
|
||||
monkeypatch.setenv("GAOKAO_PAYMENT_WEBHOOK_SECRET", "P" + "r" * 31 + "!" * 32)
|
||||
monkeypatch.setenv("GAOKAO_PAYMENT_BASE_URL", "http://testserver")
|
||||
|
||||
from admin.config import load_settings
|
||||
from admin.routes.web_public import (
|
||||
alipay_sim_payment_page,
|
||||
complete_alipay_sim_payment,
|
||||
complete_mock_payment,
|
||||
mock_payment_page,
|
||||
mock_payment_webhook,
|
||||
)
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
settings = load_settings()
|
||||
request = Request(
|
||||
@@ -427,6 +423,10 @@ def test_prod_hides_simulated_payment_entrypoints(tmp_path, monkeypatch):
|
||||
"method": "POST",
|
||||
"path": "/api/public/payments/mock/webhook",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"server": ("testserver", 80),
|
||||
"client": ("127.0.0.1", 12345),
|
||||
"scheme": "http",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -442,43 +442,7 @@ def test_prod_hides_simulated_payment_entrypoints(tmp_path, monkeypatch):
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
def test_public_pages_include_privacy_and_deletion_links(route_client):
|
||||
landing = route_client.get("/")
|
||||
assert landing.status_code == 200, landing.text
|
||||
assert 'href="/privacy"' in landing.text
|
||||
assert 'href="/service-terms"' in landing.text
|
||||
assert 'href="/deletion-policy"' in landing.text
|
||||
|
||||
pricing = route_client.get("/pricing")
|
||||
assert pricing.status_code == 200, pricing.text
|
||||
assert 'href="/privacy"' in pricing.text
|
||||
assert 'href="/service-terms"' in pricing.text
|
||||
assert 'href="/deletion-policy"' in pricing.text
|
||||
|
||||
|
||||
def test_privacy_and_deletion_pages_are_served(route_client):
|
||||
privacy = route_client.get("/privacy")
|
||||
assert privacy.status_code == 200, privacy.text
|
||||
assert "隐私政策" in privacy.text
|
||||
assert "隐私说明" in privacy.text
|
||||
assert '/static/portal-ui.css' in privacy.text
|
||||
|
||||
terms = route_client.get("/service-terms")
|
||||
assert terms.status_code == 200, terms.text
|
||||
assert "服务说明与免责声明" in terms.text
|
||||
assert "服务边界" in terms.text
|
||||
assert '/static/portal-ui.css' in terms.text
|
||||
|
||||
deletion = route_client.get("/deletion-policy")
|
||||
assert deletion.status_code == 200, deletion.text
|
||||
assert "删除申请" in deletion.text
|
||||
assert "数据删除" in deletion.text
|
||||
assert '/static/portal-ui.css' in deletion.text
|
||||
|
||||
|
||||
def test_public_create_order_returns_503_without_creating_orphan_order_when_provider_unavailable(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
def test_public_create_order_returns_503_without_creating_orphan_order_when_provider_unavailable(tmp_path, monkeypatch):
|
||||
admin_db = tmp_path / "admin.db"
|
||||
orders_db = tmp_path / "orders.db"
|
||||
share_db = tmp_path / "share.db"
|
||||
@@ -505,8 +469,8 @@ def test_public_create_order_returns_503_without_creating_orphan_order_when_prov
|
||||
settings = load_settings()
|
||||
app = create_app(settings)
|
||||
|
||||
with RouteClient(app) as route_client:
|
||||
resp = route_client.post(
|
||||
with TestClient(app) as client:
|
||||
resp = client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "standard",
|
||||
@@ -522,132 +486,3 @@ def test_public_create_order_returns_503_without_creating_orphan_order_when_prov
|
||||
assert "payment provider unavailable" in resp.text
|
||||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||
assert dao.count() == 0
|
||||
|
||||
|
||||
def test_public_create_order_returns_503_without_creating_orphan_order_when_checkout_fails(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
admin_db = tmp_path / "admin.db"
|
||||
orders_db = tmp_path / "orders.db"
|
||||
share_db = tmp_path / "share.db"
|
||||
share_reports = tmp_path / "share_reports"
|
||||
share_reports.mkdir()
|
||||
|
||||
monkeypatch.setenv("GAOKAO_ENV", "dev")
|
||||
monkeypatch.setenv("GAOKAO_DB_PATH", str(admin_db))
|
||||
monkeypatch.setenv("GAOKAO_ORDERS_DB_PATH", str(orders_db))
|
||||
monkeypatch.setenv("GAOKAO_SHARE_DB_PATH", str(share_db))
|
||||
monkeypatch.setenv("GAOKAO_SHARE_REPORT_DIR", str(share_reports))
|
||||
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-web-self-service")
|
||||
monkeypatch.setenv("GAOKAO_JWT_SECRET", "x" * 64)
|
||||
monkeypatch.setenv("GAOKAO_JWT_EXP_MIN", "5")
|
||||
monkeypatch.setenv("GAOKAO_ADMIN_USER", "admin")
|
||||
monkeypatch.setenv("GAOKAO_ADMIN_PASS", "test-pass-123")
|
||||
monkeypatch.setenv("GAOKAO_PAYMENT_PROVIDER", "mock")
|
||||
monkeypatch.setenv("GAOKAO_PAYMENT_BASE_URL", "http://testserver")
|
||||
monkeypatch.setenv("GAOKAO_PAYMENT_WEBHOOK_SECRET", "test-payment-secret")
|
||||
|
||||
from admin.app import create_app
|
||||
from admin.config import load_settings
|
||||
from data.payments.service import PaymentError, PaymentService
|
||||
|
||||
original = PaymentService.create_checkout
|
||||
|
||||
def _boom(self, order_id: str, *, portal_token: str | None = None):
|
||||
raise PaymentError("checkout transport failed")
|
||||
|
||||
monkeypatch.setattr(PaymentService, "create_checkout", _boom)
|
||||
|
||||
settings = load_settings()
|
||||
app = create_app(settings)
|
||||
|
||||
with RouteClient(app) as route_client:
|
||||
resp = route_client.post(
|
||||
"/api/public/orders",
|
||||
json={
|
||||
"service_version": "standard",
|
||||
"amount_cents": 9900,
|
||||
"customer_name": "张家长",
|
||||
"customer_phone": "13800138000",
|
||||
"candidate_name": "张三",
|
||||
"candidate_province": "湖南",
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(PaymentService, "create_checkout", original)
|
||||
assert resp.status_code == 503, resp.text
|
||||
assert "payment checkout unavailable" in resp.text
|
||||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||
assert dao.count() == 0
|
||||
def test_public_pricing_page_shows_consult_recommendation(route_client):
|
||||
resp = route_client.get(
|
||||
"/pricing?province=%E6%B9%96%E5%8D%97&score=578&goal=%E5%85%88%E5%AE%A1%E6%A0%B8&consult=%E5%B7%B2%E6%9C%89%E4%B8%80%E7%89%88%E6%96%B9%E6%A1%88"
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "更适合先做 49 元方案审核" in resp.text
|
||||
assert "湖南 578 先审核" in resp.text
|
||||
|
||||
|
||||
def test_info_page_wizard_actions_outside_form_for_sticky_bottom(route_client, settings):
|
||||
"""资料页移动端关键操作按钮必须放在 form 之外, 才能让
|
||||
`position: sticky; bottom: 0` 跨越整个表单区域在视口持续可见。"""
|
||||
from data.orders.dao import OrdersDAO
|
||||
from data.orders.public_flow import PublicOrderCreate, create_public_order
|
||||
from data.customer_portal.token import issue_portal_token
|
||||
|
||||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||
order = create_public_order(
|
||||
dao,
|
||||
PublicOrderCreate(
|
||||
service_version="standard",
|
||||
amount_cents=9900,
|
||||
customer_name="Sticky 用户",
|
||||
customer_phone="13800138000",
|
||||
candidate_name="Sticky-User",
|
||||
candidate_province="湖南",
|
||||
),
|
||||
)
|
||||
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||
resp = route_client.get(f"/portal/{token}/info")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.text
|
||||
# 关键断言: wizard-actions 必须出现在 </form> 之后
|
||||
form_end = body.find("</form>")
|
||||
wizard_start = body.find('<div class="wizard-actions">')
|
||||
assert form_end > 0, "should have </form> closing tag"
|
||||
assert wizard_start > 0, "should have wizard-actions div"
|
||||
assert wizard_start > form_end, (
|
||||
"wizard-actions must be a sibling of <form>, not nested inside it "
|
||||
"(so position: sticky works across all step-panels)"
|
||||
)
|
||||
# 同时确认主操作按钮文案都还在
|
||||
for label in ["保存草稿", "下一步", "上一步", "确认并提交资料"]:
|
||||
assert label in body, f"missing wizard button label: {label}"
|
||||
# 移动端 safe-area 适配
|
||||
assert "safe-area-inset-bottom" in body, "should reserve safe-area for notched devices"
|
||||
|
||||
|
||||
def test_confirm_summary_does_not_use_innerhtml_for_user_fields(route_client, settings):
|
||||
from data.orders.dao import OrdersDAO
|
||||
from data.orders.public_flow import PublicOrderCreate, create_public_order
|
||||
from data.customer_portal.token import issue_portal_token
|
||||
|
||||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||
order = create_public_order(
|
||||
dao,
|
||||
PublicOrderCreate(
|
||||
service_version="standard",
|
||||
amount_cents=9900,
|
||||
customer_name="Summary 用户",
|
||||
customer_phone="13800138000",
|
||||
candidate_name="Summary-User",
|
||||
candidate_province="湖南",
|
||||
),
|
||||
)
|
||||
token = issue_portal_token(order.id, settings.portal_token_secret)
|
||||
resp = route_client.get(f"/portal/{token}/info")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.text
|
||||
assert "confirm-summary').innerHTML" not in body
|
||||
assert "replaceChildren" in body
|
||||
assert "createElement('div')" in body
|
||||
|
||||
Reference in New Issue
Block a user