Files
group_xinghuo_jinrong/tests/test_db.py
T
GaoYiYuan_0626 f056fcd007 基金转换 T+1 模型:T-1~T-6 落地(DDL/数据层/语义收窄/锁/受理事务)
AIcoding 第 5 步 todo 开发(开发计划 v2.0)前半段:

- T-1 3 张新表 DDL(core_convert_request 6 态 ENUM / core_trade_calendar /
  core_share_rule)+ 种子 + sqlite 单一事实源同步 + 列清单断言
- T-2/T-2b calc 扩展(product_round/redeem_amount/partial_qty)+ 真实净值实算回填
- T-3 convert_request_repository(6 态 + 条件 UPDATE 守卫)+ core_ro 三读方法
  + share_lot_repository.available_qty_with_inflight(R-3 在途占用推导)
- T-4 convert_repository.sync_mirror 成为 risk_convert_detail 唯一进度镜像写入口
  (旧三方法标 Deprecated,T-7 后删)
- T-5 locks.py 锁键构造器 convert_req_lock_key / convert_confirm_lock_key
- T-6 convert_service.accept_convert 受理事务(八步:锁→幂等→校验→受理日顺延
  →在途占用校验→落单→镜像+审计→202;不扣份额/不折算/不写流水)
  + tests/test_convert_accept.py(16 用例)
  + scripts/dev/verify_convert_accept.py(真库 36/36 一致)
  + trading_calendar.py 纯函数包(R-5)+ 21 用例

T-6 真库实测暴露并修复:confirm_eta 在日历数据边界抛 ValueError,会让已落库
的受理单在调用方眼里变 500;改为展示性字段容错 + 单测守护。

基线:798 passed / 10 skipped,零回归。
2026-09-11 19:21:15 +08:00

