Files
group_xinghuo_jinrong/tests/test_profile_l3.py
T

314 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 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},
)
# 口径(评审 P3-3):以 VARCHAR/TEXT 近似真实 DDL 的 ENUM/SMALLINT/JSON,
# ENUM 档位防线不在测试 DB 层,由 tier_of 白名单 + highest_tier 保证。
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, 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" # 未被静默改写