Files
gaokao-volunteer-system/admin/tests/test_routes_orders.py
Hermes Agent adacb1224c
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat(legal): 个人主体正式落盘 + consent_version 切到 v2026.06.25
按确认方案落地:
- 运营主体: 龙某某(个人开发者)
- 联系邮箱: lon22@qq.com
- 隐私政策版本: privacy-policy-v2026.06.25
- 服务条款版本: service-terms-v2026.06.25
- 生效日期: 2026-06-25

文档更新:
- PRIVACY_POLICY_DRAFT.md: 新增个人信息处理者信息, 联系邮箱, 版本号, 生效日期
- SERVICE_TERMS.md: 新增运营主体信息, 联系邮箱, 版本号, 生效日期
- LEGAL_PRIVACY_BASELINE.md: 同步法务基线状态, 收窄待补项到前台挂载/第三方实名披露
- 清理文档中残留的“待补运营主体/邮箱/版本号”占位文案

运行时更新:
- Settings 默认 GAOKAO_CONSENT_VERSION 改为 privacy-policy-v2026.06.25
- /privacy 和 /service-terms 页面头部渲染主体/邮箱/版本/生效日期
- admin create_order / portal intake 默认 consent_version 与文档版本对齐

测试:
- 38 个针对性测试全过
- 页面真实渲染包含 lon22@qq.com 与正式版本号
- dev-verify all checks passed
2026-06-25 15:04:29 +08:00