304 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.
"""引擎工厂(utils/db.py)单测(B7 评审 P1-1):单例复用 + dispose 真实释放。
create_engine 与缓存字典均 monkeypatch 替换,不触网;验证 dispose_engines
对每个缓存 Engine 显式调用 dispose() 并清空缓存(不再是仅 cache_clear)。
另含 T-0 门禁用例(sqlite 结构对齐 + 门禁自身有效性反向验证)、T-0b 引擎角色
隔离用例(同库不同角色 / 账号未配置回退 / 非法角色报错),以及 3 条真 MySQL
权限断言(`xh_core_ro` 只读 · `audit_log` 只追加 · `xh_core_rw` 限 4 表)——
后者在账号未配置或真库不可达时自动 skip,不阻塞单测基线。
"""
import pytest
from sqlalchemy import text
from _ddl import EXPECTED_CORE_HOLDING_COLUMNS, create_sqlite_engine
from app.utils import db
def _unique_column_sets(conn, table: str) -> set[tuple[str, ...]]:
"""PRAGMA 取某表全部唯一约束的列组合(sqlite 自动索引 unique 字段为 1/'u')。"""
result: set[tuple[str, ...]] = set()
for row in conn.execute(text(f"PRAGMA index_list({table})")):
# 行结构:seq, name, unique, origin, partial
if row[2]:
cols = tuple(r[2] for r in conn.execute(text(f"PRAGMA index_info({row[1]})")))
result.add(cols)
return result
class SpyEngine:
def __init__(self):
self.dispose_calls = 0
def dispose(self, close: bool = True) -> None:
self.dispose_calls += 1
def _patch(monkeypatch):
created_urls = []
def fake_create_engine(url, **kwargs):
created_urls.append(url)
return SpyEngine()
monkeypatch.setattr(db, "create_engine", fake_create_engine)
monkeypatch.setattr(db, "_engines", {})
return created_urls
def test_get_engine_caches_one_engine_per_database(monkeypatch):
created = _patch(monkeypatch)
e1 = db.get_engine("db_a")
e2 = db.get_engine("db_a")
e3 = db.get_engine("db_b")
assert e1 is e2
assert e1 is not e3
assert len(created) == 2
def test_dispose_engines_calls_dispose_and_clears_cache(monkeypatch):
_patch(monkeypatch)
e = db.get_engine("db_a")
db.dispose_engines()
assert e.dispose_calls == 1
assert db._engines == {}
# 缓存已清:重建新实例而非复用已 dispose 的旧实例
fresh = db.get_engine("db_a")
assert fresh is not e
assert fresh.dispose_calls == 0
def test_dispose_engines_on_empty_cache_is_noop(monkeypatch):
_patch(monkeypatch)
db.dispose_engines() # 不抛异常即可
# ---------- T-0 门禁:sqlite 结构对齐(R1)----------
def test_core_holding_columns(sqlite_engine):
"""sqlite core_holding 与 MySQL scripts/core/01-ddl.sql:132-145 逐列对齐。
convert 的首个阻断项正是列名失配(sqlite 曾为 quantity、MySQL 为 qty),
本用例把这类失配钉死在单测层,不让它留到集成测试期。
"""
with sqlite_engine.connect() as conn:
info = conn.execute(text("PRAGMA table_info(core_holding)")).all()
columns = {row[1] for row in info}
assert EXPECTED_CORE_HOLDING_COLUMNS <= columns, (
f"core_holding 缺列:{sorted(EXPECTED_CORE_HOLDING_COLUMNS - columns)}"
)
primary_key = {row[1] for row in info if row[5] > 0}
assert primary_key == {"id"}
assert ("customer_id", "product_id") in _unique_column_sets(conn, "core_holding")
def test_core_product_nav_columns(sqlite_engine):
"""core_product_nav 存在且含 (product_id, nav_date) 唯一约束(R-f)。"""
with sqlite_engine.connect() as conn:
tables = {
row[0]
for row in conn.execute(text("SELECT name FROM sqlite_master WHERE type = 'table'"))
}
assert "core_product_nav" in tables
columns = {row[1] for row in conn.execute(text("PRAGMA table_info(core_product_nav)"))}
assert {"id", "product_id", "nav", "daily_chg_pct", "nav_date"} <= columns
assert ("product_id", "nav_date") in _unique_column_sets(conn, "core_product_nav")
def test_t1_new_tables_and_columns(sqlite_engine):
"""T-1 新增 3 表 + core_product 4 列在 sqlite 建表源中存在(R-回归 14)。
MySQL 侧落点 scripts/core/01-ddl.sql;两侧双份同步的一致性由
REQUIRED_CONVERT_TABLES + 本用例列清单守护(R-g)。核心断言:
· core_convert_request 存在且有 status(6 态受理单权威状态机)
· core_trade_calendar 存在且有 cal_date/is_open(Q11 交易日历)
· core_share_rule 存在且有 product_id/business_type/share_digits(D27)
· core_product 含 T-1 补的 4 列(share_class/allow_ac_convert/
rounding_mode/share_digits)
"""
with sqlite_engine.connect() as conn:
tables = {
row[0]
for row in conn.execute(text("SELECT name FROM sqlite_master WHERE type = 'table'"))
}
for t in ("core_convert_request", "core_trade_calendar", "core_share_rule"):
assert t in tables, f"T-1 新表缺失:{t}"
req_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(core_convert_request)"))}
assert {"convert_group_id", "client_request_id", "customer_id", "from_product_id",
"to_product_id", "qty", "actual_qty", "status", "cancel_before",
"requested_at", "confirmed_at", "coupon", "remark", "created_at", "updated_at"
} <= req_cols, f"core_convert_request 缺列:{sorted(req_cols)}"
cal_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(core_trade_calendar)"))}
assert {"cal_date", "is_open", "remark"} <= cal_cols
rule_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(core_share_rule)"))}
assert {"product_id", "business_type", "share_digits", "rounding_mode"} <= rule_cols
prod_cols = {row[1] for row in conn.execute(text("PRAGMA table_info(core_product)"))}
assert {"share_class", "allow_ac_convert", "rounding_mode", "share_digits"} <= prod_cols
def test_ddl_alignment_guard_fails_when_column_missing(monkeypatch):
"""反向验证门禁自身有效:抽掉 qty 列 → 建库即 AssertionError。"""
import _ddl
broken = dict(_ddl.SQLITE_TABLES)
broken["core_holding"] = """
CREATE TABLE core_holding (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id VARCHAR(64) NOT NULL, product_id VARCHAR(64) NOT NULL,
market_value DECIMAL NOT NULL, UNIQUE (customer_id, product_id))
"""
monkeypatch.setattr(_ddl, "SQLITE_TABLES", broken)
with pytest.raises(AssertionError, match="core_holding 缺列"):
create_sqlite_engine()
# ---------- T-0b:引擎角色隔离(D20 · 缓存键 (库名, 角色))----------
def test_get_engine_role_isolation(monkeypatch):
"""同库不同角色 → 不同 engine(缓存键由「库名」改为「(库名, 角色)」)。"""
created = _patch(monkeypatch)
ro = db.get_engine("db_a", "ro")
rw = db.get_engine("db_a", "rw")
admin = db.get_engine("db_a", "admin")
assert ro is not rw
assert rw is not admin
assert ro is not admin
assert db.get_engine("db_a", "ro") is ro # 同角色仍复用
assert len(created) == 3
def test_get_engine_role_accounts_and_fallback(monkeypatch):
"""ro/rw 各用自己账号;账号留空 → 回退 mysql_user(渐进启用,不阻塞开发)。"""
from app.config.settings import settings
for name, value in (
("mysql_host", "127.0.0.1"),
("mysql_port", 3306),
("mysql_user", "root"),
("mysql_password", "rootpw"),
("mysql_database", "jinrong_agent"),
("mysql_core_database", "jinrong_core"),
("mysql_core_ro_user", ""),
("mysql_core_ro_password", ""),
("mysql_core_rw_user", ""),
("mysql_core_rw_password", ""),
("mysql_agent_user", ""),
("mysql_agent_password", ""),
):
monkeypatch.setattr(settings, name, value)
created = _patch(monkeypatch)
db.get_engine("jinrong_core", "ro")
db.get_engine("jinrong_core", "rw")
db.get_engine("jinrong_agent", "rw")
assert len(created) == 3
# 三组账号均未配置 → 全部回退 mysql_user(与单账号时代行为一致)
assert all("root:rootpw@" in url for url in created)
# 配置账号后:各角色取各自账号
db.dispose_engines()
created.clear()
for name, value in (
("mysql_core_ro_user", "xh_core_ro"),
("mysql_core_ro_password", "p1"),
("mysql_core_rw_user", "xh_core_rw"),
("mysql_core_rw_password", "p2"),
("mysql_agent_user", "xh_agent_rw"),
("mysql_agent_password", "p3"),
):
monkeypatch.setattr(settings, name, value)
db.get_engine("jinrong_core", "ro")
db.get_engine("jinrong_core", "rw")
db.get_engine("jinrong_agent", "rw")
assert "xh_core_ro:p1@" in created[0]
assert "xh_core_rw:p2@" in created[1]
assert "xh_agent_rw:p3@" in created[2]
assert "/jinrong_core?" in created[0]
assert "/jinrong_core?" in created[1]
assert "/jinrong_agent?" in created[2]
# agent 库无独立只读账号 → ro 亦走 mysql_agent_user(不静默落到 root),
# 但缓存键 (库, 角色) 不同,因此仍是独立 engine
agent_ro = db.get_engine("jinrong_agent", "ro")
assert "xh_agent_rw:p3@" in created[3]
assert agent_ro is not db.get_engine("jinrong_agent", "rw")
def test_get_engine_rejects_unknown_role(monkeypatch):
"""非法角色立即报错:防拼写错误静默取到默认账号。"""
_patch(monkeypatch)
with pytest.raises(ValueError, match="unknown db role"):
db.get_engine("db_a", "readonly")
# ---------- T-0b:真 MySQL 权限断言(账号未配置则 skip)----------
# 前置:管理员执行 scripts/core/00-grant.sql,把 3 组账号写入 .env。
# 未配置时不阻塞单测基线(本文件默认全绿)。
def _role_engine_or_skip(role: str, database_setting: str, user_setting: str):
"""取角色 engine;账号未配置或真库不可达 → skip 并给出 bootstrap 提示。"""
from app.config.settings import settings
from app.utils.db import get_engine
if not getattr(settings, user_setting):
pytest.skip(
f"未配置 {user_setting}:先执行 scripts/core/00-grant.sql 并写入 .env"
)
engine = get_engine(getattr(settings, database_setting), role)
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
except Exception as exc:
pytest.skip(f"真 MySQL 不可用({exc})")
return engine
def test_core_ro_account_is_readonly():
"""xh_core_ro 只有 SELECT:INSERT 被数据库拒绝(Core 只读 DB 级强制)。"""
from sqlalchemy.exc import SQLAlchemyError
engine = _role_engine_or_skip("ro", "mysql_core_database", "mysql_core_ro_user")
with pytest.raises(SQLAlchemyError) as exc:
with engine.begin() as conn:
conn.execute(
text(
"INSERT INTO core_trade (trade_id, customer_id, product_id, trade_type,"
" amount, trade_status, traded_at)"
" VALUES ('TRD-TEST-RO-DENY', '__x__', '__x__', 'subscribe', 1,"
" 'confirmed', NOW())"
)
)
assert "denied" in str(exc.value).lower()
def test_audit_log_append_only():
"""audit_log 只允许追加:UPDATE / DELETE 均被拒(审计红线 DB 级强制)。"""
from sqlalchemy.exc import SQLAlchemyError
engine = _role_engine_or_skip("rw", "mysql_database", "mysql_agent_user")
for stmt in (
"UPDATE audit_log SET decision = 'tampered' WHERE id = -1",
"DELETE FROM audit_log WHERE id = -1",
):
with pytest.raises(SQLAlchemyError) as exc:
with engine.begin() as conn:
conn.execute(text(stmt))
assert "denied" in str(exc.value).lower(), stmt
def test_core_rw_scope():
"""xh_core_rw 限 4 表:对第 5 张表 core_product 的写被拒(最小权限)。"""
from sqlalchemy.exc import SQLAlchemyError
engine = _role_engine_or_skip("rw", "mysql_core_database", "mysql_core_rw_user")
with pytest.raises(SQLAlchemyError) as exc:
with engine.begin() as conn:
conn.execute(
text("UPDATE core_product SET product_name = '__x__' WHERE product_id = '__x__'")
)
assert "denied" in str(exc.value).lower()