Files
cma-management/backend/tests/conftest.py
T

316 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.
"""
管理会计OS 测试配置
使用 SQLite 内存数据库进行测试,避免依赖外部 MySQL。
测试前自动建表,测试后自动清理。
重要:此文件在pytest收集测试时最先加载,确保环境变量在app模块导入前注入。
"""
import os
# 必须在任何app模块导入之前设置环境变量(通过 pytest.ini 的 python_files 保证加载顺序)
os.environ.setdefault("CMA_DB_USER", "test")
os.environ.setdefault("CMA_DB_PASS", "test")
os.environ.setdefault("CMA_DB_HOST", "localhost")
os.environ.setdefault("CMA_DB_PORT", "3306")
os.environ.setdefault("CMA_DB_NAME", "test")
# ── 测试期生产 Redis 隔离(project-20260918-P1-test-redis-token-fix)────────────
# 机制(详见任务单/报告):
# app.auth_middleware 在 import 时构造 redis 客户端(127.0.0.1:6379 db=1 = 生产库),
# ping 成功即 _redis_available=True → create_token() 走 setex("cma:token:<hex>", 86400)
# 把测试签发的 token 写进生产 Redis;而 require_auth() 只按 token 里的 user_id 反查用户、
# 不比对口令,测试库首个用户 id=1 与生产 users.id=1 撞号 —— 每跑一轮套件都会在生产 Redis
# 留下 24h 有效、可直接当生产会话使用的 token。
# 处置(只改测试侧,不动 app/auth_middleware.py 的任何业务逻辑):
# ① 在导入任何 app 模块之前,把 redis.Redis 构造器替换成阻断器(计数 + 抛错)——
# app 侧既有的 `except Exception → _redis=None / _redis_available=False` 降级分支
# 自动生效,测试期对生产 Redis **零连接**(连 TCP 探测都不发生);
# ② 显式强制 _redis_available=False(进程内 _token_store 兜底,用例语义不变);
# ③ app.utils.cache 同样硬编码 db=1(测试夹具会调 delete("ai")),一并关闭,
# 使整轮测试对生产 Redis 零写命令;
# ④ pytest_sessionfinish 落盘隔离审计 JSON(构造尝试次数 / 通道状态)作为可复核证据。
_REDIS_GUARD = {"constructor_attempts": 0, "callers": []}
class _BlockedProductionRedis:
"""测试期阻断器:生产 Redis 客户端构造被计数并拒绝。
抛出的异常与真实连接失败同形(Exception),由 app 侧既有 except 分支兜底降级,
因此不会改变任何鉴权/缓存语义,只保证测试进程不碰生产 Redis。
"""
def __init__(self, *args, **kwargs):
import traceback
_REDIS_GUARD["constructor_attempts"] += 1
frames = [f for f in traceback.extract_stack()[:-1] if "conftest" not in f.filename]
if frames:
_REDIS_GUARD["callers"].append(f"{frames[-1].filename}:{frames[-1].lineno}")
raise RuntimeError(
"test-mode: production Redis (127.0.0.1:6379 db=1) blocked by backend/tests/conftest.py"
)
def _install_production_redis_guard() -> None:
"""替换 redis.Redis 构造器(进程级,覆盖整轮 pytest 会话)。"""
try:
import redis as redis_lib
except Exception: # redis 未安装:app 侧本来就会降级
return
redis_lib.Redis = _BlockedProductionRedis
_install_production_redis_guard()
import pytest
from typing import Generator
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.pool import StaticPool
# 在 import app 模块之前就先覆盖掉 database.py 的 engine
# 方式:直接 monkey-patch database 模块
from app import database as db_module
from app.database import Base
# SQLite 内存引擎
TEST_ENGINE = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TEST_SESSION_LOCAL = sessionmaker(autocommit=False, autoflush=False, bind=TEST_ENGINE)
# MySQL-only 的 date_format() 在 SQLite 下注册等价实现(仅测试库)
# 生产用 MySQL 原生函数;此处仅为让测试能跑通 expenses 月度累计校验/stats 统计
def _sqlite_date_format(dt_val, fmt):
if dt_val is None:
return None
import datetime as _dt
if isinstance(dt_val, str):
for f in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y-%m"):
try:
dt_val = _dt.datetime.strptime(str(dt_val)[:19], f)
break
except ValueError:
continue
else:
return None
if isinstance(dt_val, _dt.datetime):
d = dt_val
elif isinstance(dt_val, _dt.date):
d = _dt.datetime(dt_val.year, dt_val.month, dt_val.day)
else:
return None
return {
"%Y": f"{d.year:04d}",
"%Y-%m": f"{d.year:04d}-{d.month:02d}",
"%Y-%m-%d": f"{d.year:04d}-{d.month:02d}-{d.day:02d}",
}.get(fmt)
from sqlalchemy import event # noqa: E402
event.listen(TEST_ENGINE, "connect", lambda dbapi_conn, rec: dbapi_conn.create_function("date_format", 2, _sqlite_date_format))
# 替换 database 模块的全局引擎
db_module._engine = TEST_ENGINE
db_module._SessionLocal = TEST_SESSION_LOCAL
# ── 测试期强制关闭 Redis 通道(生产 Redis 零写入硬隔离)──────────────────────
# 不修改 app/auth_middleware.py:只置其模块级开关,走既有进程内 _token_store 降级分支。
# 导入点上方的 _BlockedProductionRedis 已生效 → 这两次 import 不会连生产 Redis。
import app.auth_middleware as auth_middleware # noqa: E402
from app.utils import cache as cache_util # noqa: E402
auth_middleware._redis_available = False
auth_middleware._redis = None # 导入期即被阻断,显式置空防后续被复活
cache_util._available = False
cache_util._client = None
# 然后导入models(它们依赖Base)
from app.models import (
User, StrategicMap, KPIDefinition, KPIValue, KPIAlert,
ActionPlan, OrgNode, StrategicMapVersion,
)
import hashlib
@pytest.fixture(autouse=True)
def setup_db():
"""每个测试函数自动初始化和清理数据库"""
from app.utils import cache as cache_util
cache_util.delete("ai") # 清AI分析缓存,防测试间Redis污染(dashboard-analysis缓存全局共享)
Base.metadata.create_all(bind=TEST_ENGINE)
yield
Base.metadata.drop_all(bind=TEST_ENGINE)
cache_util.delete("ai")
@pytest.fixture(autouse=True)
def _disable_ai_suggestion_push(monkeypatch):
"""R1触达修复(2026-08-31): 测试库把企微推送替换为 no-op,防测试建议推真实企微群
生产环境真实推送(8800 relay);测试只验证推送逻辑(决策类推/预警不推/幂等)不打真实企微。
测试类如需断言推送内容,可自行 monkeypatch.setattr 覆盖本 no-op。
"""
from app.api import ai_analysis
monkeypatch.setattr(ai_analysis, "_push_decision_suggestion", lambda s: True)
@pytest.fixture(autouse=True)
def _enforce_production_redis_isolation():
"""每个用例前后强制断言:测试期 Redis 通道始终关闭(防用例把它翻回 True)。
project-20260918-P1-test-redis-token-fix:生产 Redis 写入的唯一入口是
auth_middleware.create_token() 的 `if _redis_available` 分支 —— 用例级复检保证
「一轮全量测试 = 零生产 Redis 写命令」,任何把开关翻回 True 的用例都会当场暴露。
"""
auth_middleware._redis_available = False
cache_util._available = False
yield
assert auth_middleware._redis_available is False, "测试期禁止开启生产 Redis token 通道"
assert cache_util._available is False, "测试期禁止开启生产 Redis 缓存通道"
@pytest.fixture
def production_redis_isolation_state() -> dict:
"""暴露测试期 Redis 隔离状态(构造阻断计数 + 通道开关),供断言用例使用。"""
return _REDIS_GUARD
def pytest_sessionfinish(session, exitstatus):
"""落盘本轮测试的 Redis 隔离审计(零生产 Redis 连接/写入的可复核证据)。"""
import json
import datetime as _dt
audit = {
"generated_at": _dt.datetime.now().isoformat(timespec="seconds"),
"task": "project-20260918-P1-test-redis-token-fix",
"redis_constructor_attempts": _REDIS_GUARD["constructor_attempts"],
"redis_constructor_blocked": _REDIS_GUARD["constructor_attempts"],
"blocked_callsites": _REDIS_GUARD["callers"],
"auth_middleware_redis_available": bool(auth_middleware._redis_available),
"auth_middleware_redis_client_is_none": auth_middleware._redis is None,
"cache_available": bool(cache_util._available),
"cache_client_is_none": cache_util._client is None,
"token_store_size": len(auth_middleware._token_store),
"pytest_exitstatus": int(exitstatus),
"violation": bool(auth_middleware._redis_available or cache_util._available),
}
path = os.environ.get("CMA_TEST_REDIS_AUDIT", "/tmp/cma-test-redis-isolation-audit.json")
try:
with open(path, "w", encoding="utf-8") as fh:
json.dump(audit, fh, ensure_ascii=False, indent=2)
except Exception:
pass
@pytest.fixture
def db() -> Generator[Session, None, None]:
"""提供数据库 session"""
session = TEST_SESSION_LOCAL()
try:
yield session
finally:
session.close()
@pytest.fixture
def client(db) -> Generator[TestClient, None, None]:
"""提供测试 HTTP 客户端"""
from app.main import app
from app.models import Entity
# 账套模式:确保测试库存在 entity_id=1 的active实体
ent = db.query(Entity).filter(Entity.id == 1).first()
if not ent:
db.add(Entity(id=1, name="测试企业", short_name="测试", status="active"))
db.commit()
# 重写依赖,使用测试数据库
app.dependency_overrides[db_module.get_db] = lambda: db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
# ── 测试数据工厂 ──
# 测试夹具口令(仅内存 SQLite 测试库使用;不是生产默认口令——生产凭据只存 600 密钥文件
# .secrets/cma-admin.env)。可用 $CMA_TEST_USER_PASSWORD 覆盖;用例一律从这里导入,禁止写字面量。
TEST_USER_PASSWORD = os.environ.get("CMA_TEST_USER_PASSWORD", "test-fixture-pass")
def create_test_user(db: Session, **kwargs) -> User:
"""创建测试用户"""
defaults = {
"username": "testadmin",
"password_hash": hashlib.sha256(TEST_USER_PASSWORD.encode()).hexdigest(),
"name": "测试管理员",
"role": "ceo",
}
defaults.update(kwargs)
user = User(**defaults)
db.add(user)
db.commit()
db.refresh(user)
return user
def get_token_for_user(client: TestClient, username: str = "testadmin", password: str = TEST_USER_PASSWORD) -> str:
"""获取测试用户的token(账套模式:需entity_id)"""
resp = client.post("/api/cma/auth/login", json={
"username": username,
"password": password,
"entity_id": 1,
})
if resp.status_code != 200:
raise RuntimeError(f"登录失败: {resp.status_code} {resp.text[:300]}")
data = resp.json()
return data.get("token") or data.get("access_token")
def auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def create_test_kpi(db: Session, **kwargs) -> KPIDefinition:
"""创建测试KPI"""
defaults = {
"kpi_code": "TEST_001",
"kpi_name": "测试KPI",
"dimension": "finance",
"target_value": 100.0,
"unit": "%",
"status": "active",
}
defaults.update(kwargs)
kpi = KPIDefinition(**defaults)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def create_test_map(db: Session, **kwargs) -> StrategicMap:
"""创建测试战略地图"""
defaults = {
"title": "测试地图",
"status": "draft",
"dimensions": [
{"key": "finance", "name": "财务维度", "icon": "💰", "color": "#409eff", "objectives": []},
{"key": "customer", "name": "客户维度", "icon": "🤝", "color": "#67c23a", "objectives": []},
],
"canvas_data": {"connections": []},
}
defaults.update(kwargs)
m = StrategicMap(**defaults)
db.add(m)
db.commit()
db.refresh(m)
return m