|
|
|
@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
降级场景验证测试
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
覆盖:
|
|
|
|
|
|
|
|
1. Redis 不可用但 MySQL 可用时,自动降级到 MySQL
|
|
|
|
|
|
|
|
2. Redis 和 MySQL 均不可用时,自动降级到 SQLite
|
|
|
|
|
|
|
|
3. 各降级场景下接口正常返回,无 500 错误
|
|
|
|
|
|
|
|
4. 恢复 Redis 服务后,等待惰性检测间隔验证自动恢复
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
实现说明:
|
|
|
|
|
|
|
|
- 使用 FastAPI TestClient 验证接口层行为。
|
|
|
|
|
|
|
|
- 使用内存 SQLite 引擎作为 MySQL 替身(StorageManager 基于 SQLAlchemy 通用接口)。
|
|
|
|
|
|
|
|
- 通过 patch `app.services.cache.get_storage_manager` 注入已配置好的 StorageManager,
|
|
|
|
|
|
|
|
从而绕过当前 `app/main.py` 启动时尚未将初始化后的 Redis/MySQL 客户端注入 StorageManager
|
|
|
|
|
|
|
|
全局单例的问题,专注于验证 cache / API 层的降级与恢复逻辑。
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
from sqlalchemy import create_engine, text
|
|
|
|
|
|
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
|
|
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
from app.models import Base, MarketData
|
|
|
|
|
|
|
|
from app.storage_manager import StorageManager
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# test_main_lifespan.py 中的 fixture 会 patch app.main.Base.metadata.create_all 且未正确恢复,
|
|
|
|
|
|
|
|
# 导致后续依赖 create_all 的测试失败。这里保留原始方法引用,必要时恢复。
|
|
|
|
|
|
|
|
_original_create_all = Base.metadata.create_all
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===== Fixtures =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
|
|
def sqlite_engine():
|
|
|
|
|
|
|
|
"""应用主数据库使用的内存 SQLite 引擎。"""
|
|
|
|
|
|
|
|
# 防御:修复上游 fixture 对 Base.metadata.create_all 的 patch 泄漏
|
|
|
|
|
|
|
|
if isinstance(Base.metadata.create_all, MagicMock):
|
|
|
|
|
|
|
|
Base.metadata.create_all = _original_create_all
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
engine = create_engine(
|
|
|
|
|
|
|
|
"sqlite:///:memory:",
|
|
|
|
|
|
|
|
connect_args={"check_same_thread": False},
|
|
|
|
|
|
|
|
poolclass=StaticPool,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
Base.metadata.create_all(bind=engine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 确认表已创建
|
|
|
|
|
|
|
|
with engine.connect() as conn:
|
|
|
|
|
|
|
|
result = conn.execute(
|
|
|
|
|
|
|
|
text("SELECT name FROM sqlite_master WHERE type='table' AND name='market_data'")
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
assert result.fetchone() is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
yield engine
|
|
|
|
|
|
|
|
engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
|
|
def mysql_engine():
|
|
|
|
|
|
|
|
"""作为 MySQL 替身的内存 SQLite 引擎(StorageManager 使用 SQLAlchemy 通用接口)。"""
|
|
|
|
|
|
|
|
# 防御:修复上游 fixture 对 Base.metadata.create_all 的 patch 泄漏
|
|
|
|
|
|
|
|
if isinstance(Base.metadata.create_all, MagicMock):
|
|
|
|
|
|
|
|
Base.metadata.create_all = _original_create_all
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
engine = create_engine(
|
|
|
|
|
|
|
|
"sqlite:///:memory:",
|
|
|
|
|
|
|
|
connect_args={"check_same_thread": False},
|
|
|
|
|
|
|
|
poolclass=StaticPool,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
Base.metadata.create_all(bind=engine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 确认表已创建
|
|
|
|
|
|
|
|
with engine.connect() as conn:
|
|
|
|
|
|
|
|
result = conn.execute(
|
|
|
|
|
|
|
|
text("SELECT name FROM sqlite_master WHERE type='table' AND name='market_data'")
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
assert result.fetchone() is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
yield engine
|
|
|
|
|
|
|
|
engine.dispose()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
|
|
def app_with_db(sqlite_engine):
|
|
|
|
|
|
|
|
"""返回替换 get_db 依赖为内存 SQLite 的 FastAPI app。"""
|
|
|
|
|
|
|
|
from app.main import app
|
|
|
|
|
|
|
|
from app.database import get_db as original_get_db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=sqlite_engine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def override_get_db():
|
|
|
|
|
|
|
|
db = SessionLocal()
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
yield db
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
|
|
|
db.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.dependency_overrides[original_get_db] = override_get_db
|
|
|
|
|
|
|
|
yield app
|
|
|
|
|
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
|
|
def patch_lifespan_deps():
|
|
|
|
|
|
|
|
"""Patch lifespan 依赖,避免真实连接数据库/Redis/MySQL 和启动调度器。"""
|
|
|
|
|
|
|
|
mock_db = MagicMock()
|
|
|
|
|
|
|
|
mock_session_local = MagicMock(return_value=mock_db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patches = [
|
|
|
|
|
|
|
|
patch("app.main.Base.metadata.create_all"),
|
|
|
|
|
|
|
|
patch("app.main.UserBase.metadata.create_all"),
|
|
|
|
|
|
|
|
patch("app.analysis_db.init_analysis_db"),
|
|
|
|
|
|
|
|
patch("app.database.SessionLocal", mock_session_local),
|
|
|
|
|
|
|
|
patch("app.auth_service.create_default_admin"),
|
|
|
|
|
|
|
|
patch("app.main.start_scheduler"),
|
|
|
|
|
|
|
|
patch("app.services.cache.list_tasks", return_value=[]),
|
|
|
|
|
|
|
|
patch("app.services.scheduler.add_job"),
|
|
|
|
|
|
|
|
patch("app.main.stop_scheduler"),
|
|
|
|
|
|
|
|
patch("app.redis_client.init_redis"),
|
|
|
|
|
|
|
|
patch("app.mysql_database.init_mysql"),
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
started = [p.start() for p in patches]
|
|
|
|
|
|
|
|
yield {
|
|
|
|
|
|
|
|
"Base_create_all": started[0],
|
|
|
|
|
|
|
|
"UserBase_create_all": started[1],
|
|
|
|
|
|
|
|
"init_analysis_db": started[2],
|
|
|
|
|
|
|
|
"SessionLocal": started[3],
|
|
|
|
|
|
|
|
"create_default_admin": started[4],
|
|
|
|
|
|
|
|
"start_scheduler": started[5],
|
|
|
|
|
|
|
|
"list_tasks": started[6],
|
|
|
|
|
|
|
|
"add_job": started[7],
|
|
|
|
|
|
|
|
"stop_scheduler": started[8],
|
|
|
|
|
|
|
|
"init_redis": started[9],
|
|
|
|
|
|
|
|
"init_mysql": started[10],
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
for p in patches:
|
|
|
|
|
|
|
|
p.stop()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeRedis:
|
|
|
|
|
|
|
|
"""可切换可用状态的轻量级 Redis 替身。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, available=True):
|
|
|
|
|
|
|
|
self._store = {}
|
|
|
|
|
|
|
|
self._available = available
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_available(self, available):
|
|
|
|
|
|
|
|
self._available = available
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ping(self):
|
|
|
|
|
|
|
|
if not self._available:
|
|
|
|
|
|
|
|
raise Exception("redis down")
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get(self, key):
|
|
|
|
|
|
|
|
return self._store.get(key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def setex(self, key, ttl, value):
|
|
|
|
|
|
|
|
self._store[key] = value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete(self, *keys):
|
|
|
|
|
|
|
|
for key in keys:
|
|
|
|
|
|
|
|
self._store.pop(key, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def keys(self, pattern):
|
|
|
|
|
|
|
|
prefix = pattern.rstrip("*")
|
|
|
|
|
|
|
|
return [k for k in self._store if k.startswith(prefix)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===== Helpers =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _seed_market_data(
|
|
|
|
|
|
|
|
engine,
|
|
|
|
|
|
|
|
symbol="AG2606",
|
|
|
|
|
|
|
|
data_type="futures",
|
|
|
|
|
|
|
|
period="5min",
|
|
|
|
|
|
|
|
current_price=123.45,
|
|
|
|
|
|
|
|
):
|
|
|
|
|
|
|
|
"""在指定引擎中写入一条行情数据。"""
|
|
|
|
|
|
|
|
Session = sessionmaker(bind=engine)
|
|
|
|
|
|
|
|
with Session() as db:
|
|
|
|
|
|
|
|
db.add(
|
|
|
|
|
|
|
|
MarketData(
|
|
|
|
|
|
|
|
symbol=symbol,
|
|
|
|
|
|
|
|
data_type=data_type,
|
|
|
|
|
|
|
|
period=period,
|
|
|
|
|
|
|
|
candles_json=json.dumps(
|
|
|
|
|
|
|
|
[
|
|
|
|
|
|
|
|
{
|
|
|
|
|
|
|
|
"datetime": "2026-07-04T09:55:00",
|
|
|
|
|
|
|
|
"open": 100,
|
|
|
|
|
|
|
|
"high": 115,
|
|
|
|
|
|
|
|
"low": 95,
|
|
|
|
|
|
|
|
"close": 110,
|
|
|
|
|
|
|
|
"volume": 1000,
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
current_price=current_price,
|
|
|
|
|
|
|
|
fetched_at=datetime(2026, 7, 4, 10, 0, 0),
|
|
|
|
|
|
|
|
candle_count=1,
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===== Lifespan 日志测试 =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLifespanFallbackLogs:
|
|
|
|
|
|
|
|
"""启动时降级检测日志测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
|
|
async def test_logs_mysql_mode_when_redis_unavailable(
|
|
|
|
|
|
|
|
self, patch_lifespan_deps, caplog
|
|
|
|
|
|
|
|
):
|
|
|
|
|
|
|
|
"""Redis 不可用但 MySQL 可用时,lifespan 应输出 MySQL 降级模式日志。"""
|
|
|
|
|
|
|
|
from app.main import lifespan, app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_lifespan_deps["init_redis"].return_value = None
|
|
|
|
|
|
|
|
patch_lifespan_deps["init_mysql"].return_value = MagicMock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with caplog.at_level(logging.WARNING, logger="app.main"):
|
|
|
|
|
|
|
|
async with lifespan(app):
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert "存储模式: MySQL (Redis 不可用)" in caplog.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
|
|
|
async def test_logs_sqlite_mode_when_both_unavailable(
|
|
|
|
|
|
|
|
self, patch_lifespan_deps, caplog
|
|
|
|
|
|
|
|
):
|
|
|
|
|
|
|
|
"""Redis 和 MySQL 均不可用时,lifespan 应输出 SQLite 降级模式日志。"""
|
|
|
|
|
|
|
|
from app.main import lifespan, app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_lifespan_deps["init_redis"].return_value = None
|
|
|
|
|
|
|
|
patch_lifespan_deps["init_mysql"].return_value = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with caplog.at_level(logging.ERROR, logger="app.main"):
|
|
|
|
|
|
|
|
async with lifespan(app):
|
|
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert "存储模式: SQLite (Redis 和 MySQL 均不可用)" in caplog.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===== StorageManager 运行时降级与恢复测试 =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestStorageManagerFallback:
|
|
|
|
|
|
|
|
"""StorageManager 运行时降级与恢复测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_falls_back_to_mysql_when_redis_unavailable(self, mysql_engine):
|
|
|
|
|
|
|
|
"""Redis 不可用但 MySQL 可用时,从 MySQL 读取数据。"""
|
|
|
|
|
|
|
|
_seed_market_data(mysql_engine, current_price=123.45)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fake_redis = FakeRedis(available=False)
|
|
|
|
|
|
|
|
manager = StorageManager(redis_client=fake_redis, mysql_engine=mysql_engine)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
result = manager.get_market_data("AG2606", "futures", "5min")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert result is not None
|
|
|
|
|
|
|
|
assert result["current_price"] == 123.45
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_returns_none_when_both_unavailable(self):
|
|
|
|
|
|
|
|
"""Redis 和 MySQL 均不可用时,StorageManager 返回 None(由调用方降级到 SQLite)。"""
|
|
|
|
|
|
|
|
fake_redis = FakeRedis(available=False)
|
|
|
|
|
|
|
|
manager = StorageManager(redis_client=fake_redis, mysql_engine=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
result = manager.get_market_data("AG2606", "futures", "5min")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_recovers_to_redis_after_interval(self, mysql_engine):
|
|
|
|
|
|
|
|
"""Redis 恢复后,超过惰性检测间隔应重新使用 Redis 并回填缓存。"""
|
|
|
|
|
|
|
|
_seed_market_data(mysql_engine, current_price=123.45)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fake_redis = FakeRedis(available=False)
|
|
|
|
|
|
|
|
manager = StorageManager(
|
|
|
|
|
|
|
|
redis_client=fake_redis, mysql_engine=mysql_engine, check_interval=0.1
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Redis 不可用,首次读取应来自 MySQL
|
|
|
|
|
|
|
|
result = manager.get_market_data("AG2606", "futures", "5min")
|
|
|
|
|
|
|
|
assert result["current_price"] == 123.45
|
|
|
|
|
|
|
|
# Redis 不可用,不应有回填
|
|
|
|
|
|
|
|
assert fake_redis.get("market_data:AG2606:5min") is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 恢复 Redis
|
|
|
|
|
|
|
|
fake_redis.set_available(True)
|
|
|
|
|
|
|
|
# 等待检测间隔过期
|
|
|
|
|
|
|
|
time.sleep(0.15)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 再次读取:Redis ping 通过,get 返回 None,回源 MySQL 后回填 Redis
|
|
|
|
|
|
|
|
result = manager.get_market_data("AG2606", "futures", "5min")
|
|
|
|
|
|
|
|
assert result["current_price"] == 123.45
|
|
|
|
|
|
|
|
assert fake_redis.get("market_data:AG2606:5min") is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===== API 接口降级测试 =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestApiFallbackResponses:
|
|
|
|
|
|
|
|
"""API 接口在不同存储模式下的响应测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_returns_mysql_data_when_redis_unavailable(
|
|
|
|
|
|
|
|
self, patch_lifespan_deps, app_with_db, sqlite_engine, mysql_engine
|
|
|
|
|
|
|
|
):
|
|
|
|
|
|
|
|
"""Redis 不可用但 MySQL 可用时,接口从 MySQL 返回数据且无 500 错误。"""
|
|
|
|
|
|
|
|
_seed_market_data(mysql_engine, current_price=123.45)
|
|
|
|
|
|
|
|
_seed_market_data(sqlite_engine, current_price=456.78)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fake_redis = FakeRedis(available=False)
|
|
|
|
|
|
|
|
storage_manager = StorageManager(
|
|
|
|
|
|
|
|
redis_client=fake_redis, mysql_engine=mysql_engine, check_interval=0.1
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_lifespan_deps["init_redis"].return_value = None
|
|
|
|
|
|
|
|
patch_lifespan_deps["init_mysql"].return_value = mysql_engine
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.mysql_database.mysql_engine", mysql_engine):
|
|
|
|
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager):
|
|
|
|
|
|
|
|
with TestClient(app_with_db) as client:
|
|
|
|
|
|
|
|
response = client.get("/api/v1/data/latest/AG2606?period=5min")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
|
|
assert data["current_price"] == 123.45
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_returns_sqlite_data_when_both_unavailable(
|
|
|
|
|
|
|
|
self, patch_lifespan_deps, app_with_db, sqlite_engine
|
|
|
|
|
|
|
|
):
|
|
|
|
|
|
|
|
"""Redis 和 MySQL 均不可用时,接口降级到 SQLite 返回数据且无 500 错误。"""
|
|
|
|
|
|
|
|
_seed_market_data(sqlite_engine, current_price=456.78)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fake_redis = FakeRedis(available=False)
|
|
|
|
|
|
|
|
storage_manager = StorageManager(
|
|
|
|
|
|
|
|
redis_client=fake_redis, mysql_engine=None, check_interval=0.1
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_lifespan_deps["init_redis"].return_value = None
|
|
|
|
|
|
|
|
patch_lifespan_deps["init_mysql"].return_value = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager):
|
|
|
|
|
|
|
|
with TestClient(app_with_db) as client:
|
|
|
|
|
|
|
|
response = client.get("/api/v1/data/latest/AG2606?period=5min")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
|
|
assert data["current_price"] == 456.78
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_api_returns_404_not_500_when_no_data_anywhere(
|
|
|
|
|
|
|
|
self, patch_lifespan_deps, app_with_db
|
|
|
|
|
|
|
|
):
|
|
|
|
|
|
|
|
"""任何存储层均无数据时,接口应返回 404 而非 500。"""
|
|
|
|
|
|
|
|
fake_redis = FakeRedis(available=False)
|
|
|
|
|
|
|
|
storage_manager = StorageManager(
|
|
|
|
|
|
|
|
redis_client=fake_redis, mysql_engine=None, check_interval=0.1
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
patch_lifespan_deps["init_redis"].return_value = None
|
|
|
|
|
|
|
|
patch_lifespan_deps["init_mysql"].return_value = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with patch("app.services.cache.get_storage_manager", return_value=storage_manager):
|
|
|
|
|
|
|
|
with TestClient(app_with_db) as client:
|
|
|
|
|
|
|
|
response = client.get("/api/v1/data/latest/UNKNOWN?period=5min")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 404
|