2026-09-06 23:27:37 +08:00
|
|
|
|
"""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
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from sqlalchemy import text
|
|
|
|
|
|
|
|
|
|
|
|
from _ddl import create_sqlite_engine
|
|
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
DEMO_SQL = ROOT / "scripts" / "demo" / "prepare_risk_demo.sql"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
|
def sqlite_engine():
|
|
|
|
|
|
"""内存 sqlite 全表引擎(B8 前 DDL 散落各测试文件,收敛后统一走这里)。"""
|
|
|
|
|
|
engine = create_sqlite_engine()
|
|
|
|
|
|
yield engine
|
|
|
|
|
|
engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------- 真库集成环境(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:
|
|
|
|
|
|
core = get_engine(settings.mysql_core_database)
|
|
|
|
|
|
agent = get_engine(settings.mysql_database)
|
|
|
|
|
|
with core.connect() as conn:
|
|
|
|
|
|
days = conn.execute(
|
|
|
|
|
|
text(
|
|
|
|
|
|
"SELECT DATEDIFF(CURDATE(), evaluated_at) FROM core_customer_risk"
|
|
|
|
|
|
" WHERE customer_id = 'CUST-4001'"
|
|
|
|
|
|
)
|
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
|
if days is None or days >= settings.risk_assessment_valid_days:
|
|
|
|
|
|
pytest.skip(f"演示数据未就位(CUST-4001 测评 {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:
|
2026-09-06 23:52:59 +08:00
|
|
|
|
# pytest.skip 抛出的 Skipped 继承 BaseException,不会被此处吞掉,直接向上传播
|
2026-09-06 23:27:37 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
core = get_engine(settings.mysql_core_database)
|
|
|
|
|
|
agent = get_engine(settings.mysql_database)
|
|
|
|
|
|
started_at = datetime.now()
|
2026-09-06 23:52:59 +08:00
|
|
|
|
l3_snapshot: list = [] # 哨兵:setup 失败时 teardown 不因未绑定变量掩盖原始异常
|
2026-09-06 23:27:37 +08:00
|
|
|
|
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()
|