Files
gaokao-volunteer-system/admin/tests/test_web_public.py
Hermes Agent f5e7098c50
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
fix(landing+pricing): A) consult form privacy note + B) pricing page copy sync
A) 首页咨询表单加隐私说明 (admin/routes/web_public.py):
  - 在表单标题下方加 .consult-privacy 框:
    '🔒 这些输入只用于判断要不要复核你的方案, 不会留底、不会用于生成方案、不会发邮件推销。
     如果你决定不进入付费方案, 提交的资料不会保存到我们的数据库。'
  - 在表单下方加 .consult-privacy-tail:
    '不会收到营销短信, 提交后你也可以随时要求删除已填资料。'
  - 新增 CSS: .consult-privacy (薄荷绿底+边框) / .consult-privacy-tail (灰色脚注)
  - 设计意图: 不抢眼但能看见, 用浅色背景与表单区分, 不影响主 CTA 视觉权重

B) 套餐页 /pricing 同步'复核免费 / 方案付费' 口径:
  - lead 文案加 <strong>复核现有方案本身免费</strong> (放在最前面)
  - consult-summary 内加 consult-reassure 框:
    '💡 如果你还没决定, 先做一次免费复核, 再决定要不要进入付费方案。'
  - summary 副卡加 summary-reassure:
    '🔒 还没下单前, 提交的基本情况仅用于判断是否需要复核, 不会留底。'
  - trust-proof 1 号卡: '先审计再决定' → '复核免费 / 方案付费'
  - trust-band 3 号卡: '合规入口可见' → '复核免费 / 方案付费' (新增回流入口)
  - 49元档 eyebrow: '快速校验' → '复核 / 风险' (强调只审不改)
  - 99元档 eyebrow: '完整规划' → '生成 / 完整' (强调生成动作)
  - 199元档 eyebrow: '深度辅导' → '生成 / 深度'
  - 49元档 desc: 加'不会重新生成志愿表 (生成需选 99 元)' 边界
  - 99元档 desc: 加'方案生成与详细报告在支付后启动' 边界
  - 199元档 desc: 加'在 99 元完整方案基础上提供多轮修订和深度解释'
  - 49元档 CTA: '先做快速审核' → '先做付费审核' (用'付费'反衬'免费复核')
  - 99元档 CTA: '立即开始完整规划' → '支付并启动方案生成'
  - 199元档 CTA: '了解深度辅导' (保持)
  - 49元档 features: 加'不重新生成志愿表(生成需选 99 元)' 边界
  - 199元档 features: 加'包含 99 元完整方案的所有交付'
  - FAQ 加 '复核是免费的吗?包含什么?' (明确边界: 复核免费/审核报告 49/完整方案 99起)
  - 99元 FAQ 加 '99 元是生成一份完整方案的价格, 不是查看价格' 避免误解
  - notice 提示: '先做一次免费复核' 引导 + 保留 49/99 路径建议

新增测试断言:
  - 首页: '不会留底' / '不会用于生成方案' / '不会发邮件推销' / '不会收到营销短信' (4 条)
  - 套餐页: '复核现有方案本身免费' / '复核免费 / 方案付费' / 'href="/#consult-box"' /
    '先做一次免费复核' / '先做付费审核' / '支付并启动方案生成' / '复核 / 风险' /
    '生成 / 完整' / '生成 / 深度' / '不会重新生成志愿表' / '支付后启动' /
    '复核是免费的吗?包含什么?' / '还没决定' (13 条)
  - 套餐页反向断言: '先做快速审核' / '立即开始完整规划' / '先审计再决定' / '快速校验' (4 条)

验证:
  - pytest 22 passed (test_web_public + test_order_status_page + test_web_public_alipay_sim_e2e)
  - ruff check: All checks passed
  - 真实 HTTP 端到端 17/17 断言 OK (A 6 条 + B 20 条旧移除+新命中)
  - Playwright vision 验证:
    A) 首页隐私说明: 上下双向布局, 浅色克制, 覆盖'用途/营销/删除'三要素 
    B) 套餐页: lead/eyebrow/CTA/FAQ 5 处引导回首页 全部对齐'复核免费/方案付费' 
2026-06-17 22:38:42 +08:00

