Files
XingHuo/tests/test_profile_l3.py
T

248 lines
8.3 KiB
Python

"""profile_l3 单测(B3 · 最高档合并防降级 / AML 后大额不回落 / 并发首单)。
sqlite StaticPool 单连接共享内存库(同 test_alert_service 模式);
IntegrityError 兜底用 monkeypatch 模拟跨进程首写竞态。
"""
from datetime import datetime
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.pool import StaticPool
from app.repository.risk_repository import RiskRepository
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()
def env():
engine = create_engine(
"sqlite://",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE customer_profile_l3 (
customer_id VARCHAR(64) PRIMARY KEY,
monitor_tier VARCHAR(16) NOT NULL,
risk_score INTEGER,
score_dimensions TEXT,
monitor_tags TEXT,
last_alert_id VARCHAR(64),
computed_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
)
repo = RiskRepository(engine=engine)
yield repo, engine
engine.dispose()
def _upsert(repo, cid, alert_type, score=None, alert_id=None, tags=None, dims=None):
return upsert_profile_l3(
cid,
alert_type,
risk_score=score,
monitor_tags=tags,
last_alert_id=alert_id,
score_dimensions=dims,
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", 70)
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", risk_score=70, monitor_tags=["t1"],
last_alert_id="ALT-1")
assert merged["monitor_tier"] == "watch"
assert merged["risk_score"] == 70
assert merged["monitor_tags"] == ["t1"]
assert merged["last_alert_id"] == "ALT-1"
assert merged["computed_at"] is not None
def test_merge_both_scores_none_stays_none():
existing = {"monitor_tier": "watch", "risk_score": None, "monitor_tags": [],
"score_dimensions": {}}
merged = merge_l3(existing, mapped_tier="watch", risk_score=None, 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", 70, 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"] == 70
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", 90, alert_id="ALT-0")
merged = _upsert(repo, "C1", "pattern", 80, 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", 80, alert_id="ALT-1")
merged = _upsert(repo, "C1", "suitability", 90, 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", 95, 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_large_amount_after_aml_does_not_fall_back(env):
"""B3 验收:AML 后大额 → tier 仍 high、tags 并集、score 取 max。"""
repo, engine = env
_upsert(repo, "C1", "aml", 95, alert_id="ALT-1")
merged = _upsert(repo, "C1", "large_amount", 70, alert_id="ALT-2", tags=["manual_review"])
assert merged["monitor_tier"] == "high"
assert merged["risk_score"] == 95 # 取 max,不降
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"] == 95
def test_tags_accumulate_not_overwrite(env):
repo, _ = env
_upsert(repo, "C1", "pattern", 80, alert_id="ALT-1", tags=["freq"])
merged = _upsert(repo, "C1", "pattern", 80, alert_id="ALT-2", tags=["freq"])
assert merged["monitor_tags"] == ["freq"] # set 合并去重
def test_score_dimensions_replaced_only_when_passed(env):
repo, _ = env
_upsert(repo, "C1", "aml", 95, alert_id="ALT-1", dims={"amount": 1})
merged = _upsert(repo, "C1", "large_amount", 70, alert_id="ALT-2")
assert merged["score_dimensions"] == {"amount": 1} # 未传保留旧值
merged = _upsert(repo, "C1", "large_amount", 70, 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, 70, 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
from sqlalchemy.exc import IntegrityError
# 对方进程已写入 high(模拟 AML 先落库)
repo.insert_l3("C1", "high", 95, {}, [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", 70, alert_id="ALT-2")
finally:
monkeypatch.undo()
assert merged["monitor_tier"] == "high" # 重读后合并,不降级
assert merged["risk_score"] == 95
assert merged["last_alert_id"] == "ALT-2"
row = _row(engine, "C1")
assert row["monitor_tier"] == "high" and row["last_alert_id"] == "ALT-2"