602 lines
20 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.
"""订单管理路由测试 (T6.4).
覆盖:
- 手工录单 POST /api/orders
- 订单列表 / 详情
- PATCH 业务字段更新 + 状态流转
- refunded 退款流转
- CSV 导出
"""
from __future__ import annotations
import csv
from io import StringIO
import pytest
from data.orders.dao import OrdersDAO
from data.orders.intake_store import IntakeStore
from data.orders.models import Order
@pytest.fixture(autouse=True)
def _orders_fernet_key(monkeypatch):
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-admin-orders")
def _seed_order(settings, **overrides) -> Order:
payload = {
"id": overrides.pop("id", "GKO-20260612-SEED"),
"source": "web",
"external_id": None,
"service_version": "basic",
"amount_cents": 4900,
"status": "pending",
"customer_name": "张三",
"customer_phone": "13800001234",
"customer_wechat": "zhangsanwx",
"candidate_name": "李同学",
"candidate_id_card": "430102200501011234",
"candidate_province": "湖南",
"candidate_score": 578,
"tags": ["首单"],
"notes": "待跟进",
}
payload.update(overrides)
order = Order(**payload)
with OrdersDAO.connect(settings.orders_db_path) as dao:
return dao.create(order, actor="test", reason="seed")
def test_create_order_returns_masked_payload_with_history(client, auth_headers):
resp = client.post(
"/api/orders",
headers=auth_headers,
json={
"source": "wechat",
"external_id": "WX-001",
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "王家长",
"customer_phone": "13900001111",
"customer_wechat": "parentwx",
"candidate_name": "王同学",
"candidate_id_card": "110101200701011234",
"candidate_province": "北京",
"candidate_score": 640,
"candidate_subjects": ["物理", "化学"],
"notes": "手工补录",
"tags": ["渠道补单"],
"consent": {
"consent_method": "verbal_chat",
"consent_note": "微信沟通后家长口头同意",
},
},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["action"] == "created"
assert body["order"]["source"] == "wechat"
assert body["order"]["status"] == "pending"
assert body["order"]["customer_phone"] == "139****1111"
assert body["order"]["candidate_id_card"] == "110101********1234"
assert "customer_phone_hash" not in body["order"]
assert body["order"]["consent_method"] == "verbal_chat"
assert body["order"]["consent_given_at"] is not None
assert body["history"][0]["from_status"] is None
assert body["history"][0]["to_status"] == "pending"
# ---------------------------------------------------------------------------
# A-2: 后台/外部渠道补录同意审计统一化2026-06-20 落地)
#
# LEGAL_PRIVACY_BASELINE §6 要求所有订单创建路径必须记录同意审计字段。
# portal 路径已自动落 consent_channel=portal / consent_operator=guardian
# (见 web_public.py + intake_store.save
# admin 代录/外部渠道补录路径必须在请求体里显式带 consent 块, 否则拒绝
# (HTTP 422)。
# ---------------------------------------------------------------------------
_XIANYU_BASE = {
"source": "xianyu",
"external_id": "XY-A2-001",
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "陈家长",
"customer_phone": "13900002222",
"candidate_name": "陈同学",
"candidate_province": "广东",
"candidate_score": 595,
}
@pytest.mark.parametrize("source", ["xianyu", "wechat", "school", "web"])
def test_create_order_rejects_missing_consent_block(client, auth_headers, source):
"""A-2 RED: 任何 source 补录都必须在请求体里带 consent 块, 否则 422。"""
resp = client.post(
"/api/orders",
headers=auth_headers,
json={**_XIANYU_BASE, "source": source, "external_id": f"TEST-{source}"},
)
assert resp.status_code == 422, resp.text
body_str = resp.text
# 项目对 422 走自定义 exception handler, detail 可能是 dict 或 str
# 只要 body 里出现 "consent" 字段名就算合规
assert "consent" in body_str, f"422 错误体未提及 consent 字段: {body_str}"
def test_create_order_writes_intake_record_with_consent_audit(
client, auth_headers, settings
):
"""A-2 RED: 合规补录成功后, 必须能在 order_intakes 表查到 consent 审计字段。"""
resp = client.post(
"/api/orders",
headers=auth_headers,
json={
**_XIANYU_BASE,
"external_id": "XY-A2-002",
"consent": {
"consent_method": "phone_recording",
"consent_note": "闲鱼沟通后电话确认",
},
},
)
assert resp.status_code == 201, resp.text
order_id = resp.json()["order"]["id"]
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
record = intake_store.get(order_id)
finally:
intake_store.close()
assert record is not None, f"order_intakes 表未创建 {order_id} 记录"
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") is not None
assert record.payload.get("consent_note") == "闲鱼沟通后电话确认"
def test_create_order_uses_settings_consent_version(client, auth_headers, settings):
"""consent_version / consent_scope 不再硬编码, 而是从 Settings 读取。
锁定: 默认值 = privacy-policy-v2026.06.25 / xianyu-channel-intake
(升级隐私政策时只需改环境变量 GAOKAO_CONSENT_VERSION, 无需改代码)
"""
resp = client.post(
"/api/orders",
headers=auth_headers,
json={
**_XIANYU_BASE,
"external_id": "XY-CONSENT-VER-001",
"consent": {
"consent_method": "phone_recording",
"consent_note": "consent_version 来源测试",
},
},
)
assert resp.status_code == 201, resp.text
order_id = resp.json()["order"]["id"]
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
record = intake_store.get(order_id)
finally:
intake_store.close()
assert record is not None
# 必须来自 settings.consent_version (默认 privacy-policy-v2026.06.25)
assert record.payload.get("consent_version") == settings.consent_version
assert record.payload.get("consent_version") == "privacy-policy-v2026.06.25"
# scope 必须是 f"{source}-{settings.consent_scope_channel_prefix}"
assert (
record.payload.get("consent_scope")
== f"xianyu-{settings.consent_scope_channel_prefix}"
)
def test_create_order_external_channel_marks_consent_operator_as_admin(
client, auth_headers, settings
):
"""A-2 RED: 外部渠道(xianyu/wechat/school)的 consent_operator 必须是 admin,
因为是后台代录而非用户自助 portal。"""
resp = client.post(
"/api/orders",
headers=auth_headers,
json={
**_XIANYU_BASE,
"external_id": "WECHAT-A2-003",
"source": "wechat",
"consent": {"consent_method": "screenshot", "consent_note": "微信截图"},
},
)
assert resp.status_code == 201, resp.text
order_id = resp.json()["order"]["id"]
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
record = intake_store.get(order_id)
finally:
intake_store.close()
assert record is not None
assert record.payload.get("consent_channel") == "wechat"
assert record.payload.get("consent_operator") == "admin_import"
def test_create_order_rejects_invalid_consent_method(client, auth_headers):
"""A-2 RED: consent_method 必须是白名单值。"""
resp = client.post(
"/api/orders",
headers=auth_headers,
json={
**_XIANYU_BASE,
"external_id": "XY-A2-004",
"consent": {"consent_method": "telepathy", "consent_note": "非法方法"},
},
)
assert resp.status_code == 422, resp.text
def test_order_detail_returns_consent_method_and_given_at(client, auth_headers):
"""A-2 RED: Order 列表/详情接口直接返回 consent_method + consent_given_at,
不必后端 join order_intakes 表 (冗余字段落库, 性能 + 一致性)。"""
create_resp = client.post(
"/api/orders",
headers=auth_headers,
json={
**_XIANYU_BASE,
"external_id": "XY-A2-005",
"consent": {
"consent_method": "written_form",
"consent_note": "书面确认单已签字",
},
},
)
assert create_resp.status_code == 201, create_resp.text
order_id = create_resp.json()["order"]["id"]
detail_resp = client.get(f"/api/orders/{order_id}", headers=auth_headers)
assert detail_resp.status_code == 200, detail_resp.text
detail = detail_resp.json()["order"]
assert detail["consent_method"] == "written_form"
assert detail["consent_given_at"] is not None
def test_list_and_detail_return_real_orders_with_masking(
client, auth_headers, settings
):
created = _seed_order(settings, id="GKO-20260612-LIST")
list_resp = client.get(
"/api/orders",
headers=auth_headers,
params={"status": "pending", "limit": 10, "offset": 0},
)
assert list_resp.status_code == 200, list_resp.text
payload = list_resp.json()
assert len(payload) == 1
assert payload[0]["id"] == created.id
assert payload[0]["customer_phone"] == "138****1234"
assert payload[0]["intake_status"] is None
assert payload[0]["intake_submitted_at"] is None
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"]["id"] == created.id
assert detail["order"]["customer_wechat"] == "zh******wx"
assert detail["order"]["intake_status"] is None
assert detail["order"]["intake_submitted_at"] is None
assert detail["history"][0]["to_status"] == "pending"
assert set(detail["available_next_statuses"]) == {"paid", "refunded"}
def test_detail_exposes_submitted_intake_state(client, auth_headers, settings):
created = _seed_order(settings, id="GKO-20260612-INTAKE-DETAIL")
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
record = intake_store.save(
order_id=created.id,
payload={"candidate_score": 578, "guardian_notes": "尽快处理"},
submit=True,
)
finally:
intake_store.close()
list_resp = client.get(
"/api/orders",
headers=auth_headers,
params={"status": "pending", "limit": 10, "offset": 0},
)
assert list_resp.status_code == 200, list_resp.text
payload = list_resp.json()
assert payload[0]["intake_status"] == "submitted"
assert payload[0]["intake_submitted_at"] == record.submitted_at
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_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
):
created = _seed_order(settings, id="GKO-20260612-PATCH")
resp = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={
"updates": {
"assigned_consultant": "consultant-a",
"notes": "已联系家长",
"tags": ["已跟进", "VIP"],
},
"to_status": "paid",
"reason": "manual_pay",
},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["order"]["assigned_consultant"] == "consultant-a"
assert body["order"]["notes"] == "已联系家长"
assert body["order"]["tags"] == ["已跟进", "VIP"]
assert body["order"]["status"] == "paid"
assert body["order"]["paid_at"] is not None
assert [item["to_status"] for item in body["history"]] == ["pending", "paid"]
def test_patch_refund_flow_appends_history(client, auth_headers, settings):
created = _seed_order(settings, id="GKO-20260612-REFUND")
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.transition_status(created.id, "paid", actor="test", reason="seed_pay")
resp = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={"to_status": "refunded", "reason": "manual_refund"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["order"]["status"] == "refunded"
assert [item["to_status"] for item in body["history"]] == [
"pending",
"paid",
"refunded",
]
assert body["available_next_statuses"] == []
def test_patch_invalid_transition_returns_conflict(client, auth_headers, settings):
created = _seed_order(settings, id="GKO-20260612-CONFLICT")
resp = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={"to_status": "completed", "reason": "skip_steps"},
)
assert resp.status_code == 409
body = resp.json()
assert body["code"] == "E02301"
def test_patch_paid_to_serving_requires_submitted_intake(
client, auth_headers, settings
):
created = _seed_order(settings, id="GKO-20260612-NEED-INTAKE")
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.transition_status(created.id, "paid", actor="test", reason="seed_pay")
blocked = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={"to_status": "serving", "reason": "begin_processing"},
)
assert blocked.status_code == 422
blocked_body = blocked.json()
assert blocked_body["code"] == "E03001"
assert blocked_body["detail"]["required_intake_status"] == "submitted"
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
intake_store.save(
order_id=created.id,
payload={"candidate_score": 588, "guardian_notes": "资料已确认"},
submit=True,
)
finally:
intake_store.close()
allowed = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={"to_status": "serving", "reason": "begin_processing"},
)
assert allowed.status_code == 200, allowed.text
allowed_body = allowed.json()
assert allowed_body["order"]["status"] == "serving"
assert allowed_body["order"]["intake_status"] == "submitted"
def test_patch_order_rejects_audit_report_outside_allowed_report_dir(
client, auth_headers, settings
):
created = _seed_order(settings, id="GKO-20260612-UNTRUSTED-HTML")
resp = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={
"updates": {"audit_report": "/etc/hosts"},
"reason": "attach_report",
},
)
assert resp.status_code == 422
body = resp.json()
assert body["code"] == "E03001"
assert "untrusted artifact" in body["detail"]["reason"]
def test_patch_order_rejects_pdf_path_outside_allowed_report_dir(
client, auth_headers, settings
):
created = _seed_order(settings, id="GKO-20260612-UNTRUSTED-PDF")
resp = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={
"updates": {"pdf_path": "/etc/hosts"},
"reason": "attach_report",
},
)
assert resp.status_code == 422
body = resp.json()
assert body["code"] == "E03001"
assert "untrusted artifact" in body["detail"]["reason"]
def test_viewer_cannot_list_orders(client, viewer_headers, settings):
_seed_order(settings, id="GKO-20260612-VIEWER-LIST")
resp = client.get("/api/orders", headers=viewer_headers)
assert resp.status_code == 403
body = resp.json()
assert body["code"] == "E01301"
def test_viewer_cannot_patch_order(client, viewer_headers, settings):
created = _seed_order(settings, id="GKO-20260612-VIEWER-PATCH")
resp = client.patch(
f"/api/orders/{created.id}",
headers=viewer_headers,
json={"updates": {"notes": "viewer should fail"}, "reason": "forbidden"},
)
assert resp.status_code == 403
body = resp.json()
assert body["code"] == "E01301"
def test_export_orders_csv_returns_masked_rows(client, auth_headers, settings):
_seed_order(
settings,
id="GKO-20260612-CSV",
source="school",
service_version="premium",
amount_cents=19900,
)
resp = client.get(
"/api/orders/export",
headers=auth_headers,
params={"status": "pending", "source": "school"},
)
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"].startswith("text/csv")
assert "attachment; filename=" in resp.headers["content-disposition"]
rows = list(csv.DictReader(StringIO(resp.text)))
assert len(rows) == 1
assert rows[0]["id"] == "GKO-20260612-CSV"
assert rows[0]["customer_phone"] == "138****1234"
assert rows[0]["candidate_id_card"] == "430102********1234"
assert rows[0]["status"] == "pending"
def test_export_orders_csv_neutralizes_formula_injection_values(
client, auth_headers, settings
):
_seed_order(
settings,
id="GKO-20260612-CSV-FORMULA",
customer_name="=cmd|' /C calc'!A0",
candidate_name="+SUM(1,2)",
notes="@SUM(A1:A2)",
external_id="-10+20",
)
resp = client.get("/api/orders/export", headers=auth_headers)
assert resp.status_code == 200, resp.text
rows = list(csv.DictReader(StringIO(resp.text)))
assert len(rows) == 1
assert not rows[0]["customer_name"].startswith(("=", "+", "-", "@"))
assert not rows[0]["candidate_name"].startswith(("=", "+", "-", "@"))
assert rows[0]["notes"] == "'@SUM(A1:A2)"
assert rows[0]["external_id"] == "'-10+20"