499 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""用户端 Web 自助入口页面测试T12.2/T12.3)。"""
from __future__ import annotations
from fastapi.testclient import TestClient
from data.orders.dao import OrdersDAO
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
assert "高考志愿填报智能规划服务" in body
assert "新高考志愿填报 · 志愿决策支持" in body
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 'btn-primary" href="#consult-box"' in body
assert 'btn-primary" href="/pricing"' 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 "直接看套餐</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
assert "我们先把现有方案看明白" in body
assert "服务流程" in body
assert "把风险解释清楚" in body
assert "风险重点可解释" in body
assert "进度站内可查" in body
assert "隐私与删除入口可见" in body
assert "01" in body
assert "04" in body
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(client):
resp = client.get("/pricing")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
body = resp.text
assert "服务套餐" in body
assert "49元 AI方案审核" in body
assert "99元 完整志愿方案" in body
assert "199元 深度辅导版" in body
assert 'data-package="audit"' in body
assert 'data-package="standard"' in body
assert 'data-package="premium"' in body
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 "先做快速审核" 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(client):
resp = client.get("/checkout/standard")
assert resp.status_code == 200
assert "text/html" in resp.headers["content-type"]
body = resp.text
assert "完整志愿方案" in body
assert "订单摘要" in body
assert "服务保障" in body
assert "立即支付" in body
assert "状态站内可追踪" in body
assert "当前建议" in body
assert "交付方式" in body
assert "最小下单" not in body
assert '/static/portal-ui.css' in body
def test_public_create_order_endpoint(client):
resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 9900,
"customer_phone": "13800138000",
"customer_email": "parent@example.com",
"candidate_name": "张三",
"candidate_province": "湖南",
},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["order_id"].startswith("GKO-")
assert body["source"] == "web"
assert body["status"] == "pending"
assert body["service_version"] == "standard"
assert body["amount_cents"] == 9900
assert body["next_step"] == "payment"
assert body["checkout_url"].startswith("/pay/mock/")
assert "/portal/" in body["portal_status_url"]
with OrdersDAO.connect(client.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
):
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.delenv("GAOKAO_ORDERS_FERNET_KEY", raising=False)
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")
from admin.app import create_app
from admin.config import load_settings
from data.orders import crypto
crypto.get_fernet.cache_clear()
settings = load_settings()
app = create_app(settings)
with TestClient(app) as client:
resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 9900,
"customer_phone": "13800138000",
"candidate_name": "张三",
"candidate_province": "湖南",
},
)
assert resp.status_code == 503, resp.text
body = resp.json()
assert "当前暂时无法创建订单" in body["detail"]["reason"]
def test_public_create_order_rejects_missing_minimal_fields(client):
resp = client.post(
"/api/public/orders",
json={
"service_version": "audit",
"amount_cents": 4900,
"customer_name": "李家长",
"candidate_province": "广东",
},
)
assert resp.status_code == 422
body = resp.json()
assert body["message"] == "请求数据未通过校验"
assert any(
field["field"] in {"body.customer_phone", "body.candidate_name"}
for field in body["detail"]["fields"]
)
def test_public_create_order_rejects_price_tampering(client):
resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 1,
"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 (
body["detail"]["fields"][0]["reason"]
== "Value error, amount_cents 与套餐价格不一致"
)
def test_mock_payment_complete_rejects_wrong_portal_token(client):
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": "湖南",
},
)
assert create_resp.status_code == 201, create_resp.text
body = create_resp.json()
payment_id = body["checkout_url"].split("/pay/mock/")[1].split("?")[0]
resp = client.post(f"/pay/mock/{payment_id}/complete?token=wrong-token")
assert resp.status_code == 401 or resp.status_code == 403
def test_payment_return_redirects_to_payment_success_page(client):
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": "湖南",
},
)
assert create_resp.status_code == 201, create_resp.text
body = create_resp.json()
token = body["portal_status_url"].split("/portal/")[1].split("/status")[0]
resp = client.get(f"/portal/payment-return?token={token}", follow_redirects=False)
assert resp.status_code == 303, resp.text
assert resp.headers["location"] == f"/portal/{token}/payment-success"
def test_payment_success_page_served_after_paid_order(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": "湖南",
},
)
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 = client.post(
f"/pay/mock/{payment_id}/complete?token={token}", follow_redirects=False
)
assert complete.status_code == 303, complete.text
assert complete.headers["location"] == 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 "立即补充资料" in page.text
def test_public_pages_include_privacy_and_deletion_links(client):
landing = 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 = 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(client):
privacy = 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 = 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 = 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
):
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", "alipay")
monkeypatch.setenv("GAOKAO_PAYMENT_BASE_URL", "http://testserver")
monkeypatch.setenv("GAOKAO_PAYMENT_WEBHOOK_SECRET", "missing-real-provider-env")
from admin.app import create_app
from admin.config import load_settings
settings = load_settings()
app = create_app(settings)
with TestClient(app) as client:
resp = client.post(
"/api/public/orders",
json={
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "张家长",
"customer_phone": "13800138000",
"candidate_name": "张三",
"candidate_province": "湖南",
},
)
assert resp.status_code == 503, resp.text
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):
raise PaymentError("checkout transport failed")
monkeypatch.setattr(PaymentService, "create_checkout", _boom)
settings = load_settings()
app = create_app(settings)
with TestClient(app) as client:
resp = 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(client):
resp = 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(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 = 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"