Files

381 lines
13 KiB
Python
Raw Permalink 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.
"""admin 用户行为看板集成用例(真实 PostgreSQL,FR-ADM 行为分析能力)。
覆盖四个端点的字段与分页、X-Admin-Token 鉴权口径与现有 admin 一致
(错误令牌 → 40100)、union_id 掩码(前 4 后 4,防 PII 直出)、配额快照
只读不造行。种子数据用专属随机 user_id,断言全部相对种子而非全表。
"""
from __future__ import annotations
import os
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
ADMIN_TOKEN = "it-admin-token-behavior"
AUTH = {"X-Admin-Token": ADMIN_TOKEN}
BAD_AUTH = {"X-Admin-Token": "wrong-token"}
_SEED_EVENT_NAMES = ("page_view", "outfit_card.tap")
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
@pytest.fixture(autouse=True)
async def _dispose_engine_after_test() -> AsyncIterator[None]:
"""用例结束即释放全局引擎(理由同 test_admin_console.py)。"""
yield
from packages.shared.db import dispose_engine
await dispose_engine()
@pytest.fixture
async def client() -> Any:
import httpx
from apps.admin.app import create_admin_app
from packages.shared.config import Settings
app = create_admin_app(Settings(admin_token=ADMIN_TOKEN))
transport = httpx.ASGITransport(app=app)
async with (
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
app.router.lifespan_context(app),
):
yield http
@pytest.fixture
async def seed() -> dict[str, Any]:
"""造 2 个用户 + 身份 + 事件(7 天内/外各一)+ 报告 + 配额规则与用量。"""
from sqlalchemy import select
from packages.domain.billing.quota import period_key
from packages.domain.ids import next_id
from packages.shared.db import get_session_factory
from packages.shared.models import (
ObEvent,
Quota,
QuotaRule,
RpReport,
User,
UserIdentity,
)
suffix = uuid.uuid4().hex[:8]
user_a, user_b = next_id(), next_id()
union_id_a = f"email:it-user-{suffix}@example.com"
now = datetime.now(UTC)
quota_key = f"it-quota-{suffix}"
day_key = period_key(now, "day")
async with get_session_factory()() as session:
session.add_all(
[
User(
user_id=user_a,
status="active",
nickname=f"it-a-{suffix}",
register_source="email",
created_at=now - timedelta(days=3),
),
User(
user_id=user_b,
status="active",
nickname=f"it-b-{suffix}",
register_source="dev",
created_at=now - timedelta(days=1),
),
UserIdentity(
identity_id=next_id(),
user_id=user_a,
provider="email",
union_id=union_id_a,
),
ObEvent(
event_id=next_id(),
user_id=user_a,
event_name=_SEED_EVENT_NAMES[0],
props={"page": "home"},
server_ts=now - timedelta(hours=2),
),
ObEvent(
event_id=next_id(),
user_id=user_a,
event_name=_SEED_EVENT_NAMES[1],
props={"card_id": 1},
server_ts=now - timedelta(days=1),
),
# 8 天前:在 7 天窗口外——event_count_7d 不该算它
ObEvent(
event_id=next_id(),
user_id=user_a,
event_name=_SEED_EVENT_NAMES[0],
props={"page": "old"},
server_ts=now - timedelta(days=8),
),
ObEvent(
event_id=next_id(),
user_id=user_b,
event_name=_SEED_EVENT_NAMES[0],
props={"page": "home"},
server_ts=now - timedelta(hours=1),
),
RpReport(
report_id=next_id(),
user_id=user_a,
tier="basic",
status="ready",
version=1,
),
QuotaRule(
quota_key=quota_key,
period_type="day",
base_limit=10,
task_bonus_max=0,
hard_cap=20,
),
Quota(
user_id=user_a,
quota_key=quota_key,
period_key=day_key,
used=3,
limit_value=10,
bonus_granted=0,
),
]
)
await session.commit()
rules_before = len(
(await session.execute(select(QuotaRule).where(QuotaRule.quota_key == quota_key)))
.scalars()
.all()
)
return {
"user_a": user_a,
"user_b": user_b,
"quota_key": quota_key,
"day_key": day_key,
"rules_before": rules_before,
"union_id_a": union_id_a,
}
async def test_wrong_token_rejected_like_existing_admin(client: Any) -> None:
"""鉴权口径与现有 admin 一致:错误令牌 → 40100。"""
resp = await client.get("/admin/api/v1/users", headers=BAD_AUTH)
assert resp.status_code == 401
assert resp.json()["code"] == 40100
resp = await client.get("/admin/api/v1/events", headers=BAD_AUTH)
assert resp.status_code == 401
assert resp.json()["code"] == 40100
resp = await client.get("/admin/api/v1/stats/overview", headers=BAD_AUTH)
assert resp.status_code == 401
assert resp.json()["code"] == 40100
# 无令牌头:同样 40100(compare_digest 对空串,不炸 500)
resp = await client.get("/admin/api/v1/users")
assert resp.status_code == 401
assert resp.json()["code"] == 40100
async def test_users_list_fields_and_filters(client: Any, seed: dict[str, Any]) -> None:
resp = await client.get("/admin/api/v1/users", headers=AUTH, params={"query": "email"})
assert resp.status_code == 200
body = resp.json()["data"]
assert body["page"] == 1
assert body["page_size"] == 20
assert body["total"] >= 1
assert all(item["register_source"] == "email" for item in body["items"])
# query 按 user_id 精确:只命中种子用户 A——字段契约全在它身上断言,
# 不受库里其它用户数据/分页位置影响
resp = await client.get(
"/admin/api/v1/users", headers=AUTH, params={"query": str(seed["user_a"])}
)
body = resp.json()["data"]
assert [item["user_id"] for item in body["items"]] == [str(seed["user_a"])]
row_a = body["items"][0]
assert row_a["register_source"] == "email"
assert row_a["created_at"] is not None
assert row_a["last_active_at"] is not None
assert row_a["event_count_7d"] == 2 # 8 天外那条不算
assert row_a["report_count"] == 1
async def test_users_list_pagination(client: Any, seed: dict[str, Any]) -> None:
resp = await client.get(
"/admin/api/v1/users",
headers=AUTH,
params={"query": "dev", "page": 1, "page_size": 1},
)
body = resp.json()["data"]
assert body["page_size"] == 1
assert len(body["items"]) == 1
assert body["total"] >= 1
async def test_user_detail_masks_identity_and_snapshots_quota(
client: Any, seed: dict[str, Any]
) -> None:
from sqlalchemy import select
from packages.shared.db import get_session_factory
from packages.shared.models import Quota, UserIdentity
user_id = seed["user_a"]
resp = await client.get(f"/admin/api/v1/users/{user_id}", headers=AUTH)
assert resp.status_code == 200
data = resp.json()["data"]
# 基本信息
assert data["user"]["user_id"] == str(user_id)
assert data["user"]["register_source"] == "email"
# 身份:provider 明文、union_id 只留前 4 后 4(PII 直出防护,逐字符断言)
assert len(data["identities"]) == 1
ident = data["identities"][0]
assert ident["provider"] == "email"
raw = seed["union_id_a"]
expected = f"{raw[:4]}{'*' * (len(raw) - 8)}{raw[-4:]}"
assert ident["union_id"] == expected
assert raw not in str(data["identities"])
# 配额快照:种子用量 used=3,limit=base 10,remaining=7
quota_rows = [q for q in data["quotas"] if q["quota_key"] == seed["quota_key"]]
assert len(quota_rows) == 1
assert quota_rows[0]["used"] == 3
assert quota_rows[0]["limit"] == 10
assert quota_rows[0]["remaining"] == 7
assert quota_rows[0]["period_key"] == seed["day_key"]
# 只读断言:看板不产生写副作用(身份与计量行数不变)
async with get_session_factory()() as session:
idents = (
(await session.execute(select(UserIdentity).where(UserIdentity.user_id == user_id)))
.scalars()
.all()
)
quotas = (
(
await session.execute(
select(Quota).where(
Quota.user_id == user_id, Quota.quota_key == seed["quota_key"]
)
)
)
.scalars()
.all()
)
assert len(idents) == 1
assert len(quotas) == 1
assert quotas[0].used == 3
# 最近事件/报告:各落一条种子数据
assert len(data["recent_events"]) == 3 # 2 条 7 天内 + 1 条 8 天前(窗口不按详情截)
assert data["recent_events"][0]["server_ts"] >= data["recent_events"][-1]["server_ts"]
assert len(data["recent_reports"]) == 1
report = data["recent_reports"][0]
assert report["tier"] == "basic"
assert report["status"] == "ready"
assert report["version"] == 1
assert report["report_id"]
async def test_user_detail_not_found(client: Any) -> None:
resp = await client.get("/admin/api/v1/users/0", headers=AUTH)
assert resp.status_code == 404
assert resp.json()["code"] == 40400
async def test_events_stream_filters_and_pagination(client: Any, seed: dict[str, Any]) -> None:
user_id = seed["user_a"]
# user_id 过滤:只出种子用户的事件,server_ts 倒序
resp = await client.get(
"/admin/api/v1/events",
headers=AUTH,
params={"user_id": user_id, "page_size": 2, "page": 1},
)
assert resp.status_code == 200
body = resp.json()["data"]
assert body["total"] == 3
assert len(body["items"]) == 2
assert all(item["user_id"] == str(user_id) for item in body["items"])
first, second = body["items"][0]["server_ts"], body["items"][1]["server_ts"]
assert first >= second
# 翻第二页拿到最早那条(8 天前),证明分页生效
resp = await client.get(
"/admin/api/v1/events",
headers=AUTH,
params={"user_id": user_id, "page_size": 2, "page": 2},
)
page2 = resp.json()["data"]
assert len(page2["items"]) == 1
# event_name 过滤 + ISO 时间范围:8 天前那条被 start 挡在窗外
start = (datetime.now(UTC) - timedelta(days=7)).isoformat()
resp = await client.get(
"/admin/api/v1/events",
headers=AUTH,
params={"user_id": user_id, "event_name": _SEED_EVENT_NAMES[0], "start": start},
)
body = resp.json()["data"]
assert body["total"] == 1
assert body["items"][0]["props"] == {"page": "home"}
# epoch 秒也接受(与 ISO 等价窗口)
epoch_start = str(int((datetime.now(UTC) - timedelta(days=7)).timestamp()))
resp = await client.get(
"/admin/api/v1/events",
headers=AUTH,
params={"user_id": user_id, "event_name": _SEED_EVENT_NAMES[0], "start": epoch_start},
)
assert resp.json()["data"]["total"] == 1
# page_size 超上限 → 422(契约上限 200)
resp = await client.get("/admin/api/v1/events", headers=AUTH, params={"page_size": 201})
assert resp.status_code == 422
async def test_stats_overview_pivot_and_series(client: Any, seed: dict[str, Any]) -> None:
resp = await client.get("/admin/api/v1/stats/overview", headers=AUTH, params={"days": 7})
assert resp.status_code == 200
data = resp.json()["data"]
assert data["days"] == 7
assert data["registered_total"] >= 2
# 透视表:种子用户的两个事件名都出现,PV 至少为种子条数
pv = {(row["event_name"]) for row in data["daily_event_pv"]}
assert _SEED_EVENT_NAMES[0] in pv
assert _SEED_EVENT_NAMES[1] in pv
page_view_pv = sum(
row["pv"] for row in data["daily_event_pv"] if row["event_name"] == _SEED_EVENT_NAMES[0]
)
assert page_view_pv >= 2 # 种子 A、B 各至少一条(库内其它数据只会更多)
# 活跃序列:至少一天有活跃用户
assert data["daily_active_users"]
assert max(row["active_users"] for row in data["daily_active_users"]) >= 1
# days 超上限 → 422
resp = await client.get("/admin/api/v1/stats/overview", headers=AUTH, params={"days": 91})
assert resp.status_code == 422