8f20d8d9a2
Co-Authored-By: Claude Code <noreply@anthropic.com>
240 lines
12 KiB
Python
240 lines
12 KiB
Python
import pytest
|
||
from datetime import timedelta
|
||
from django.contrib.auth import get_user_model
|
||
from django.utils import timezone
|
||
from rest_framework.test import APIClient
|
||
from tracker.models import Event, Site, SourceClassification, VisitSession, Visitor
|
||
from tracker.services.stats import refresh_daily
|
||
|
||
pytestmark = pytest.mark.django_db
|
||
User = get_user_model()
|
||
|
||
# 用上海本地时刻做基准:日界是上海自然日(make_aware/localdate),
|
||
# 直接用 UTC 的 now 在本地 00:00-08:00 之间跑测试会错一天
|
||
NOW = timezone.localtime(timezone.now())
|
||
TODAY = NOW.date()
|
||
YESTERDAY = TODAY - timedelta(days=1)
|
||
AT_3D = NOW - timedelta(days=3) # 7d 当前窗口内(近 3 天)
|
||
AT_10D = NOW - timedelta(days=10) # 7d 上期窗口内(前 7 天)
|
||
|
||
|
||
@pytest.fixture
|
||
def client(): # 覆盖 pytest-django 的 Django Client——认证用例需要 credentials()
|
||
return APIClient()
|
||
|
||
|
||
@pytest.fixture
|
||
def member_user():
|
||
return User.objects.create_user(username="member", password="memberpass123")
|
||
|
||
|
||
def _auth(client, user):
|
||
from rest_framework_simplejwt.tokens import RefreshToken
|
||
token = RefreshToken.for_user(user)
|
||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token.access_token}")
|
||
|
||
|
||
def _visitor(site, vid, last_seen=None, first_seen=None):
|
||
v = Visitor.objects.create(site=site, visitor_id=vid)
|
||
# first/last_seen_at 是 auto_now_add,只能 queryset.update 绕开
|
||
fields = {}
|
||
if last_seen is not None:
|
||
fields["last_seen_at"] = last_seen
|
||
if first_seen is not None:
|
||
fields["first_seen_at"] = first_seen
|
||
if fields:
|
||
Visitor.objects.filter(pk=v.pk).update(**fields)
|
||
return v
|
||
|
||
|
||
def _session(site, visitor, sid, referrer="", at=NOW, stay_ms=0):
|
||
return VisitSession.objects.create(site=site, visitor=visitor, session_id=sid,
|
||
started_at=at, ended_at=at + timedelta(minutes=5), referrer_domain=referrer,
|
||
stay_ms=stay_ms)
|
||
|
||
|
||
def _events(site, visitor, session, n, path, at=NOW, prefix="e"):
|
||
for i in range(n):
|
||
Event.objects.create(site=site, visitor=visitor, session=session,
|
||
event_id=f"{prefix}-{i}", event_name="page_view",
|
||
occurred_at=at + timedelta(seconds=i), path=path)
|
||
|
||
|
||
def test_overview_requires_auth(client):
|
||
assert client.get("/api/admin/dashboard/overview").status_code == 401
|
||
|
||
|
||
def test_overview_7d_cards_and_deltas(client, member_user):
|
||
"""7d 四卡指标 + delta 正负 + 上期零(returning)为 null;爬虫全口径排除。"""
|
||
_auth(client, member_user)
|
||
site = Site.objects.create(name="s", domains=["a.com"])
|
||
# 本期(近 7 天):v1/v2 今天首访(新访客),v3 是 10 天前的老访客 3 天前回访
|
||
v1 = _visitor(site, "v1", last_seen=timezone.now())
|
||
v2 = _visitor(site, "v2", last_seen=timezone.now())
|
||
v3 = _visitor(site, "v3", last_seen=timezone.now(), first_seen=AT_10D)
|
||
s1 = _session(site, v1, "s1", stay_ms=60000)
|
||
s2 = _session(site, v2, "s2", stay_ms=30000)
|
||
s3 = _session(site, v3, "s3", at=AT_3D) # stay 0
|
||
_events(site, v1, s1, 2, "/a", prefix="a")
|
||
_events(site, v2, s2, 1, "/b", prefix="b")
|
||
_events(site, v3, s3, 1, "/c", at=AT_3D, prefix="c")
|
||
# 上期(前 7 天):v3 首访 + v4 三次会话——上期 visitors=2 / visits=4 / avg=20000
|
||
sp3 = _session(site, v3, "sp3", at=AT_10D, stay_ms=10000)
|
||
_events(site, v3, sp3, 1, "/old3", at=AT_10D, prefix="o3")
|
||
v4 = _visitor(site, "v4", last_seen=AT_10D, first_seen=AT_10D)
|
||
for i, stay in enumerate((20000, 30000, 20000)):
|
||
sp = _session(site, v4, f"sp4-{i}", at=AT_10D, stay_ms=stay)
|
||
_events(site, v4, sp, 1, "/old4", at=AT_10D, prefix=f"o4{i}")
|
||
# 爬虫:今天有大额停留与事件,任何口径都不得计入(事件行同步标记 is_bot,与 ingest 同口径)
|
||
bot = _visitor(site, "bot-v", last_seen=timezone.now())
|
||
Visitor.objects.filter(pk=bot.pk).update(is_bot=True)
|
||
sb = _session(site, bot, "sb", stay_ms=999000)
|
||
_events(site, bot, sb, 5, "/bot", prefix="bt")
|
||
Event.objects.filter(visitor=bot).update(is_bot=True)
|
||
|
||
r = client.get("/api/admin/dashboard/overview") # 缺省 range=7d
|
||
assert r.status_code == 200
|
||
d = r.json()["data"]
|
||
# 本期:visitors=3(v1/v2/v3)、visits=3、avg=(60k+30k+0)/3=30k、returning=3-2 新=1
|
||
# 上期:visitors=2(v3/v4)、visits=4、avg=80k/4=20k、returning=2-2 新=0 → delta null
|
||
assert d["cards"]["visitors"] == {"value": 3, "delta_pct": 50.0} # 正
|
||
assert d["cards"]["visits"] == {"value": 3, "delta_pct": -25.0} # 负
|
||
assert d["cards"]["avg_stay_ms"] == {"value": 30000, "delta_pct": 50.0}
|
||
assert d["cards"]["returning"] == {"value": 1, "delta_pct": None} # 上期 0 → null
|
||
# 在线:v1/v2/v3 活跃(bot 排除、v4 十天前不在线)
|
||
assert d["header"] == {"online": 3, "active_now": 3} # active_now 兼容保留
|
||
# 趋势:7 点日粒度,历史无 DailyStat 补零,今天一位用实时值(uv=2、sessions=2,bot 排除)
|
||
trend = d["trend"]
|
||
assert len(trend) == 7
|
||
assert trend[0]["date"] == (TODAY - timedelta(days=6)).isoformat()
|
||
assert trend[-1] == {"date": TODAY.isoformat(), "uv": 2, "sessions": 2}
|
||
assert trend[-2] == {"date": YESTERDAY.isoformat(), "uv": 0, "sessions": 0}
|
||
assert d["channels"] == [] # 无分类行 → 空列表
|
||
|
||
|
||
def test_overview_prev_zero_delta_null(client, member_user):
|
||
"""上期完全无数据:四卡 delta_pct 全为 null,卡值正常。"""
|
||
_auth(client, member_user)
|
||
site = Site.objects.create(name="s", domains=["a.com"])
|
||
v1 = _visitor(site, "v1", last_seen=timezone.now())
|
||
s1 = _session(site, v1, "s1", stay_ms=50000)
|
||
_events(site, v1, s1, 2, "/a", prefix="a")
|
||
|
||
d = client.get("/api/admin/dashboard/overview").json()["data"]
|
||
assert d["cards"] == {
|
||
"visitors": {"value": 1, "delta_pct": None},
|
||
"visits": {"value": 1, "delta_pct": None},
|
||
"avg_stay_ms": {"value": 50000, "delta_pct": None},
|
||
"returning": {"value": 0, "delta_pct": None}, # 新访客 → 回访 0,同样上期零
|
||
}
|
||
|
||
|
||
def test_overview_channels_pct(client, member_user):
|
||
"""channels:窗口内 session_entry 分类聚合,pct 占已分类会话比、总和 ≈ 1。"""
|
||
_auth(client, member_user)
|
||
site = Site.objects.create(name="s", domains=["a.com"])
|
||
v1 = _visitor(site, "v1", last_seen=timezone.now())
|
||
v2 = _visitor(site, "v2", last_seen=timezone.now())
|
||
s1 = _session(site, v1, "s1", referrer="www.bing.com")
|
||
s2 = _session(site, v1, "s2", referrer="www.bing.com", at=AT_3D)
|
||
s3 = _session(site, v2, "s3", referrer="chatgpt.com")
|
||
s4 = _session(site, v2, "s4") # 直接访问
|
||
s5 = _session(site, v2, "s5", referrer="t.co", at=AT_10D) # 上期:不计入
|
||
s6 = _session(site, v2, "s6", referrer="example.com") # 无分类行:不计入
|
||
for s, ch, pf in ((s1, "natural_search", "Bing"), (s2, "natural_search", "Bing"),
|
||
(s3, "ai_referral", "ChatGPT"), (s4, "direct", "")):
|
||
SourceClassification.objects.create(site=site, visitor=s.visitor, session=s,
|
||
scope="session_entry", channel=ch, platform=pf)
|
||
SourceClassification.objects.create(site=site, visitor=v2, session=s5,
|
||
scope="session_entry", channel="social", platform="Twitter")
|
||
_events(site, v1, s1, 1, "/", prefix="x")
|
||
|
||
d = client.get("/api/admin/dashboard/overview").json()["data"]
|
||
assert d["channels"] == [
|
||
{"channel": "natural_search", "sessions": 2, "pct": pytest.approx(0.5)},
|
||
{"channel": "ai_referral", "sessions": 1, "pct": pytest.approx(0.25)},
|
||
{"channel": "direct", "sessions": 1, "pct": pytest.approx(0.25)}, # 平局按名稳定
|
||
]
|
||
assert sum(c["pct"] for c in d["channels"]) == pytest.approx(1.0)
|
||
|
||
|
||
def test_overview_range_today(client, member_user):
|
||
"""range=today:卡按今天口径、delta 比昨天;趋势单点;非法 range 回落 7d。"""
|
||
_auth(client, member_user)
|
||
site = Site.objects.create(name="s", domains=["a.com"])
|
||
vy = _visitor(site, "vy", last_seen=timezone.now(), first_seen=NOW - timedelta(days=1))
|
||
sy = _session(site, vy, "sy", at=NOW - timedelta(days=1), stay_ms=60000)
|
||
_events(site, vy, sy, 2, "/y", at=NOW - timedelta(days=1), prefix="y")
|
||
vt = _visitor(site, "vt", last_seen=timezone.now())
|
||
st = _session(site, vt, "st", stay_ms=30000)
|
||
_events(site, vt, st, 1, "/t", prefix="t")
|
||
|
||
d = client.get("/api/admin/dashboard/overview", {"range": "today"}).json()["data"]
|
||
assert d["trend"] == [{"date": TODAY.isoformat(), "uv": 1, "sessions": 1}]
|
||
assert d["cards"]["visitors"] == {"value": 1, "delta_pct": 0.0} # 昨天也是 1
|
||
assert d["cards"]["visits"] == {"value": 1, "delta_pct": 0.0}
|
||
assert d["cards"]["avg_stay_ms"] == {"value": 30000, "delta_pct": -50.0}
|
||
assert d["cards"]["returning"]["delta_pct"] is None # 两天都是全新访客
|
||
|
||
assert len(client.get("/api/admin/dashboard/overview").json()["data"]["trend"]) == 7
|
||
d = client.get("/api/admin/dashboard/overview", {"range": "abc"}).json()["data"]
|
||
assert len(d["trend"]) == 7 # 非法值回落 7d
|
||
d = client.get("/api/admin/dashboard/overview", {"range": "30d"}).json()["data"]
|
||
assert len(d["trend"]) == 30
|
||
|
||
|
||
def test_overview_trend_uses_dailystat_history(client, member_user):
|
||
"""7d 趋势历史位读 DailyStat 聚合(跨站求和),今天位用实时值。"""
|
||
_auth(client, member_user)
|
||
site_a = Site.objects.create(name="a", domains=["a.com"])
|
||
site_b = Site.objects.create(name="b", domains=["b.com"])
|
||
yn = NOW - timedelta(days=1) # 昨天同一本地时刻,必落上海昨日窗口
|
||
va = Visitor.objects.create(site=site_a, visitor_id="va")
|
||
sa = _session(site_a, va, "sa", at=yn)
|
||
_events(site_a, va, sa, 3, "/", at=yn, prefix="ya")
|
||
refresh_daily(site_a, YESTERDAY)
|
||
vb = Visitor.objects.create(site=site_b, visitor_id="vb")
|
||
sb = _session(site_b, vb, "sb", at=yn)
|
||
_events(site_b, vb, sb, 4, "/", at=yn, prefix="yb")
|
||
refresh_daily(site_b, YESTERDAY)
|
||
|
||
d = client.get("/api/admin/dashboard/overview").json()["data"]
|
||
trend = d["trend"]
|
||
assert len(trend) == 7
|
||
assert trend[-2] == {"date": YESTERDAY.isoformat(), "uv": 2, "sessions": 2} # 跨站求和
|
||
assert trend[-1] == {"date": TODAY.isoformat(), "uv": 0, "sessions": 0} # 今天无事件
|
||
|
||
|
||
def test_overview_site_filter_and_404(client, member_user):
|
||
_auth(client, member_user)
|
||
site_a = Site.objects.create(name="a", domains=["a.com"])
|
||
site_b = Site.objects.create(name="b", domains=["b.com"])
|
||
va = _visitor(site_a, "va", last_seen=timezone.now())
|
||
vb = _visitor(site_b, "vb", last_seen=timezone.now())
|
||
sa = _session(site_a, va, "sa", referrer="google.com")
|
||
sb = _session(site_b, vb, "sb")
|
||
_events(site_a, va, sa, 2, "/", prefix="a")
|
||
_events(site_b, vb, sb, 5, "/b", prefix="b")
|
||
SourceClassification.objects.create(site=site_b, visitor=vb, session=sb,
|
||
scope="session_entry", channel="direct", resolution="unknown")
|
||
# a 站昨天有 DailyStat 行——过滤 site_b 时不得混入
|
||
yn = NOW - timedelta(days=1)
|
||
_events(site_a, va, sa, 3, "/", at=yn, prefix="ay")
|
||
refresh_daily(site_a, YESTERDAY)
|
||
|
||
d = client.get("/api/admin/dashboard/overview").json()["data"]
|
||
assert d["cards"]["visitors"]["value"] == 2 # 缺省聚合全部站点
|
||
assert d["header"]["online"] == 2
|
||
d = client.get("/api/admin/dashboard/overview", {"site": site_b.site_id}).json()["data"]
|
||
assert d["cards"]["visitors"]["value"] == 1 and d["cards"]["visits"]["value"] == 1
|
||
assert d["header"]["online"] == 1 # 只计本站
|
||
assert d["channels"] == [{"channel": "direct", "sessions": 1,
|
||
"pct": pytest.approx(1.0)}]
|
||
assert d["trend"][-2] == {"date": YESTERDAY.isoformat(), "uv": 0, "sessions": 0}
|
||
assert d["trend"][-1] == {"date": TODAY.isoformat(), "uv": 1, "sessions": 1}
|
||
|
||
r = client.get("/api/admin/dashboard/overview", {"site": "site_nope"})
|
||
assert r.status_code == 404
|
||
body = r.json()
|
||
assert body["code"] == 404 and body["data"] is None and body["message"] == "site_not_found"
|