"""推广归因测试:点击埋点 → 首启模糊匹配 → 注册一次性消费(全链路离线可验证)""" 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