314 lines
12 KiB
Python
314 lines
12 KiB
Python
"""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" # 未被静默改写
|