T-0 / T-0b(门禁 · 2026-09-10) - T-0:sqlite 与 MySQL 结构对齐 —— core_holding 统一为 qty/cost_amount/as_of/pnl_pct + PK + UNIQUE(customer_id, product_id);补 core_product_nav;新增建库自校验 _assert_ddl_aligned()(R-g);test_db.py 增 3 条门禁用例(含反向验证门禁失效) - T-0b:DB 账号分离(D20)—— 新增 scripts/core/00-grant.sql(三账号逐表授权); settings.py 增 3 组账号;db.py 改 get_engine(db, role),缓存键改为 (库名, 角色), 账号未配置回退单账号;core_ro→ro / gateway_repository→rw / risk·session_repository→rw; tests/conftest.py 四处显式 role="admin"(R-e) T-1(数据层) - scripts/core/01-ddl.sql:新建 core_fee_rule / core_share_lot / core_convert_lot_detail; core_trade 加 convert_group_id + idx_convert_group;core_product 加 8 列 + fee_rate 补 COMMENT - 新增 07-seed-fee-rule.sql(赎回费 5 档 × 14 产品,按 22 号文 §10)/ 08-seed-share-lot.sql (58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty)/ 09-seed-org.sql(管理人 + TA + 申购费率 + 最低持有余额,v1.1 按「管理人全产品线」重排) - reset.ps1 追加 07/08/09;02-mysql-agent专用.sql 追加 risk_convert_detail - tests/_ddl.py 同步 4 表 + 新增 REQUIRED_CONVERT_TABLES 建库门禁 - 新增 scripts/dev/verify_convert_seed.py(pymysql 等价 reset 流程 + 8 条 DoD 断言, 含断言 ⑧「费率档 ↔ product_type 匹配」,越档即 FAIL) T-2 / T-2b(纯函数包 + 示例实算回填) - 新增 app/service/convert/ 7 文件:__init__ / types / calc / fee / nav / lot_bootstrap / errors (纯函数,不查库、不碰 SQL;所有量化显式 ROUND_HALF_UP;lot_bootstrap 用 zlib.crc32 保证 D18 跨进程同源) - 新增 tests/test_convert_calc.py 93 用例(12 类:HALF_UP 反向自证 / 分档边界 / FIFO 含同 confirmed_at 兜底 / 双口径 / 强制全转与强制赎回 / PRD §5.3 全链自证 / 纯函数零 IO 依赖断言) - 重写 scripts/dev/calc_convert_demo.py:去掉脚本内公式副本,改为调用生产 calc.py, 末尾与 PRD §5.3 逐项比对(不一致即退出码 1),兼作一致性门禁 验证 - pytest 609 passed / 3 skipped(516 → +93,零回归) - verify_convert_seed.py 8/8 PASS;calc_convert_demo.py 15/15 与 PRD §5.3 一致 文档:PRD v0.9.1(费率分类修正)· 架构 §7 签名回填 / §8.3 错误码注 / §15 T-2 完成 · 开发计划 §1.5 新增 R-h + §4.2·§4.3 执行记录 · AGENTS.md · docs/memory
273 lines
10 KiB
Python
273 lines
10 KiB
Python
"""引擎工厂(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_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()
|