533 lines
20 KiB
Python
533 lines
20 KiB
Python
"""pytest 全局 fixture(B8 · 开发计划 B8 行)。
|
||
|
||
- sqlite_engine:内存库 + tests/_ddl.py 单一事实源建表(单测共用)。
|
||
- 集成测试(test_integration_risk.py)收集期先调 ensure_risk_demo_ready():
|
||
本机演示数据未就位(未跑 FLOW §0 ③④)时整模块 skip 并给提示,单测不受影响。
|
||
- risk_demo_env(session):真 MySQL(jinrong_agent/jinrong_core)集成环境,
|
||
setup 幂等代跑 prepare_risk_demo.sql(UPDATE 语义)+ L3 快照,teardown 按
|
||
TRD-TEST- 前缀 + session 时间窗清理测试交易及其预警/校验/审计行,并还原 L3。
|
||
注意:时间窗清理假定测试期间无他人向演示库写入(学习项目单机约定)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from sqlalchemy import text
|
||
|
||
from _ddl import create_sqlite_engine, seed_suitability_matrix
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
DEMO_SQL = ROOT / "scripts" / "demo" / "prepare_risk_demo.sql"
|
||
|
||
|
||
class FakeRedis:
|
||
"""最小 Redis 替身(decode_responses=True 语义;客服 wave 测试用)。"""
|
||
|
||
def __init__(self) -> None:
|
||
self.strings: dict = {}
|
||
self.lists: dict = {}
|
||
self.hashes: dict = {}
|
||
self.ttls: dict = {}
|
||
|
||
def get(self, key):
|
||
return self.strings.get(key)
|
||
|
||
def setex(self, key, ttl, value):
|
||
self.strings[key] = value
|
||
self.ttls[key] = ttl
|
||
|
||
def delete(self, *keys):
|
||
for key in keys:
|
||
self.strings.pop(key, None)
|
||
self.lists.pop(key, None)
|
||
self.hashes.pop(key, None)
|
||
|
||
def incr(self, key):
|
||
value = int(self.strings.get(key, 0)) + 1
|
||
self.strings[key] = str(value)
|
||
return value
|
||
|
||
def expire(self, key, ttl):
|
||
self.ttls[key] = ttl
|
||
|
||
def rpush(self, key, value):
|
||
self.lists.setdefault(key, []).append(value)
|
||
|
||
def ltrim(self, key, start, end):
|
||
lst = self.lists.get(key)
|
||
if lst is None:
|
||
return
|
||
self.lists[key] = lst[start:] if end == -1 else lst[start : end + 1]
|
||
|
||
def lrange(self, key, start, end):
|
||
lst = self.lists.get(key, [])
|
||
return list(lst[start:]) if end == -1 else list(lst[start : end + 1])
|
||
|
||
def hincrby(self, key, field, delta=1):
|
||
h = self.hashes.setdefault(key, {})
|
||
h[field] = int(h.get(field, 0)) + delta
|
||
return h[field]
|
||
|
||
def hgetall(self, key):
|
||
return dict(self.hashes.get(key, {}))
|
||
|
||
def scan_iter(self, match=None):
|
||
import fnmatch
|
||
|
||
pat = match or "*"
|
||
keys = set(self.strings) | set(self.lists) | set(self.hashes)
|
||
for key in keys:
|
||
if fnmatch.fnmatch(key, pat):
|
||
yield key
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _wave_customer_fake_redis(request, monkeypatch):
|
||
"""Wave 4/5:无本机 Redis 时用 FakeRedis(与 test_wave3 口径一致)。"""
|
||
nodeid = request.node.nodeid
|
||
if "test_wave4_e2e" not in nodeid and "test_wave5_notes" not in nodeid:
|
||
yield
|
||
return
|
||
r = FakeRedis()
|
||
monkeypatch.setattr("app.config.database.get_redis_client", lambda: r)
|
||
from app.service.risk import redis_gateway
|
||
|
||
monkeypatch.setattr(redis_gateway, "_gateway", r)
|
||
from app.service import customer_service as cs
|
||
|
||
monkeypatch.setattr(cs, "_spawn", lambda fn, *a: fn(*a))
|
||
yield r
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _wave_customer_test_session(request):
|
||
"""Wave 4/5:在 agent_session 预建测试会话(merger chat 续聊须 SessionGuard 命中)。"""
|
||
nodeid = request.node.nodeid
|
||
if "test_wave4_e2e" not in nodeid and "test_wave5_notes" not in nodeid:
|
||
yield
|
||
return
|
||
|
||
from app.config.database import get_agent_engine
|
||
from app.repository.session_repository import SessionRepository
|
||
|
||
session_id = "sess-e2e-001" if "test_wave4_e2e" in nodeid else "sess-note-001"
|
||
cust = "CUST-9527"
|
||
engine = get_agent_engine()
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text("DELETE FROM agent_session WHERE session_id = :sid"),
|
||
{"sid": session_id},
|
||
)
|
||
SessionRepository(engine=engine).create_session(
|
||
session_id=session_id,
|
||
trace_id="trc-wave-customer-test",
|
||
agent_type="customer",
|
||
actor_id=cust,
|
||
actor_role="customer",
|
||
customer_id=cust,
|
||
)
|
||
yield
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text("DELETE FROM agent_session WHERE session_id = :sid"),
|
||
{"sid": session_id},
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def fake_redis():
|
||
return FakeRedis()
|
||
|
||
|
||
# 客服 Agent Wave 测试已全部解禁(见 客服Agent-合并说明.md)
|
||
collect_ignore: list[str] = [
|
||
# 源分支 MemoryService API 与 merger T-06 memory_service 模块不一致,待对齐后再启用
|
||
"test_step12_memory_service.py",
|
||
]
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _disable_concentration_rule(monkeypatch):
|
||
"""RISK-006 回归隔离(C4 起):默认把集中度阈值推到不可达,现有用例断言零改动。
|
||
|
||
RISK-006 在引擎里对**每一笔**交易都会查持仓画像,若沿用默认 0.80,
|
||
现有 436 条用例中断言「命中规则集合 / 单日一张单」的会被新规则打穿。
|
||
故全局默认禁用;RISK-006 专属测试内再显式传 `RiskThresholds(...)` 或
|
||
再次 monkeypatch 回真实阈值 0.80(实现方案 §6 全量回归口径,评审 P1-2)。
|
||
|
||
前置条件:`RiskThresholds.from_settings()` 必须补读 concentration_threshold,
|
||
否则此处 monkeypatch 不生效(rules.py 已补读并注明)。
|
||
"""
|
||
from app.config.settings import settings
|
||
|
||
monkeypatch.setattr(settings, "risk_concentration_threshold", 1.01)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _advisor_agent_sqlite_db(request, monkeypatch):
|
||
"""顾问 sprint 测试:Agent 库走内存 sqlite(含 compliance / audit 表)。"""
|
||
nodeid = request.node.nodeid
|
||
if not any(
|
||
token in nodeid
|
||
for token in (
|
||
"test_sprint",
|
||
"test_step",
|
||
"test_demo_kyc",
|
||
"test_sprint0_infrastructure",
|
||
)
|
||
):
|
||
yield
|
||
return
|
||
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
from _ddl import create_sqlite_engine
|
||
|
||
engine = create_sqlite_engine()
|
||
session_factory = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||
|
||
def _sqlite_engine(_db_name=None):
|
||
return engine
|
||
|
||
monkeypatch.setattr("app.utils.db.get_engine", _sqlite_engine)
|
||
monkeypatch.setattr("app.advisor_db.agent_engine", engine)
|
||
monkeypatch.setattr("app.advisor_db.AgentSessionLocal", session_factory)
|
||
monkeypatch.setattr("app.service.audit_service._AgentSessionLocal", session_factory)
|
||
for mod_name in (
|
||
"app.repository.compliance_rule_repository",
|
||
"app.repository.compliance_check_log_repository",
|
||
"app.repository.copy_track_repository",
|
||
"app.repository.kyc_session_repository",
|
||
"app.repository.market_alert_repository",
|
||
"app.repository.script_template_repository",
|
||
):
|
||
try:
|
||
mod = __import__(mod_name, fromlist=["AgentSessionLocal"])
|
||
monkeypatch.setattr(mod, "AgentSessionLocal", session_factory)
|
||
except ModuleNotFoundError:
|
||
pass
|
||
from app.service import audit_service as audit_module
|
||
|
||
audit_module.audit_service = audit_module.AuditService(
|
||
audit_module.SqlAlchemyAuditRepository(session_factory)
|
||
)
|
||
|
||
dataset = ROOT / "docs" / "开发文档" / "20-Sprint1首批合规规则数据集.md"
|
||
if dataset.exists():
|
||
from scripts.seed.import_compliance_rules import import_rules_from_markdown
|
||
|
||
import_rules_from_markdown(dataset, actor_id="seed:test_data")
|
||
|
||
yield
|
||
|
||
|
||
@pytest.fixture()
|
||
def sqlite_engine():
|
||
"""内存 sqlite 全表引擎(B8 前 DDL 散落各测试文件,收敛后统一走这里)。
|
||
|
||
AL-05 起统一灌 C×R 矩阵种子(check_suitability 以矩阵表为 L0 权威,
|
||
缺失即全量 forbidden,见 _ddl.seed_suitability_matrix)。
|
||
"""
|
||
engine = create_sqlite_engine()
|
||
seed_suitability_matrix(engine)
|
||
yield engine
|
||
engine.dispose()
|
||
|
||
|
||
@pytest.fixture()
|
||
def backdated_alert(sqlite_engine):
|
||
"""注入 created_at 回拨的 pending 单(C5/C6 免等待基建);teardown 按 alert_id 精确删除。
|
||
|
||
用法:
|
||
aid = backdated_alert(alert_id="ALT-TEST-X", customer_id="C1", hours_ago=5,
|
||
payload={...}, alert_type="pattern", risk_score=60)
|
||
注意:注入与读取必须共用同一个 sqlite_engine(StaticPool 单连接共享)——测试内
|
||
一律 `RiskRepository(engine=sqlite_engine)`;回拨行 teardown 精确按 alert_id 删,
|
||
不污染正常 trace 空间(评审必查点,_cleanup_test_rows 的时间窗清不掉回拨行)。
|
||
"""
|
||
from sqlalchemy import text
|
||
|
||
import json
|
||
|
||
created_ids: list[str] = []
|
||
|
||
def _make(
|
||
alert_id: str,
|
||
customer_id: str,
|
||
hours_ago: float,
|
||
payload: dict | None = None,
|
||
alert_type: str = "pattern",
|
||
risk_score: int = 60,
|
||
) -> str:
|
||
created_at = datetime.now() - timedelta(hours=hours_ago)
|
||
with sqlite_engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO risk_alert (alert_id, trace_id, customer_id, trade_id,"
|
||
" alert_type, triggered_rules, risk_score, status, payload, created_at)"
|
||
" VALUES (:aid, 'TRACE-TEST', :cid, 'TRD-TEST-0', :atype, :rules,"
|
||
" :score, 'pending_review', :payload, :created_at)"
|
||
),
|
||
{
|
||
"aid": alert_id,
|
||
"cid": customer_id,
|
||
"atype": alert_type,
|
||
"rules": json.dumps(["RISK-007"]),
|
||
"score": risk_score,
|
||
"payload": json.dumps(payload or {}, ensure_ascii=False),
|
||
"created_at": created_at,
|
||
},
|
||
)
|
||
created_ids.append(alert_id)
|
||
return alert_id
|
||
|
||
yield _make
|
||
with sqlite_engine.begin() as conn:
|
||
for aid in created_ids:
|
||
conn.execute(
|
||
text("DELETE FROM risk_alert WHERE alert_id = :aid"), {"aid": aid}
|
||
)
|
||
|
||
|
||
@pytest.fixture()
|
||
def backdated_audit_event(sqlite_engine):
|
||
"""注入 created_at 回拨的 audit_log 行(C6);teardown 按 trace_id(TEST-TRACE- 前缀)精确删除。
|
||
|
||
用法:
|
||
tid = backdated_audit_event(event_type="authz", actor_id="STAFF-X",
|
||
customer_id="CUST-Y",
|
||
input_summary={"code": "AUTH_403_SCOPE"},
|
||
decision="forbidden", hours_ago=5)
|
||
注意:注入与读取必须共用同一个 sqlite_engine;回拨行 teardown 按 trace_id 前缀
|
||
精确删除,不污染正常 trace 空间(评审必查点,_cleanup_test_rows 时间窗清不掉回拨行)。
|
||
"""
|
||
import json
|
||
from uuid import uuid4
|
||
|
||
created_trace_ids: list[str] = []
|
||
|
||
def _make(
|
||
event_type: str,
|
||
actor_id: str,
|
||
customer_id: str | None = None,
|
||
input_summary: dict | None = None,
|
||
decision: str = "forbidden",
|
||
hours_ago: float = 5.0,
|
||
agent_type: str = "risk",
|
||
trace_id: str | None = None,
|
||
) -> str:
|
||
tid = trace_id or f"TEST-TRACE-{uuid4().hex[:12].upper()}"
|
||
created_at = datetime.now() - timedelta(hours=hours_ago)
|
||
with sqlite_engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO audit_log (trace_id, event_type, agent_type, actor_id,"
|
||
" customer_id, rule_id, input_summary, decision, risk_score,"
|
||
" handler_id, handler_result, handler_comment, created_at)"
|
||
" VALUES (:tid, :et, :agent_type, :aid, :cid, NULL, :summary,"
|
||
" :decision, NULL, NULL, NULL, NULL, :created_at)"
|
||
),
|
||
{
|
||
"tid": tid,
|
||
"et": event_type,
|
||
"agent_type": agent_type,
|
||
"aid": actor_id,
|
||
"cid": customer_id,
|
||
"summary": json.dumps(input_summary or {}, ensure_ascii=False),
|
||
"decision": decision,
|
||
"created_at": created_at,
|
||
},
|
||
)
|
||
created_trace_ids.append(tid)
|
||
return tid
|
||
|
||
yield _make
|
||
with sqlite_engine.begin() as conn:
|
||
for tid in created_trace_ids:
|
||
conn.execute(text("DELETE FROM audit_log WHERE trace_id = :tid"), {"tid": tid})
|
||
|
||
|
||
# ---------- 真库集成环境(B8 集成测试专用) ----------
|
||
|
||
|
||
def ensure_risk_demo_ready() -> None:
|
||
"""演示数据就位校验(开发计划 B8-a):缺失则 skip 集成模块并提示 bootstrap。
|
||
|
||
在集成测试模块收集期调用;失败 pytest.skip(allow_module_level=True) 只跳过
|
||
该模块,sqlite 单测不受影响。校验口径:CUST-4001 测评 <365 天、AML 名单 ≥8。
|
||
"""
|
||
from app.config.settings import settings
|
||
from app.utils.db import get_engine
|
||
|
||
hint = "先跑 FLOW §0 ③④:scripts/core/reset.ps1 → 01-mysql-共用底座.sql →" " 02-mysql-agent专用.sql → scripts/agent/seed-aml-list.sql → prepare_risk_demo.sql"
|
||
try:
|
||
# T-0b(R-e):显式 admin —— 本函数与 fixture 的 setup/teardown 需
|
||
# INSERT/DELETE,而 xh_core_rw 无 DELETE、xh_agent_rw 亦无 DELETE。
|
||
core = get_engine(settings.mysql_core_database, role="admin")
|
||
agent = get_engine(settings.mysql_database, role="admin")
|
||
with core.connect() as conn:
|
||
# FM-03 口径(AL-05 换核):expires_at 未过期即就位(原 evaluated_at+365 天口径退役)
|
||
days = conn.execute(
|
||
text(
|
||
"SELECT DATEDIFF(expires_at, CURDATE()) FROM core_customer_risk"
|
||
" WHERE customer_id = 'CUST-4001'"
|
||
)
|
||
).scalar_one_or_none()
|
||
if days is None or days <= 0:
|
||
pytest.skip(f"演示数据未就位(CUST-4001 风评 expires_at 剩余 {days} 天):{hint}", allow_module_level=True)
|
||
with agent.connect() as conn:
|
||
aml_count = conn.execute(
|
||
text("SELECT COUNT(*) FROM risk_aml_list WHERE is_active = 1")
|
||
).scalar_one()
|
||
if aml_count < 8:
|
||
pytest.skip(f"演示数据未就位(AML 名单仅 {aml_count} 条):{hint}", allow_module_level=True)
|
||
except Exception as exc:
|
||
# pytest.skip 抛出的 Skipped 继承 BaseException,不会被此处吞掉,直接向上传播
|
||
pytest.skip(f"本机 MySQL 不可用({exc}):{hint}", allow_module_level=True)
|
||
|
||
|
||
def _run_demo_sql(core_engine) -> None:
|
||
"""幂等代跑 prepare_risk_demo.sql(开发计划 B8-b):文件须保持无存储过程/DELIMITER。"""
|
||
statements = [s.strip() for s in DEMO_SQL.read_text(encoding="utf-8").split(";") if s.strip()]
|
||
with core_engine.begin() as conn:
|
||
for stmt in statements:
|
||
if stmt.upper().startswith("USE "):
|
||
continue # engine 默认库已由 URL 指定
|
||
conn.execute(text(stmt))
|
||
|
||
|
||
def _cleanup_test_rows(agent_engine, core_engine, started_at: datetime) -> None:
|
||
"""TRD-TEST- teardown(开发计划 B8-c):测试交易及其关联行 + 时间窗兜底 + L3 还原。"""
|
||
with core_engine.begin() as conn:
|
||
conn.execute(text("DELETE FROM core_trade WHERE trade_id LIKE 'TRD-TEST-%'"))
|
||
with agent_engine.begin() as conn:
|
||
conn.execute(text("DELETE FROM risk_alert WHERE trade_id LIKE 'TRD-TEST-%' OR created_at >= :ts"), {"ts": started_at})
|
||
conn.execute(text("DELETE FROM risk_suitability_log WHERE request_ref LIKE 'TRD-TEST-%' OR created_at >= :ts"), {"ts": started_at})
|
||
conn.execute(text("DELETE FROM audit_log WHERE created_at >= :ts"), {"ts": started_at})
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def risk_demo_env():
|
||
"""真 MySQL 集成环境:setup 快照 L3 → yield engines → teardown 清理 + 引擎 dispose。"""
|
||
from app.config.settings import settings
|
||
from app.utils.db import dispose_engines, get_engine
|
||
|
||
# T-0b(R-e):teardown 需 DELETE(core_trade / risk_alert / audit_log),
|
||
# 只读账号与业务读写账号均无 DELETE 权限 → 显式 admin(= mysql_user)。
|
||
core = get_engine(settings.mysql_core_database, role="admin")
|
||
agent = get_engine(settings.mysql_database, role="admin")
|
||
started_at = datetime.now()
|
||
l3_snapshot: list = [] # 哨兵:setup 失败时 teardown 不因未绑定变量掩盖原始异常
|
||
try:
|
||
with agent.begin() as conn:
|
||
l3_snapshot = conn.execute(
|
||
text("SELECT * FROM customer_profile_l3")
|
||
).mappings().all()
|
||
_run_demo_sql(core)
|
||
yield {"agent": agent, "core": core, "started_at": started_at}
|
||
finally:
|
||
try:
|
||
_cleanup_test_rows(agent, core, started_at)
|
||
with agent.begin() as conn:
|
||
conn.execute(text("DELETE FROM customer_profile_l3"))
|
||
for row in l3_snapshot:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO customer_profile_l3 (customer_id, monitor_tier,"
|
||
" risk_score, score_dimensions, monitor_tags, last_alert_id,"
|
||
" computed_at, updated_at) VALUES (:customer_id, :monitor_tier,"
|
||
" :risk_score, :score_dimensions, :monitor_tags, :last_alert_id,"
|
||
" :computed_at, :updated_at)"
|
||
),
|
||
dict(row),
|
||
)
|
||
finally:
|
||
dispose_engines()
|
||
|
||
|
||
# ---------- Wave 0 宿主 fixture(auth/chat 单测) ----------
|
||
|
||
|
||
@pytest.fixture
|
||
def client():
|
||
from fastapi.testclient import TestClient
|
||
|
||
from app.main import app
|
||
|
||
return TestClient(app)
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def mock_wave0_db(request, monkeypatch):
|
||
"""Wave 0 chat/auth 单测 mock(仅 test_wave0_* 生效,不影响风控用例)。"""
|
||
if "test_wave0" not in request.node.nodeid:
|
||
yield None
|
||
return
|
||
|
||
from unittest.mock import MagicMock
|
||
|
||
from _ddl import create_sqlite_engine
|
||
|
||
from app.api import chat as chat_mod
|
||
from app.api import deps as deps_mod
|
||
from app.config.settings import settings
|
||
from app.repository.core_ro import CoreReadOnlyRepository
|
||
from app.repository.risk_repository import RiskRepository
|
||
from app.repository.session_repository import SessionRepository
|
||
from app.service import memory_service
|
||
from app.service.risk import redis_gateway
|
||
|
||
audit = MagicMock()
|
||
core_ro = MagicMock()
|
||
core_ro.is_advisor_assigned = MagicMock(return_value=True)
|
||
|
||
engine = create_sqlite_engine()
|
||
session_repo = SessionRepository(engine=engine)
|
||
repo = RiskRepository(engine=engine)
|
||
repo.insert_audit_log = audit.insert
|
||
repo.insert_input_guard_log = MagicMock()
|
||
|
||
class _FakeRedis:
|
||
def rpush(self, *a, **k):
|
||
pass
|
||
|
||
def lrange(self, *a, **k):
|
||
return []
|
||
|
||
def ltrim(self, *a, **k):
|
||
pass
|
||
|
||
def expire(self, *a, **k):
|
||
pass
|
||
|
||
def publish(self, *a, **k):
|
||
pass
|
||
|
||
def delete(self, *a, **k):
|
||
pass
|
||
|
||
def exists(self, *a, **k):
|
||
return False
|
||
|
||
def set_ex(self, *a, **k):
|
||
pass
|
||
|
||
monkeypatch.setattr(settings, "deepseek_api_key", "")
|
||
monkeypatch.setattr(chat_mod, "_repo", lambda: repo)
|
||
monkeypatch.setattr(chat_mod, "_session_repo", lambda: session_repo)
|
||
monkeypatch.setattr(chat_mod, "_core_ro", lambda: core_ro)
|
||
monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo)
|
||
monkeypatch.setattr(memory_service, "_session_repo", lambda: session_repo)
|
||
monkeypatch.setattr(redis_gateway, "_gateway", _FakeRedis())
|
||
|
||
yield {"audit": audit, "core_ro": core_ro, "session": session_repo, "repo": repo}
|
||
|
||
|
||
@pytest.fixture
|
||
def mock_db(mock_wave0_db):
|
||
"""Wave 0 测试兼容别名。"""
|
||
return mock_wave0_db
|