"""profile_l3 单测(B3 · 最高档合并防降级 / AML 后大额不回落 / 并发首单)。 sqlite StaticPool 单连接共享内存库(同 test_alert_service 模式); IntegrityError / 乐观锁丢竞态用 monkeypatch 模拟跨进程交错。 risk_score 口径:一期不写(恒 NULL,归 R-05 评分模型首写,评审 P3-4 用户拍板)。 """ from datetime import datetime import pytest from sqlalchemy import text from _ddl import create_sqlite_engine from app.repository.risk_repository import RiskRepository from app.service.risk import redis_gateway from app.service.risk.profile_l3 import ( AML_PENDING_TAG, ALERT_TYPE_TIER, get_profile_l3, highest_tier, merge_l3, tier_of, upsert_profile_l3, ) @pytest.fixture(autouse=True) def _fake_redis(monkeypatch): """L3 写侧 DEL 钩子隔离(B7 挂账②)+ 行为断言记录(B7 复审 P2-1)。""" class _FakeGateway: def __init__(self): self.messages = [] self.deletes = [] def publish(self, channel, payload): self.messages.append((channel, payload)) def delete(self, *keys): self.deletes.append(keys) fake = _FakeGateway() monkeypatch.setattr(redis_gateway, "_gateway", fake) return fake @pytest.fixture() def env(): engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12) repo = RiskRepository(engine=engine) yield repo, engine engine.dispose() def _upsert(repo, cid, alert_type, alert_id=None, tags=None, dims=None, computed_at=None): return upsert_profile_l3( cid, alert_type, monitor_tags=tags, last_alert_id=alert_id, score_dimensions=dims, computed_at=computed_at, risk_repo=repo, ) def _row(engine, cid): with engine.connect() as conn: return conn.execute( text( "SELECT monitor_tier, risk_score, monitor_tags, last_alert_id, computed_at" " FROM customer_profile_l3 WHERE customer_id = :cid" ), {"cid": cid}, ).mappings().one() # ---------- 映射与纯函数 ---------- @pytest.mark.parametrize(("alert_type", "tier"), sorted(ALERT_TYPE_TIER.items())) def test_alert_type_tier_mapping(alert_type, tier): assert tier_of(alert_type) == tier def test_unknown_alert_type_rejected(env): repo, _ = env with pytest.raises(ValueError): _upsert(repo, "C1", "unknown_type") def test_highest_tier_order(): assert highest_tier("normal", "watch") == "watch" assert highest_tier("watch", "high") == "high" assert highest_tier("high", "normal") == "high" assert highest_tier("normal", "normal") == "normal" def test_merge_new_customer(): merged = merge_l3(None, mapped_tier="watch", monitor_tags=["t1"], last_alert_id="ALT-1") assert merged["monitor_tier"] == "watch" assert merged["risk_score"] is None # 一期不写(P3-4 口径) assert merged["monitor_tags"] == ["t1"] assert merged["last_alert_id"] == "ALT-1" assert merged["computed_at"] is not None def test_merge_risk_score_stays_null_even_if_existing_has_value(): """历史行若已有 score(异常数据),合并时也不维护/不传播。""" existing = {"monitor_tier": "watch", "risk_score": 90, "monitor_tags": [], "score_dimensions": {}} merged = merge_l3(existing, mapped_tier="watch", monitor_tags=[], last_alert_id="ALT-2") assert merged["risk_score"] is None # ---------- upsert:首写与合并 ---------- def test_first_event_inserts(env): repo, engine = env merged = _upsert(repo, "C1", "large_amount", alert_id="ALT-1") assert merged["monitor_tier"] == "watch" and merged["customer_id"] == "C1" row = _row(engine, "C1") assert row["monitor_tier"] == "watch" and row["risk_score"] is None assert row["last_alert_id"] == "ALT-1" and row["computed_at"] is not None assert get_profile_l3("C1", risk_repo=repo)["monitor_tier"] == "watch" def test_normal_upgrades_to_watch(env): repo, _ = env _upsert(repo, "C1", "suitability", alert_id="ALT-0") merged = _upsert(repo, "C1", "pattern", alert_id="ALT-1") assert merged["monitor_tier"] == "watch" # normal → watch 升档 def test_watch_does_not_degrade_to_normal(env): """suitability 映射 normal:已 watch 的客户不被 suitability 事件拉低。""" repo, engine = env _upsert(repo, "C1", "pattern", alert_id="ALT-1") merged = _upsert(repo, "C1", "suitability", alert_id="ALT-2") assert merged["monitor_tier"] == "watch" assert _row(engine, "C1")["monitor_tier"] == "watch" def test_aml_marks_high_with_pending_review_tag(env): repo, engine = env merged = _upsert(repo, "C1", "aml", alert_id="ALT-1") assert merged["monitor_tier"] == "high" assert AML_PENDING_TAG in merged["monitor_tags"] assert AML_PENDING_TAG in _row(engine, "C1")["monitor_tags"] def test_high_does_not_degrade_to_normal(env): """B3 验收(评审 P2-4):aml 后 suitability(映射 normal)不回落。""" repo, engine = env _upsert(repo, "C1", "aml", alert_id="ALT-1") merged = _upsert(repo, "C1", "suitability", alert_id="ALT-2") assert merged["monitor_tier"] == "high" assert _row(engine, "C1")["monitor_tier"] == "high" def test_large_amount_after_aml_does_not_fall_back(env): """B3 验收:AML 后大额 → tier 仍 high、tags 并集、risk_score 保持 NULL。""" repo, engine = env _upsert(repo, "C1", "aml", alert_id="ALT-1") merged = _upsert(repo, "C1", "large_amount", alert_id="ALT-2", tags=["manual_review"]) assert merged["monitor_tier"] == "high" assert merged["risk_score"] is None assert set(merged["monitor_tags"]) == {AML_PENDING_TAG, "manual_review"} assert merged["last_alert_id"] == "ALT-2" # 联动最新 row = _row(engine, "C1") assert row["monitor_tier"] == "high" and row["risk_score"] is None def test_tags_accumulate_not_overwrite(env): repo, _ = env _upsert(repo, "C1", "pattern", alert_id="ALT-1", tags=["freq"]) merged = _upsert(repo, "C1", "pattern", alert_id="ALT-2", tags=["manual_review"]) assert merged["monitor_tags"] == ["freq", "manual_review"] # 不同 tag 跨事件追加 def test_last_alert_id_kept_when_not_passed(env): """评审 P2-2:漏传 last_alert_id 不抹掉旧值(FR-7 联动语义防御)。""" repo, engine = env _upsert(repo, "C1", "aml", alert_id="ALT-1") merged = upsert_profile_l3("C1", "large_amount", risk_repo=repo) assert merged["last_alert_id"] == "ALT-1" assert _row(engine, "C1")["last_alert_id"] == "ALT-1" def test_computed_at_refreshed_on_each_write(env): """评审 P3-2:每次写 computed_at 均刷新(FR-7)。""" repo, engine = env _upsert(repo, "C1", "pattern", alert_id="ALT-1") upsert_profile_l3("C1", "large_amount", last_alert_id="ALT-2", computed_at=datetime(2027, 1, 1, 8, 0, 0), risk_repo=repo) # sqlite 读回为字符串,格式无关断言(核心是值已从首写的 now 刷新为传入时间) assert str(_row(engine, "C1")["computed_at"]).startswith("2027-01-01 08:00") def test_score_dimensions_replaced_only_when_passed(env): repo, _ = env _upsert(repo, "C1", "aml", alert_id="ALT-1", dims={"amount": 1}) merged = _upsert(repo, "C1", "large_amount", alert_id="ALT-2") assert merged["score_dimensions"] == {"amount": 1} # 未传保留旧值 merged = _upsert(repo, "C1", "large_amount", alert_id="ALT-3", dims={"amount": 2}) assert merged["score_dimensions"] == {"amount": 2} # 传入则替换 # ---------- 并发与竞态 ---------- def test_concurrent_first_upsert_single_row(env): """并发冒烟:两线程同客户首单(aml + 大额)→ 1 行、high、tags 并集。""" import json from threading import Thread repo, engine = env errors = [] def worker(alert_type, alert_id): try: _upsert(repo, "C1", alert_type, alert_id=alert_id) except Exception as exc: # pragma: no cover errors.append(exc) threads = [ Thread(target=worker, args=("aml", "ALT-A")), Thread(target=worker, args=("large_amount", "ALT-B")), ] for t in threads: t.start() for t in threads: t.join() assert not errors, errors with engine.connect() as conn: count, payload = conn.execute( text( "SELECT COUNT(*), GROUP_CONCAT(monitor_tags) FROM customer_profile_l3" " WHERE customer_id = 'C1'" ) ).fetchone() assert count == 1 tags = json.loads(payload) assert AML_PENDING_TAG in tags def test_integrity_error_falls_back_to_remerge(env, monkeypatch): """跨进程竞态(评审 P2-7③):首读 None、insert 撞主键 → 重读合并转更新,不丢对方写入。""" repo, engine = env # 对方进程已写入 high(模拟 AML 先落库) repo.insert_l3("C1", "high", None, {}, [AML_PENDING_TAG], "ALT-AML", datetime.now()) calls = {"n": 0} orig_get = type(repo).get_l3 def racing_get(self, cid): calls["n"] += 1 if calls["n"] == 1: return None # 本进程读写在对方 insert 之前发生 return orig_get(self, cid) monkeypatch.setattr(type(repo), "get_l3", racing_get) try: merged = _upsert(repo, "C1", "large_amount", alert_id="ALT-2") finally: monkeypatch.undo() assert merged["monitor_tier"] == "high" # 重读后合并,不降级 assert merged["last_alert_id"] == "ALT-2" row = _row(engine, "C1") assert row["monitor_tier"] == "high" and row["last_alert_id"] == "ALT-2" def test_update_lost_race_retries_and_converges(env, monkeypatch): """评审 P1-1:update 路径丢更新——本进程读旧值后对方先写 high,乐观锁未命中 触发重读重试,最终收敛 high(修复前会被覆盖回退 watch)。""" repo, engine = env repo.insert_l3("C1", "watch", None, {}, [], "ALT-1", datetime.now()) calls = {"n": 0} orig_update = type(repo).update_l3 def racing_update(self, *args, **kwargs): calls["n"] += 1 if calls["n"] == 1: orig_update(self, "C1", "high", None, {}, [AML_PENDING_TAG], "ALT-AML", datetime.now()) return False # 对方抢先提交,本进程乐观锁未命中 return orig_update(self, *args, **kwargs) monkeypatch.setattr(type(repo), "update_l3", racing_update) merged = _upsert(repo, "C1", "large_amount", alert_id="ALT-2") assert calls["n"] >= 2 # 确实走了重试 assert merged["monitor_tier"] == "high" assert AML_PENDING_TAG in merged["monitor_tags"] row = _row(engine, "C1") assert row["monitor_tier"] == "high" assert row["last_alert_id"] == "ALT-2" def test_persistent_conflict_raises_not_silent(env, monkeypatch): """重试耗尽抛错(宁失败不静默覆盖),不留半写状态。""" repo, engine = env repo.insert_l3("C1", "watch", None, {}, [], "ALT-1", datetime.now()) monkeypatch.setattr(type(repo), "update_l3", lambda self, *a, **k: False) with pytest.raises(RuntimeError, match="conflicted"): _upsert(repo, "C1", "large_amount", alert_id="ALT-2") row = _row(engine, "C1") assert row["monitor_tier"] == "watch" # 未被静默改写 # ---------- 写侧缓存 DEL 钩子行为(B7 复审 P2-1) ---------- def test_upsert_deletes_l3_cache_key(env, _fake_redis): """upsert 成功后 DEL profile:l3:{customer_id}(PRD §5.1,B7 挂账②)。""" repo, _ = env _upsert(repo, "CUST-DEL-1", "pattern", alert_id="ALT-1") assert ("profile:l3:CUST-DEL-1",) in _fake_redis.deletes def test_cache_delete_failure_degrades_to_ttl(env, monkeypatch): """降级路径:DEL 失败只告警不阻塞 upsert(TTL 兜底,redis_gateway 契约)。""" repo, _ = env class _BrokenGateway: def publish(self, channel, payload): pass def delete(self, *keys): raise ConnectionError("redis down") monkeypatch.setattr(redis_gateway, "_gateway", _BrokenGateway()) merged = _upsert(repo, "C1", "pattern", alert_id="ALT-1") # 不抛即降级成功 assert merged["monitor_tier"] == "watch" row = _row(env[1], "C1") assert row["monitor_tier"] == "watch" # DB 权威数据不受缓存失败影响