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