Files
gaokao-volunteer-system/admin/tests/test_routes_stats_dashboard.py
Hermes Agent 3f46811e49
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
release: cut v2.1
2026-06-13 14:49:58 +08:00

462 lines
15 KiB
Python

"""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 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_dashboard_empty_db_shape(client, auth_headers):
"""空库时返回完整骨架 — 前端可直接渲染空状态。"""
resp = client.get("/api/stats/dashboard", headers=auth_headers)
body = resp.json()
# summary 9 字段
assert set(body["summary"].keys()) == {
"total_orders",
"total_revenue_cents",
"total_users",
"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
# ---------------------------------------------------------------------------
# /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