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

588 lines
20 KiB
Python
Raw Normal View History

2026-06-13 14:49:58 +08:00
"""T6.2 仪表盘端点测试。
覆盖目标
--------
- ``GET /api/stats/dashboard`` 鉴权 (401 / 200)
- 鉴权通过后返回的 payload 形状稳定
- 空库时:
- summary 0
- by_status / by_source / by_service_version 完整 0 填充
- trends.today = 1 , 7d = 7 , 30d = 30 , 全部按日期升序
- generated_at 是合法 ISO8601 UTC
- 真实写入订单后:
- summary.total_orders / total_revenue_cents / orders_7d / 趋势 累加正确
- 收入口径 = 排除 pending refunded
- 窗口边界 (今日 / 7d / 30d) 切分正确
- 趋势 0 填充 : 范围内无订单的日也返回 0
- 业务隔离 : 订单 DB admin DB 是分开的, admin_users 计数独立
"""
from __future__ import annotations
from pathlib import Path
2026-06-13 14:49:58 +08:00
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
def _insert_order(
db_path: str,
*,
order_id: str,
status: str,
source: str = "xianyu",
service_version: str = "basic",
amount_cents: int = 10000,
created_at: str,
) -> None:
"""绕过 OrdersDAO 直接往 orders 表塞测试行 (含 status / amount / created_at)。
orders 表要求必填字段 ; 测试只关心统计字段,其他可空
"""
from admin.db import get_connection
with get_connection(db_path) as conn:
conn.execute(
"""
INSERT INTO orders(
id, source, external_id, service_version, amount_cents,
status, status_updated_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
order_id,
source,
f"ext-{order_id}",
service_version,
int(amount_cents),
status,
created_at,
created_at,
),
)
def _now_utc() -> datetime:
return datetime.now(timezone.utc).replace(microsecond=0)
def _iso_at(d: datetime) -> str:
return d.replace(microsecond=0).isoformat()
# ---------------------------------------------------------------------------
# 鉴权
# ---------------------------------------------------------------------------
def test_dashboard_requires_auth(client):
resp = client.get("/api/stats/dashboard")
assert resp.status_code == 401
def test_dashboard_with_token_returns_200(client, auth_headers):
resp = client.get("/api/stats/dashboard", headers=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"
2026-06-13 14:49:58 +08:00
# ---------------------------------------------------------------------------
# 形状契约 (空库)
# ---------------------------------------------------------------------------
def test_dashboard_empty_db_shape(client, auth_headers):
"""空库时返回完整骨架 — 前端可直接渲染空状态。"""
resp = client.get("/api/stats/dashboard", headers=auth_headers)
body = resp.json()
feat(stats+dashboard): expand pending_orders into 3 actionable dimensions ## 后端 admin/stats.py - compute_summary 在单条聚合 SQL 中加两个新字段: - pending_overdue_24h: status='pending' AND created_at < (now - 24h) (超时未付,需主动催付) - pending_missing_intake: status='pending' AND (order_intakes 无记录 OR order_intakes.status='draft') (资料待补,需主动跟进) - LEFT JOIN order_intakes AS i ON i.order_id = orders.id - 关键 bug 修复: SQL ? 顺序与参数顺序必须匹配, (overdue_cutoff 在 IN 子句后) 之前误用 (overdue_cutoff, *_REVENUE_STATUSES) 导致 overdue_cutoff datetime 字符串误入 IN 子句, 'completed' 误入 created_at < ? 子句, 收入少算 30%。 已修正为 (*_REVENUE_STATUSES, overdue_cutoff) - 列名全部限定为 orders.status / orders.created_at,避免与 i.status 歧义 ## 前端 admin/static/dashboard.html - KPI 4 待处理订单卡加 .pending-breakdown 区域 - 两个 pill 标签: pending-tag-overdue(红色,超时) + pending-tag-missing(橙色,资料待补) - 仅在对应字段 > 0 时才显示 - CSS .pending-tag[hidden] { display: none; } 确保 hidden 属性生效 ## 前端 admin/static/dashboard.js - renderSummary 新增 pending_overdue_24h / pending_missing_intake 渲染 - 至少一个非零才展开 breakdown 区域 ## 测试 - conftest.py orders_db fixture 同时建 order_intakes 表,避免 LEFT JOIN 缺表 - test_routes_stats_dashboard.py: - test_dashboard_empty_db_shape 锁住 summary 必须含 12 字段 - test_dashboard_summary_pending_orders 更新 revenue 期望 (paid + serving 计入) - 新增 test_dashboard_summary_pending_overdue_24h (2天前 vs 12h前) - 新增 test_dashboard_summary_pending_intake (submitted/draft/无 intake 4 种组合) - test_app.py 加 pending-breakdown 结构断言 - pytest 52 passed ## 真实环境验证 - admin/test-pass-123 登录后: pending=7 overdue=0 missing_intake=7 - 视觉验证: KPI 4 显示 '7' 主值 + '资料待补 7' 橙色 pill '超时' pill 因 overdue=0 自动隐藏 - 数据一致性: by_status.pending=7 与 summary.pending_orders=7 一致
2026-06-16 21:48:18 +08:00
# summary 12 字段
2026-06-13 14:49:58 +08:00
assert set(body["summary"].keys()) == {
"total_orders",
"total_revenue_cents",
"total_users",
"pending_orders",
feat(stats+dashboard): expand pending_orders into 3 actionable dimensions ## 后端 admin/stats.py - compute_summary 在单条聚合 SQL 中加两个新字段: - pending_overdue_24h: status='pending' AND created_at < (now - 24h) (超时未付,需主动催付) - pending_missing_intake: status='pending' AND (order_intakes 无记录 OR order_intakes.status='draft') (资料待补,需主动跟进) - LEFT JOIN order_intakes AS i ON i.order_id = orders.id - 关键 bug 修复: SQL ? 顺序与参数顺序必须匹配, (overdue_cutoff 在 IN 子句后) 之前误用 (overdue_cutoff, *_REVENUE_STATUSES) 导致 overdue_cutoff datetime 字符串误入 IN 子句, 'completed' 误入 created_at < ? 子句, 收入少算 30%。 已修正为 (*_REVENUE_STATUSES, overdue_cutoff) - 列名全部限定为 orders.status / orders.created_at,避免与 i.status 歧义 ## 前端 admin/static/dashboard.html - KPI 4 待处理订单卡加 .pending-breakdown 区域 - 两个 pill 标签: pending-tag-overdue(红色,超时) + pending-tag-missing(橙色,资料待补) - 仅在对应字段 > 0 时才显示 - CSS .pending-tag[hidden] { display: none; } 确保 hidden 属性生效 ## 前端 admin/static/dashboard.js - renderSummary 新增 pending_overdue_24h / pending_missing_intake 渲染 - 至少一个非零才展开 breakdown 区域 ## 测试 - conftest.py orders_db fixture 同时建 order_intakes 表,避免 LEFT JOIN 缺表 - test_routes_stats_dashboard.py: - test_dashboard_empty_db_shape 锁住 summary 必须含 12 字段 - test_dashboard_summary_pending_orders 更新 revenue 期望 (paid + serving 计入) - 新增 test_dashboard_summary_pending_overdue_24h (2天前 vs 12h前) - 新增 test_dashboard_summary_pending_intake (submitted/draft/无 intake 4 种组合) - test_app.py 加 pending-breakdown 结构断言 - pytest 52 passed ## 真实环境验证 - admin/test-pass-123 登录后: pending=7 overdue=0 missing_intake=7 - 视觉验证: KPI 4 显示 '7' 主值 + '资料待补 7' 橙色 pill '超时' pill 因 overdue=0 自动隐藏 - 数据一致性: by_status.pending=7 与 summary.pending_orders=7 一致
2026-06-16 21:48:18 +08:00
"pending_overdue_24h",
"pending_missing_intake",
2026-06-13 14:49:58 +08:00
"orders_today",
"orders_7d",
"orders_30d",
"revenue_today_cents",
"revenue_7d_cents",
"revenue_30d_cents",
}
# 订单 / 收入 / 窗口切片都应为 0;total_users 由 bootstrap_admin 决定,
# 这里只要求 >= 1 (conftest 启用了 bootstrap,默认管理员已建)
for k, v in body["summary"].items():
if k == "total_users":
assert v >= 1, k
else:
assert isinstance(v, int)
assert v == 0, k
# 6 态 / 4 来源 / 4 版本 — 完整 0 填充
assert body["by_status"] == {
"pending": 0,
"paid": 0,
"serving": 0,
"delivered": 0,
"completed": 0,
"refunded": 0,
}
assert body["by_source"] == {"xianyu": 0, "wechat": 0, "web": 0, "school": 0}
assert body["by_service_version"] == {
"audit": 0,
"basic": 0,
"standard": 0,
"premium": 0,
}
# 趋势: today=1, 7d=7, 30d=30
assert len(body["trends"]["today"]) == 1
assert len(body["trends"]["7d"]) == 7
assert len(body["trends"]["30d"]) == 30
# generated_at 是 ISO8601 UTC
ts = body["generated_at"]
parsed = datetime.fromisoformat(ts)
assert parsed.tzinfo is not None
assert parsed.utcoffset() is not None
assert parsed.utcoffset().total_seconds() == 0
def test_dashboard_trends_strictly_ascending(client, auth_headers):
"""趋势序列按日期严格升序。"""
resp = client.get("/api/stats/dashboard", headers=auth_headers)
body = resp.json()
for key, expected_len in (("today", 1), ("7d", 7), ("30d", 30)):
points = body["trends"][key]
assert len(points) == expected_len, key
dates = [p["date"] for p in points]
assert dates == sorted(dates), f"{key} not ascending: {dates}"
# 间隔必须正好 1 天 (含 today=1 也只一个点)
for prev, cur in zip(dates, dates[1:]):
d_prev = datetime.fromisoformat(prev).date()
d_cur = datetime.fromisoformat(cur).date()
assert (d_cur - d_prev).days == 1, f"{key} gap: {d_prev}{d_cur}"
def test_dashboard_trends_zero_filled_point_shape(client, auth_headers):
"""0 填充点也必须有完整三字段 (date/orders/revenue_cents)。"""
resp = client.get("/api/stats/dashboard", headers=auth_headers)
body = resp.json()
for key in ("today", "7d", "30d"):
for p in body["trends"][key]:
assert set(p.keys()) == {"date", "orders", "revenue_cents"}, (key, p)
assert p["orders"] == 0
assert p["revenue_cents"] == 0
# ---------------------------------------------------------------------------
# 真实数据
# ---------------------------------------------------------------------------
def _seed_orders(db_path: str, now: datetime) -> List[Dict[str, Any]]:
"""构造一组覆盖各种状态的订单,日期分布在 today / 7d 内 / 30d 内 / 30d 外。
Returns:
写入的订单信息列表 (用于断言)
"""
today_mid = now.replace(hour=12, minute=0, second=0)
within_7d = now - timedelta(days=3) # 4 天前
within_30d = now - timedelta(days=15) # 15 天前
outside_30d = now - timedelta(days=45) # 45 天前 — 应被 30d 窗口排除
seeds: List[Dict[str, Any]] = [
# today 一笔 paid
dict(
order_id="O-today-1",
status="paid",
amount_cents=20000,
created_at=_iso_at(today_mid),
source="xianyu",
),
# 4 天前 serving
dict(
order_id="O-7d-1",
status="serving",
amount_cents=50000,
created_at=_iso_at(within_7d),
source="wechat",
),
# 15 天前 completed
dict(
order_id="O-30d-1",
status="completed",
amount_cents=30000,
created_at=_iso_at(within_30d),
source="web",
),
# 45 天前 (应被 30d 窗口排除) pending
dict(
order_id="O-out-1",
status="pending",
amount_cents=99999,
created_at=_iso_at(outside_30d),
source="xianyu",
),
# today pending (应不计入收入)
dict(
order_id="O-pending-today",
status="pending",
amount_cents=88888,
created_at=_iso_at(today_mid + timedelta(hours=1)),
source="school",
),
# today refunded (应不计入收入)
dict(
order_id="O-refunded-today",
status="refunded",
amount_cents=77777,
created_at=_iso_at(today_mid + timedelta(hours=2)),
source="xianyu",
),
]
for s in seeds:
_insert_order(
db_path,
order_id=s["order_id"],
status=s["status"],
amount_cents=s["amount_cents"],
created_at=s["created_at"],
source=s["source"],
)
return seeds
def test_dashboard_summary_excludes_30d_outside_orders(client, auth_headers, settings):
"""45 天前的订单不在 30d 窗口内,但应计入 total_orders / total_revenue_cents (paid+ 的话)。"""
db = settings.orders_db_path
_seed_orders(db, _now_utc())
resp = client.get("/api/stats/dashboard", headers=auth_headers)
body = resp.json()
summary = body["summary"]
# 总订单:全部 6 笔
assert summary["total_orders"] == 6
# 收入:仅 paid(2w) + serving(5w) + completed(3w) = 10w
# (pending / refunded 不计入)
assert summary["total_revenue_cents"] == 20000 + 50000 + 30000
# today orders: O-today-1(paid) + O-pending-today(pending) + O-refunded-today(refunded) = 3
assert summary["orders_today"] == 3
# today revenue: 仅 O-today-1(20000),pending/refunded 排除
assert summary["revenue_today_cents"] == 20000
# 7d: today(3) + within_7d(1, serving) = 4
assert summary["orders_7d"] == 4
assert summary["revenue_7d_cents"] == 20000 + 50000
# 30d: 7d(4) + within_30d(1, completed) = 5 (45 天前被排除)
assert summary["orders_30d"] == 5
assert summary["revenue_30d_cents"] == 20000 + 50000 + 30000
# 至少 1 个管理员 (bootstrap admin) — 这里具体值不重要,但必须 >= 1
assert summary["total_users"] >= 1
def test_dashboard_by_status_counts_seeded_orders(client, auth_headers, settings):
db = settings.orders_db_path
_seed_orders(db, _now_utc())
resp = client.get("/api/stats/dashboard", headers=auth_headers)
body = resp.json()
# seeds: paid(1) + serving(1) + completed(1) + pending(2) + refunded(1) = 6
assert body["by_status"]["paid"] == 1
assert body["by_status"]["serving"] == 1
assert body["by_status"]["completed"] == 1
assert body["by_status"]["pending"] == 2 # today pending + 45d pending
assert body["by_status"]["refunded"] == 1
assert body["by_status"]["delivered"] == 0
def test_dashboard_by_source_and_service_version(client, auth_headers, settings):
db = settings.orders_db_path
_insert_order(
db,
order_id="A",
status="paid",
source="xianyu",
service_version="basic",
amount_cents=1000,
created_at=_iso_at(_now_utc()),
)
_insert_order(
db,
order_id="B",
status="paid",
source="xianyu",
service_version="standard",
amount_cents=1000,
created_at=_iso_at(_now_utc()),
)
_insert_order(
db,
order_id="C",
status="paid",
source="wechat",
service_version="premium",
amount_cents=1000,
created_at=_iso_at(_now_utc()),
)
resp = client.get("/api/stats/dashboard", headers=auth_headers)
body = resp.json()
assert body["by_source"]["xianyu"] == 2
assert body["by_source"]["wechat"] == 1
assert body["by_source"]["web"] == 0
assert body["by_source"]["school"] == 0
assert body["by_service_version"]["basic"] == 1
assert body["by_service_version"]["standard"] == 1
assert body["by_service_version"]["premium"] == 1
assert body["by_service_version"]["audit"] == 0
def test_dashboard_trends_today_only_contains_today(client, auth_headers, settings):
"""今日 trend 只包含今天一天的聚合,值与 summary.revenue_today_cents 一致。"""
db = settings.orders_db_path
now = _now_utc()
today_iso = now.date().isoformat()
# 2 笔 today paid
_insert_order(
db,
order_id="T1",
status="paid",
amount_cents=1100,
created_at=_iso_at(now.replace(hour=10)),
)
_insert_order(
db,
order_id="T2",
status="paid",
amount_cents=2200,
created_at=_iso_at(now.replace(hour=20)),
)
# 1 笔 5 天前 paid (不在 today 内)
five_ago = (now - timedelta(days=5)).replace(hour=10)
_insert_order(
db,
order_id="T3",
status="paid",
amount_cents=99999,
created_at=_iso_at(five_ago),
)
resp = client.get("/api/stats/dashboard", headers=auth_headers)
body = resp.json()
today_points = body["trends"]["today"]
assert len(today_points) == 1
assert today_points[0]["date"] == today_iso
assert today_points[0]["orders"] == 2
assert today_points[0]["revenue_cents"] == 3300 # 1100 + 2200 (T3 排除)
# 7d 应包含 T3
seven_total_orders = sum(p["orders"] for p in body["trends"]["7d"])
seven_total_revenue = sum(p["revenue_cents"] for p in body["trends"]["7d"])
assert seven_total_orders == 3
assert seven_total_revenue == 1100 + 2200 + 99999
def test_dashboard_trends_zero_fills_gaps(client, auth_headers, settings):
"""窗口内无订单的日也要返回 0 点 (前端不会拿到空洞数组)。"""
db = settings.orders_db_path
now = _now_utc()
five_ago = (now - timedelta(days=5)).replace(hour=10)
_insert_order(
db,
order_id="Lone",
status="paid",
amount_cents=500,
created_at=_iso_at(five_ago),
)
resp = client.get("/api/stats/dashboard", headers=auth_headers)
body = resp.json()
seven = body["trends"]["7d"]
assert len(seven) == 7
# 5 天前那一笔: 恰好 1 个点是 {orders: 1, revenue_cents: 500}
lone_points = [p for p in seven if p["orders"] > 0]
assert len(lone_points) == 1
assert lone_points[0]["orders"] == 1
assert lone_points[0]["revenue_cents"] == 500
# 其余 6 个点全 0
zero_points = [p for p in seven if p["orders"] == 0]
assert len(zero_points) == 6
for p in zero_points:
assert p["revenue_cents"] == 0
def test_dashboard_summary_pending_orders(client, auth_headers, settings):
"""summary.pending_orders 应等于所有 status='pending' 订单数,与其他状态无关。"""
db = settings.orders_db_path
now = _now_utc()
# 3 pending + 2 paid + 1 serving + 1 refunded = 7 笔
_insert_order(db, order_id="P-1", status="pending", created_at=_iso_at(now))
_insert_order(db, order_id="P-2", status="pending", created_at=_iso_at(now))
_insert_order(db, order_id="P-3", status="pending", created_at=_iso_at(now))
_insert_order(db, order_id="PD-1", status="paid", created_at=_iso_at(now))
_insert_order(db, order_id="PD-2", status="paid", created_at=_iso_at(now))
_insert_order(db, order_id="SV-1", status="serving", created_at=_iso_at(now))
_insert_order(db, order_id="RF-1", status="refunded", created_at=_iso_at(now))
resp = client.get("/api/stats/dashboard", headers=auth_headers)
body = resp.json()
assert body["summary"]["pending_orders"] == 3
assert body["summary"]["total_orders"] == 7
# pending / refunded 不计入 revenue; paid + serving 才算
# paid 2 笔 × 10000 + serving 1 笔 × 10000 = 30000
assert body["summary"]["total_revenue_cents"] == 30000
# by_status 的 pending 也应一致,防止两路数据分裂
assert body["by_status"]["pending"] == 3
feat(stats+dashboard): expand pending_orders into 3 actionable dimensions ## 后端 admin/stats.py - compute_summary 在单条聚合 SQL 中加两个新字段: - pending_overdue_24h: status='pending' AND created_at < (now - 24h) (超时未付,需主动催付) - pending_missing_intake: status='pending' AND (order_intakes 无记录 OR order_intakes.status='draft') (资料待补,需主动跟进) - LEFT JOIN order_intakes AS i ON i.order_id = orders.id - 关键 bug 修复: SQL ? 顺序与参数顺序必须匹配, (overdue_cutoff 在 IN 子句后) 之前误用 (overdue_cutoff, *_REVENUE_STATUSES) 导致 overdue_cutoff datetime 字符串误入 IN 子句, 'completed' 误入 created_at < ? 子句, 收入少算 30%。 已修正为 (*_REVENUE_STATUSES, overdue_cutoff) - 列名全部限定为 orders.status / orders.created_at,避免与 i.status 歧义 ## 前端 admin/static/dashboard.html - KPI 4 待处理订单卡加 .pending-breakdown 区域 - 两个 pill 标签: pending-tag-overdue(红色,超时) + pending-tag-missing(橙色,资料待补) - 仅在对应字段 > 0 时才显示 - CSS .pending-tag[hidden] { display: none; } 确保 hidden 属性生效 ## 前端 admin/static/dashboard.js - renderSummary 新增 pending_overdue_24h / pending_missing_intake 渲染 - 至少一个非零才展开 breakdown 区域 ## 测试 - conftest.py orders_db fixture 同时建 order_intakes 表,避免 LEFT JOIN 缺表 - test_routes_stats_dashboard.py: - test_dashboard_empty_db_shape 锁住 summary 必须含 12 字段 - test_dashboard_summary_pending_orders 更新 revenue 期望 (paid + serving 计入) - 新增 test_dashboard_summary_pending_overdue_24h (2天前 vs 12h前) - 新增 test_dashboard_summary_pending_intake (submitted/draft/无 intake 4 种组合) - test_app.py 加 pending-breakdown 结构断言 - pytest 52 passed ## 真实环境验证 - admin/test-pass-123 登录后: pending=7 overdue=0 missing_intake=7 - 视觉验证: KPI 4 显示 '7' 主值 + '资料待补 7' 橙色 pill '超时' pill 因 overdue=0 自动隐藏 - 数据一致性: by_status.pending=7 与 summary.pending_orders=7 一致
2026-06-16 21:48:18 +08:00
def test_dashboard_summary_pending_overdue_24h(client, auth_headers, settings):
"""pending_overdue_24h 应只数 status='pending' 且 created_at < now-24h 的订单。"""
from datetime import timedelta as _td
db = settings.orders_db_path
now = _now_utc()
two_days_ago = now - _td(days=2)
twelve_hours_ago = now - _td(hours=12)
just_now = now
# 2 天前的 pending → 超时
_insert_order(db, order_id="OLD-1", status="pending", created_at=_iso_at(two_days_ago))
_insert_order(db, order_id="OLD-2", status="pending", created_at=_iso_at(two_days_ago))
# 12h 前的 pending → 未超时
_insert_order(db, order_id="NEW-1", status="pending", created_at=_iso_at(twelve_hours_ago))
# 现在的 pending → 未超时
_insert_order(db, order_id="NEW-2", status="pending", created_at=_iso_at(just_now))
# 2 天前但已支付 → 不算 pending
_insert_order(db, order_id="OLD-PAID", status="paid", created_at=_iso_at(two_days_ago))
body = client.get("/api/stats/dashboard", headers=auth_headers).json()
s = body["summary"]
assert s["pending_orders"] == 4
assert s["pending_overdue_24h"] == 2
# 旧 paid 不应误入 overdue
assert body["by_status"]["pending"] == 4
def test_dashboard_summary_pending_missing_intake(client, auth_headers, settings):
"""pending_missing_intake 应数 status='pending' 且 (无 intake 记录 OR intake.status='draft') 的订单。"""
from data.orders.intake_store import SCHEMA_SQL as INTAKE_SCHEMA_SQL
from admin.db import get_connection
db = settings.orders_db_path
conn = get_connection(db)
# 确保 intake 表已建 (conftest 已建,这里只是显式建一次防止测试独立运行)
conn.executescript(INTAKE_SCHEMA_SQL)
conn.commit()
now = _now_utc()
# P-A: pending + 完整 intake(submitted) → 不算缺失
_insert_order(db, order_id="P-A", status="pending", created_at=_iso_at(now))
conn.execute(
"INSERT INTO order_intakes(order_id, status, payload_json, created_at, updated_at, submitted_at) "
"VALUES (?, 'submitted', '{}', ?, ?, ?)",
("P-A", _iso_at(now), _iso_at(now), _iso_at(now)),
)
# P-B: pending + draft intake → 算缺失
_insert_order(db, order_id="P-B", status="pending", created_at=_iso_at(now))
conn.execute(
"INSERT INTO order_intakes(order_id, status, payload_json, created_at, updated_at) "
"VALUES (?, 'draft', '{}', ?, ?)",
("P-B", _iso_at(now), _iso_at(now)),
)
# P-C: pending + 无 intake → 算缺失
_insert_order(db, order_id="P-C", status="pending", created_at=_iso_at(now))
# P-D: paid + draft intake → 不算 (只统计 pending)
_insert_order(db, order_id="P-D", status="paid", created_at=_iso_at(now))
conn.execute(
"INSERT INTO order_intakes(order_id, status, payload_json, created_at, updated_at) "
"VALUES (?, 'draft', '{}', ?, ?)",
("P-D", _iso_at(now), _iso_at(now)),
)
conn.commit()
conn.close()
body = client.get("/api/stats/dashboard", headers=auth_headers).json()
s = body["summary"]
assert s["pending_orders"] == 3
assert s["pending_missing_intake"] == 2 # P-B + P-C
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()
2026-06-13 14:49:58 +08:00
# ---------------------------------------------------------------------------
# /api/stats/orders 兼容老契约
# ---------------------------------------------------------------------------
def test_stats_orders_real_shape_full_fields(client, auth_headers, settings):
"""``/api/stats/orders`` 老端点 5 字段契约,字段名不变,数据真实。"""
db = settings.orders_db_path
now = _now_utc()
_insert_order(
db,
order_id="legacy-1",
status="paid",
amount_cents=12345,
created_at=_iso_at(now),
source="xianyu",
service_version="basic",
)
resp = client.get("/api/stats/orders", headers=auth_headers)
assert resp.status_code == 200
body = resp.json()
assert body["total_orders"] == 1
assert body["total_revenue_cents"] == 12345
assert body["by_status"]["paid"] == 1
assert body["by_source"]["xianyu"] == 1
assert body["by_service_version"]["basic"] == 1
# 旧契约: 不应有 dashboard 专属字段
assert "summary" not in body
assert "trends" not in body
assert "total_users" not in body
# _stub 标记已移除
assert "_stub" not in body