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