|
|
"""测试配置与公共Fixtures"""
|
|
|
from collections.abc import AsyncGenerator, Generator
|
|
|
from contextlib import asynccontextmanager
|
|
|
from datetime import datetime, timedelta
|
|
|
from typing import Any
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
|
|
import pytest
|
|
|
from fastapi.testclient import TestClient
|
|
|
from sqlalchemy import create_engine
|
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
# Mock 结果辅助类
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class MockScalarResult:
|
|
|
"""模拟 SQLAlchemy ScalarResult"""
|
|
|
|
|
|
def __init__(self, items: list[Any] | None = None):
|
|
|
self._items = items or []
|
|
|
|
|
|
def all(self) -> list[Any]:
|
|
|
return list(self._items)
|
|
|
|
|
|
def first(self) -> Any:
|
|
|
return self._items[0] if self._items else None
|
|
|
|
|
|
def one_or_none(self) -> Any:
|
|
|
if len(self._items) == 1:
|
|
|
return self._items[0]
|
|
|
if len(self._items) == 0:
|
|
|
return None
|
|
|
raise Exception("Multiple rows found")
|
|
|
|
|
|
def __iter__(self):
|
|
|
return iter(self._items)
|
|
|
|
|
|
|
|
|
class MockResult:
|
|
|
"""模拟 SQLAlchemy Result"""
|
|
|
|
|
|
def __init__(self, items: list[Any] | None = None, scalar: Any = None):
|
|
|
self._items = items or []
|
|
|
self._scalar = scalar
|
|
|
|
|
|
def scalars(self) -> MockScalarResult:
|
|
|
return MockScalarResult(self._items)
|
|
|
|
|
|
def scalar_one_or_none(self) -> Any:
|
|
|
if self._scalar is not None:
|
|
|
return self._scalar
|
|
|
if len(self._items) == 1:
|
|
|
return self._items[0]
|
|
|
if len(self._items) == 0:
|
|
|
return None
|
|
|
return self._items[0]
|
|
|
|
|
|
def scalar(self) -> Any:
|
|
|
return self._scalar
|
|
|
|
|
|
def first(self) -> Any:
|
|
|
return self._items[0] if self._items else None
|
|
|
|
|
|
def all(self) -> list[Any]:
|
|
|
return list(self._items)
|
|
|
|
|
|
|
|
|
def make_mock_db(return_items: list[Any] | None = None, return_scalar: Any = None) -> AsyncMock:
|
|
|
"""创建一个模拟的异步数据库会话。
|
|
|
|
|
|
Args:
|
|
|
return_items: execute() 返回的列表数据
|
|
|
return_scalar: execute() 返回的标量数据
|
|
|
"""
|
|
|
mock_db = AsyncMock()
|
|
|
result = MockResult(items=return_items, scalar=return_scalar)
|
|
|
mock_db.execute = AsyncMock(return_value=result)
|
|
|
mock_db.flush = AsyncMock()
|
|
|
mock_db.commit = AsyncMock()
|
|
|
mock_db.rollback = AsyncMock()
|
|
|
mock_db.refresh = AsyncMock()
|
|
|
mock_db.delete = MagicMock()
|
|
|
mock_db.add = MagicMock()
|
|
|
return mock_db
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
# Fixtures
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
|
def db_session() -> Generator[Session, None, None]:
|
|
|
"""创建测试用数据库会话(内存SQLite)"""
|
|
|
engine = create_engine("sqlite:///:memory:")
|
|
|
from app.models.analysis import Base as AnalysisBase
|
|
|
from app.models.market import Base as MarketBase
|
|
|
from app.models.user import Base as UserBase
|
|
|
|
|
|
MarketBase.metadata.create_all(engine)
|
|
|
AnalysisBase.metadata.create_all(engine)
|
|
|
UserBase.metadata.create_all(engine)
|
|
|
session_factory = sessionmaker(bind=engine)
|
|
|
session = session_factory()
|
|
|
yield session
|
|
|
session.close()
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
async def _mock_lifespan(app: Any) -> AsyncGenerator[None, None]:
|
|
|
"""模拟应用生命周期,跳过真实数据库/调度器初始化。"""
|
|
|
yield
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
def mock_session() -> AsyncMock:
|
|
|
"""创建一个可配置的 mock 数据库会话"""
|
|
|
return make_mock_db()
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
def client(mock_session: AsyncMock) -> Generator[TestClient, None, None]:
|
|
|
"""创建FastAPI测试客户端(mock lifespan + mock get_db)。"""
|
|
|
from app.database import get_db
|
|
|
from app.main import app
|
|
|
|
|
|
app.router.lifespan_context = _mock_lifespan
|
|
|
|
|
|
async def _override_get_db() -> AsyncGenerator[Any, None]:
|
|
|
yield mock_session
|
|
|
|
|
|
app.dependency_overrides[get_db] = _override_get_db
|
|
|
|
|
|
with TestClient(app) as c:
|
|
|
yield c
|
|
|
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
def client_factory() -> Any:
|
|
|
"""创建一个可以自定义 mock_session 的客户端工厂"""
|
|
|
from app.database import get_db
|
|
|
from app.main import app
|
|
|
|
|
|
app.router.lifespan_context = _mock_lifespan
|
|
|
|
|
|
def _create_client(return_items=None, return_scalar=None):
|
|
|
mock_db = make_mock_db(return_items=return_items, return_scalar=return_scalar)
|
|
|
|
|
|
async def _override_get_db() -> AsyncGenerator[Any, None]:
|
|
|
yield mock_db
|
|
|
|
|
|
app.dependency_overrides[get_db] = _override_get_db
|
|
|
return TestClient(app), mock_db
|
|
|
|
|
|
yield _create_client
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
def mock_akshare() -> Any:
|
|
|
"""Mock akshare数据采集,返回样本K线数据"""
|
|
|
mock = AsyncMock()
|
|
|
mock.fetch_symbol_data.return_value = _build_sample_result()
|
|
|
return mock
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
def sample_kline_data() -> list[dict[str, Any]]:
|
|
|
"""样本K线数据"""
|
|
|
base_date = datetime(2025, 1, 2)
|
|
|
data: list[dict[str, Any]] = []
|
|
|
for i in range(100):
|
|
|
data.append(
|
|
|
{
|
|
|
"date": (base_date + timedelta(days=i)).strftime("%Y-%m-%d"),
|
|
|
"open": 4000.0 + i * 2,
|
|
|
"high": 4050.0 + i * 2,
|
|
|
"low": 3980.0 + i * 2,
|
|
|
"close": 4020.0 + i * 2,
|
|
|
"volume": 10000 + i * 100,
|
|
|
"hold": 50000 + i * 50,
|
|
|
}
|
|
|
)
|
|
|
return data
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
def sample_symbols_config() -> list[dict[str, Any]]:
|
|
|
"""样本品种配置"""
|
|
|
return [
|
|
|
{
|
|
|
"symbol": "rb2510",
|
|
|
"variety": "螺纹钢",
|
|
|
"exchange": "SHFE",
|
|
|
"enabled": True,
|
|
|
"periods": ["1d", "4h", "1h"],
|
|
|
},
|
|
|
{
|
|
|
"symbol": "i2510",
|
|
|
"variety": "铁矿石",
|
|
|
"exchange": "DCE",
|
|
|
"enabled": True,
|
|
|
"periods": ["1d", "4h"],
|
|
|
},
|
|
|
{
|
|
|
"symbol": "MA510",
|
|
|
"variety": "甲醇",
|
|
|
"exchange": "CZCE",
|
|
|
"enabled": True,
|
|
|
"periods": ["1d"],
|
|
|
},
|
|
|
]
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
# 辅助函数
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_sample_kline_df() -> Any:
|
|
|
"""构建样本K线 DataFrame(供 mock_akshare 使用)"""
|
|
|
try:
|
|
|
import pandas as pd
|
|
|
except ImportError:
|
|
|
return None
|
|
|
|
|
|
base_date = datetime(2025, 1, 2)
|
|
|
rows = []
|
|
|
for i in range(100):
|
|
|
rows.append(
|
|
|
{
|
|
|
"date": (base_date + timedelta(days=i)).strftime("%Y%m%d"),
|
|
|
"open": 4000.0 + i * 2,
|
|
|
"high": 4050.0 + i * 2,
|
|
|
"low": 3980.0 + i * 2,
|
|
|
"close": 4020.0 + i * 2,
|
|
|
"volume": 10000 + i * 100,
|
|
|
"hold": 50000 + i * 50,
|
|
|
}
|
|
|
)
|
|
|
return pd.DataFrame(rows)
|
|
|
|
|
|
|
|
|
def _build_sample_result() -> dict[str, Any]:
|
|
|
"""构建样本采集结果。"""
|
|
|
return {
|
|
|
"symbol": "rb2510",
|
|
|
"type": "futures",
|
|
|
"current_price": 4020.0,
|
|
|
"timestamp": "2025-01-15T10:00:00",
|
|
|
"timeframes": {
|
|
|
"daily": [
|
|
|
{
|
|
|
"datetime": "2025-01-10",
|
|
|
"open": 4000.0,
|
|
|
"high": 4050.0,
|
|
|
"low": 3980.0,
|
|
|
"close": 4020.0,
|
|
|
"volume": 10000,
|
|
|
}
|
|
|
],
|
|
|
},
|
|
|
}
|
|
|
|
|
|
|
|
|
def make_mock_user(
|
|
|
user_id: int = 1,
|
|
|
username: str = "admin",
|
|
|
role: str = "admin",
|
|
|
email: str = "admin@test.com",
|
|
|
) -> Any:
|
|
|
"""创建一个模拟用户对象。"""
|
|
|
|
|
|
class MockUser:
|
|
|
def __init__(self) -> None:
|
|
|
self.id = user_id
|
|
|
self.username = username
|
|
|
self.role = role
|
|
|
self.email = email
|
|
|
self.is_active = True
|
|
|
self.created_at = datetime(2025, 1, 1)
|
|
|
self.last_login = datetime(2025, 1, 15)
|
|
|
|
|
|
return MockUser()
|