Files
group_xinghuo_jinrong/tests/test_db.py
T

273 lines
10 KiB
Python
Raw Normal View History

"""引擎工厂(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()