"""T-201 · Redis 分布式锁双层(Redis 为主、进程内为备)单元测试。 不依赖本机 Redis:所有用例通过 monkeypatch `redis_gateway._gateway` 注入 可控假网关,覆盖「抢到 / 占用超时 / 不可用降级 / 释放只删自己锁 / 三处 key 前缀」。 降级路径任意异常(ConnectionError、Fake 缺方法 AttributeError)一律归类为 unavailable,绝不向上抛——保证 Redis 故障时安全退回进程内锁、业务照常完成。 """ from __future__ import annotations import pytest from app.service.risk import locks from app.service.risk import redis_gateway class FakeLockGateway: """可控假网关:内存字典模拟 SET NX;raise_on_acquire 模拟 Redis 故障。""" def __init__(self, acquire_result: bool = True, raise_on_acquire: Exception | None = None) -> None: self._store: dict[str, str] = {} self.acquire_result = acquire_result self.raise_on_acquire = raise_on_acquire self.captured: list[tuple[str, str, int]] = [] def acquire_lock(self, key: str, token: str, ttl_seconds: int) -> bool: self.captured.append((key, token, ttl_seconds)) if self.raise_on_acquire is not None: raise self.raise_on_acquire if not self.acquire_result: return False if key in self._store: return False self._store[key] = token return True def release_lock(self, key: str, token: str) -> bool: if self._store.get(key) == token: del self._store[key] return True return False class NoAcquireGateway: """测试 Fake 缺方法场景:只有 publish 等旧方法,无 acquire_lock/release_lock。""" def publish(self, channel, payload): pass @pytest.fixture() def gateway(monkeypatch): g = FakeLockGateway() monkeypatch.setattr(redis_gateway, "_gateway", g) return g def test_redis_lock_acquired_runs_locked_true(gateway): """Redis 抢到锁 → fn(locked=True),且 key 带 TTL 与 lock: 前缀。""" seen = {} def _fn(locked: bool) -> dict: seen["locked"] = locked return {"ok": True} result = locks.run_locked("agg:event:CUST-1:2026-09-09", _fn) assert seen["locked"] is True assert result == {"ok": True} key, _token, ttl = gateway.captured[0] assert key == "lock:agg:event:CUST-1:2026-09-09" assert ttl == locks.LOCK_TTL_SECONDS def test_redis_lock_occupied_times_out(gateway, monkeypatch): """锁被占用(抢不到)→ 等待超时降级 fn(locked=False),不抛异常。""" gateway.acquire_result = False monkeypatch.setattr(locks, "LOCK_TIMEOUT_SECONDS", 0.2) monkeypatch.setattr(locks, "_RETRY_INTERVAL", 0.02) seen = {} def _fn(locked: bool) -> str: seen["locked"] = locked return "done" assert locks.run_locked("agg:event:CUST-2:2026-09-09", _fn) == "done" assert seen["locked"] is False def test_redis_connection_error_falls_back_process_lock(gateway): """Redis 抛 ConnectionError → 归类 unavailable → 退回进程内锁,业务完成。""" gateway.raise_on_acquire = ConnectionError("redis down") seen = {} def _fn(locked: bool) -> str: seen["locked"] = locked return "business-done" assert locks.run_locked("agg:suitability:CUST-3:P-9:2026-09-09", _fn) == "business-done" assert seen["locked"] is True # 进程内锁拿到 → locked=True def test_redis_missing_method_attribute_error_falls_back(gateway, monkeypatch): """Fake 缺 acquire_lock(AttributeError)→ 归类 unavailable,测试不红。""" monkeypatch.setattr(redis_gateway, "_gateway", NoAcquireGateway()) seen = {} def _fn(locked: bool) -> str: seen["locked"] = locked return "ok" assert locks.run_locked("l3:CUST-4", _fn) == "ok" assert seen["locked"] is True def test_release_only_owns_lock(): """释放只删自己的锁:错误 token 返回 False 且锁仍在,正确 token 才删。""" g = FakeLockGateway() assert g.acquire_lock("lock:k", "tokA", 30) is True assert g.release_lock("lock:k", "tokB") is False assert "lock:k" in g._store # 锁仍在 assert g.release_lock("lock:k", "tokA") is True assert "lock:k" not in g._store def test_three_call_site_keys_have_lock_prefix(gateway): """三处调用点 key 形态全覆盖:agg:event: / agg:suitability: / l3: 均带 lock: 前缀。""" keys = [ "agg:event:CUST-X:2026-09-09", "agg:suitability:CUST-X:P-Y:2026-09-09", "l3:CUST-X", ] for k in keys: locks.run_locked(k, lambda locked: True) redis_keys = [c[0] for c in gateway.captured] assert all(rk.startswith("lock:") for rk in redis_keys) assert "lock:agg:event:CUST-X:2026-09-09" in redis_keys assert "lock:agg:suitability:CUST-X:P-Y:2026-09-09" in redis_keys assert "lock:l3:CUST-X" in redis_keys # ── T-5 · try_lock(单次非阻塞抢锁,按需用于 convert 幂等/重跑) ──────────── def test_try_lock_acquired_idem_key(gateway): """Redis 抢到 → with 进入 acquired=True,key 带 lock: 前缀(convert:idem:{cid_req} 形态)。""" with locks.try_lock("convert:idem:REQ-1") as acquired: assert acquired is True key, _token, ttl = gateway.captured[0] assert key == "lock:convert:idem:REQ-1" assert ttl == locks.LOCK_TTL_SECONDS def test_try_lock_not_acquired_is_nonblocking(gateway): """锁被占用 → 立即 _NoLock(with 进入 False);非阻塞:只尝试一次、不重试。""" gateway.acquire_result = False with locks.try_lock("convert:rerun:GID-1") as acquired: assert acquired is False # 单次尝试,未进入 run_locked 的轮询循环 assert len(gateway.captured) == 1 def test_try_lock_redis_unavailable_falls_back_local(gateway): """Redis 抛 ConnectionError → 归类 unavailable → 退回进程内锁,acquired=True。""" gateway.raise_on_acquire = ConnectionError("redis down") with locks.try_lock("convert:idem:REQ-2") as acquired: assert acquired is True def test_try_lock_releases_redis_lock_on_exit(gateway): """抢到 Redis 锁后,``with`` 退出即释放(_Token.__exit__ 调 release_lock)。""" assert not gateway._store # 初始无锁 with locks.try_lock("convert:rerun:GID-3") as acquired: assert acquired is True assert gateway.captured[0][0] in gateway._store # 持锁期间在册 assert gateway.captured[0][0] not in gateway._store # 退出后已释放