140 lines
4.8 KiB
Python
140 lines
4.8 KiB
Python
"""推广归因测试:点击埋点 → 首启模糊匹配 → 注册一次性消费(全链路离线可验证)"""
|
|
import pytest
|
|
|
|
from app.services import invite_service
|
|
|
|
|
|
class FakeRedis:
|
|
"""内存版 Redis 桩:仅实现归因模块用到的命令子集"""
|
|
|
|
def __init__(self):
|
|
self.kv: dict[str, str] = {}
|
|
self.zsets: dict[str, dict[str, float]] = {}
|
|
self.ttls: dict[str, int] = {}
|
|
|
|
async def set(self, key, value, nx=False, ex=None):
|
|
if nx and key in self.kv:
|
|
return None
|
|
self.kv[key] = str(value)
|
|
if ex is not None:
|
|
self.ttls[key] = ex
|
|
return True
|
|
|
|
async def get(self, key):
|
|
return self.kv.get(key)
|
|
|
|
async def delete(self, key):
|
|
self.kv.pop(key, None)
|
|
self.zsets.pop(key, None)
|
|
|
|
async def expire(self, key, ttl):
|
|
self.ttls[key] = ttl
|
|
return True
|
|
|
|
async def zadd(self, key, mapping):
|
|
self.zsets.setdefault(key, {}).update({m: float(s) for m, s in mapping.items()})
|
|
return len(mapping)
|
|
|
|
async def zrevrange(self, key, start, stop):
|
|
items = sorted(self.zsets.get(key, {}).items(), key=lambda kv: kv[1], reverse=True)
|
|
members = [m for m, _ in items]
|
|
if stop < 0:
|
|
stop = len(members) + stop # Redis 负索引语义:-1 为最后一个元素
|
|
return members[start:stop + 1]
|
|
|
|
|
|
@pytest.fixture
|
|
def redis():
|
|
return FakeRedis()
|
|
|
|
|
|
IP = "203.0.113.8"
|
|
UA = "Mozilla/5.0 (Linux; Android 14)"
|
|
CODE_A, CODE_B = "AAAA2222", "BBBB3333"
|
|
DEVICE = "dev-uuid-0001"
|
|
|
|
|
|
# ---------- 点击埋点 ----------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_click_writes_ip_zset_and_detail(redis):
|
|
r = await invite_service.record_click(redis, CODE_A, IP, UA)
|
|
assert r == {"recorded": True}
|
|
assert await redis.zrevrange(invite_service._ip_key(IP), 0, -1) == [CODE_A]
|
|
detail = await redis.get(invite_service._clk_key(CODE_A, IP))
|
|
assert detail and UA in detail
|
|
# 两个 key 都设置了 24h TTL
|
|
assert redis.ttls[invite_service._ip_key(IP)] == invite_service.CLICK_TTL
|
|
assert redis.ttls[invite_service._clk_key(CODE_A, IP)] == invite_service.CLICK_TTL
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_click_dedup_within_60s(redis):
|
|
await invite_service.record_click(redis, CODE_A, IP, UA)
|
|
r2 = await invite_service.record_click(redis, CODE_A, IP, UA)
|
|
assert r2 == {"recorded": True, "dedup": True}
|
|
assert len(redis.zsets[invite_service._ip_key(IP)]) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_click_without_ip_not_recorded(redis):
|
|
assert (await invite_service.record_click(redis, CODE_A, "", UA)) == {"recorded": False}
|
|
|
|
|
|
# ---------- 归因匹配 ----------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_match_returns_clicked_code(redis):
|
|
await invite_service.record_click(redis, CODE_A, IP, UA)
|
|
r = await invite_service.match_and_cache(redis, DEVICE, IP, UA)
|
|
assert r == {"matched": True, "invite_code": CODE_A}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_match_most_recent_click_wins(redis):
|
|
await invite_service.record_click(redis, CODE_A, IP, UA)
|
|
await invite_service.record_click(redis, CODE_B, IP, UA) # 时间更晚
|
|
r = await invite_service.match_and_cache(redis, DEVICE, IP, UA)
|
|
assert r["invite_code"] == CODE_B
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_match_no_click_unmatched(redis):
|
|
assert (await invite_service.match_and_cache(redis, DEVICE, IP, UA)) == {"matched": False}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_match_skips_candidate_missing_detail(redis):
|
|
"""zset 中有 code 但点击详情已过期 → 跳到下一候选"""
|
|
await invite_service.record_click(redis, CODE_A, IP, UA)
|
|
await invite_service.record_click(redis, CODE_B, IP, UA)
|
|
await redis.delete(invite_service._clk_key(CODE_B, IP)) # 详情丢失
|
|
r = await invite_service.match_and_cache(redis, DEVICE, IP, UA)
|
|
assert r["invite_code"] == CODE_A
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_match_idempotent_via_cache(redis):
|
|
await invite_service.record_click(redis, CODE_A, IP, UA)
|
|
await invite_service.match_and_cache(redis, DEVICE, IP, UA)
|
|
# 点击记录清除后,归因缓存仍命中(同设备重复首启不重复计算)
|
|
await redis.delete(invite_service._ip_key(IP))
|
|
r = await invite_service.match_and_cache(redis, DEVICE, IP, UA)
|
|
assert r == {"matched": True, "invite_code": CODE_A}
|
|
|
|
|
|
# ---------- 注册消费 ----------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pop_attribution_one_time(redis):
|
|
await invite_service.record_click(redis, CODE_A, IP, UA)
|
|
await invite_service.match_and_cache(redis, DEVICE, IP, UA)
|
|
assert await invite_service.pop_attribution(redis, DEVICE) == CODE_A
|
|
# 一次性消费:第二次取不到
|
|
assert await invite_service.pop_attribution(redis, DEVICE) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pop_attribution_empty(redis):
|
|
assert await invite_service.pop_attribution(redis, "never-matched") is None
|