You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
buffer_platform/tests/test_cache_storage_integrat...

501 lines
21 KiB

"""
cache.py StorageManager 集成与 SQLite 降级逻辑测试
"""
import json
import logging
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models import Base, MarketData, SymbolTimestamp
@pytest.fixture
def db():
"""提供已创建表结构的内存 SQLite 会话。"""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
@pytest.fixture
def sample_data():
"""构造与采集脚本格式一致的测试数据。"""
return {
"type": "futures",
"current_price": 123.45,
"timestamp": datetime(2026, 7, 4, 10, 0, 0).isoformat(),
"timeframes": {
"5min": [{"datetime": "2026-07-04T09:55:00", "open": 100, "close": 110}],
"15min": [{"datetime": "2026-07-04T09:45:00", "open": 105, "close": 115}],
},
}
@pytest.fixture
def storage_manager_mock():
"""构造默认可用的 StorageManager mock。"""
manager = MagicMock()
manager.is_available.return_value = True
manager.save_market_data.return_value = True
manager.save_symbol_timestamp.return_value = True
manager.get_market_data.return_value = None
manager.get_symbol_timestamp.return_value = None
return manager
def _seed_sqlite_market_data(db, symbol="AG2606", data_type="futures"):
"""在 SQLite 中写入行情数据(使用当前时间确保缓存未过期)。"""
fetched_at = datetime.now()
db.add_all(
[
MarketData(
symbol=symbol,
data_type=data_type,
period="5min",
candles_json=json.dumps(
[{"datetime": "2026-07-04T09:55:00", "open": 100, "close": 110}]
),
current_price=123.45,
fetched_at=fetched_at,
candle_count=1,
),
MarketData(
symbol=symbol,
data_type=data_type,
period="15min",
candles_json=json.dumps(
[{"datetime": "2026-07-04T09:45:00", "open": 105, "close": 115}]
),
current_price=123.45,
fetched_at=fetched_at,
candle_count=1,
),
]
)
db.commit()
def _seed_sqlite_symbol_timestamp(db, symbol="AG2606", data_type="futures"):
"""在 SQLite 中写入合约时间戳。"""
db.add(
SymbolTimestamp(
symbol=symbol,
data_type=data_type,
last_refresh_at=datetime(2026, 7, 4, 10, 0, 0),
refresh_count=3,
)
)
db.commit()
class TestSaveMarketData:
"""save_market_data 双写与降级测试。"""
def test_uses_storage_manager_when_available(self, db, sample_data, storage_manager_mock):
"""StorageManager 可用时优先写入存储层,并同步更新时间戳,返回代表记录。"""
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
result = cache.save_market_data(db, "AG2606", sample_data)
storage_manager_mock.is_available.assert_called_once()
storage_manager_mock.save_market_data.assert_called_once_with("AG2606", sample_data)
storage_manager_mock.save_symbol_timestamp.assert_called_once()
assert isinstance(result, MarketData)
assert result.symbol == "AG2606"
assert result.data_type == "futures"
assert result.current_price == 123.45
sqlite_records = db.query(MarketData).filter_by(symbol="AG2606").all()
assert len(sqlite_records) == 0
def test_falls_back_to_sqlite_when_storage_unavailable(self, db, sample_data, storage_manager_mock):
"""StorageManager 不可用时降级到 SQLite 写入。"""
storage_manager_mock.is_available.return_value = False
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
result = cache.save_market_data(db, "AG2606", sample_data)
storage_manager_mock.save_market_data.assert_not_called()
storage_manager_mock.save_symbol_timestamp.assert_not_called()
assert isinstance(result, MarketData)
sqlite_records = db.query(MarketData).filter_by(symbol="AG2606").all()
assert len(sqlite_records) == 2
periods = {r.period for r in sqlite_records}
assert periods == {"5min", "15min"}
def test_falls_back_to_sqlite_when_storage_save_fails(self, db, sample_data, storage_manager_mock):
"""StorageManager 写入失败时降级到 SQLite 写入。"""
storage_manager_mock.save_market_data.return_value = False
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
result = cache.save_market_data(db, "AG2606", sample_data)
storage_manager_mock.save_market_data.assert_called_once()
storage_manager_mock.save_symbol_timestamp.assert_not_called()
assert isinstance(result, MarketData)
assert db.query(MarketData).filter_by(symbol="AG2606").count() == 2
def test_falls_back_to_sqlite_when_storage_raises(self, db, sample_data, storage_manager_mock, caplog):
"""StorageManager 写入异常时降级到 SQLite 写入并记录警告。"""
storage_manager_mock.save_market_data.side_effect = Exception("storage down")
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
with caplog.at_level(logging.WARNING, logger="app.services.cache"):
result = cache.save_market_data(db, "AG2606", sample_data)
assert isinstance(result, MarketData)
assert db.query(MarketData).filter_by(symbol="AG2606").count() == 2
assert "StorageManager 写入失败" in caplog.text
class TestGetCachedData:
"""get_cached_data 读取与降级测试。"""
def test_uses_storage_manager_and_merges_periods(self, db, storage_manager_mock):
"""StorageManager 返回合并后的多周期数据。"""
fetched_at = datetime.now()
storage_manager_mock.get_market_data.return_value = {
"symbol": "AG2606",
"type": "futures",
"current_price": 123.45,
"timestamp": fetched_at.isoformat(),
"fetched_at": fetched_at.isoformat(),
"timeframes": {
"5min": [{"datetime": "2026-07-04T09:55:00", "open": 100, "close": 110}],
"15min": [{"datetime": "2026-07-04T09:45:00", "open": 105, "close": 115}],
},
"is_fresh": True,
}
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
result = cache.get_cached_data(db, "AG2606", "futures", ["5min", "15min"])
storage_manager_mock.get_market_data.assert_called_once_with(
"AG2606", "futures", ["5min", "15min"], end_time=None, max_candles=100
)
assert result is not None
assert result["symbol"] == "AG2606"
assert result["type"] == "futures"
assert result["current_price"] == 123.45
assert result["timestamp"] == fetched_at.isoformat()
assert result["fetched_at"] == fetched_at.isoformat()
assert "5min" in result["timeframes"]
assert "15min" in result["timeframes"]
assert len(result["timeframes"]["5min"]) == 1
assert result["is_fresh"] is True
def test_falls_back_to_sqlite_when_storage_returns_none(self, db, storage_manager_mock):
"""StorageManager 未命中时降级到 SQLite 读取。"""
storage_manager_mock.get_market_data.return_value = None
_seed_sqlite_market_data(db)
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
result = cache.get_cached_data(db, "AG2606", "futures", ["5min", "15min"])
assert result is not None
assert result["symbol"] == "AG2606"
assert "5min" in result["timeframes"]
assert "15min" in result["timeframes"]
def test_falls_back_to_sqlite_when_storage_raises(self, db, storage_manager_mock, caplog):
"""StorageManager 读取异常时降级到 SQLite 读取并记录警告。"""
storage_manager_mock.get_market_data.side_effect = Exception("storage down")
_seed_sqlite_market_data(db)
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
with caplog.at_level(logging.WARNING, logger="app.services.cache"):
result = cache.get_cached_data(db, "AG2606", "futures", ["5min"])
assert result is not None
assert "StorageManager 读取失败" in caplog.text
def test_passes_end_time_and_max_candles_to_storage(self, db, storage_manager_mock):
"""get_cached_data 将 end_time / max_candles 透传给 StorageManager。"""
end_time = datetime(2026, 7, 4, 10, 0, 0)
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
cache.get_cached_data(db, "AG2606", "futures", ["5min"], end_time=end_time, max_candles=50)
storage_manager_mock.get_market_data.assert_called_once_with(
"AG2606", "futures", ["5min"], end_time=end_time, max_candles=50
)
def test_does_not_refilter_storage_manager_results(self, db, storage_manager_mock):
"""cache.py 不再对 StorageManager 返回的数据做二次过滤,直接原样返回。"""
fetched_at = datetime.now()
def mock_get_market_data(symbol, data_type, periods, end_time=None, max_candles=100):
return {
"symbol": symbol,
"type": data_type,
"current_price": 123.45,
"timestamp": fetched_at.isoformat(),
"fetched_at": fetched_at.isoformat(),
"timeframes": {
"5min": [
{"datetime": "2026-07-04T09:50:00", "open": 100, "close": 101},
{"datetime": "2026-07-04T09:55:00", "open": 101, "close": 102},
{"datetime": "2026-07-04T10:05:00", "open": 102, "close": 103},
]
},
"is_fresh": True,
}
storage_manager_mock.get_market_data.side_effect = mock_get_market_data
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
result = cache.get_cached_data(
db, "AG2606", "futures", ["5min"],
end_time=datetime(2026, 7, 4, 10, 0, 0),
max_candles=1,
)
assert result is not None
candles = result["timeframes"]["5min"]
# StorageManager 未做过滤cache.py 也不应再次过滤
assert len(candles) == 3
def test_does_not_mutate_storage_manager_result(self, db, storage_manager_mock):
"""get_cached_data 返回 StorageManager 结果时不会原地修改原对象。"""
fetched_at = datetime.now()
original = {
"symbol": "AG2606",
"type": "futures",
"current_price": 123.45,
"timestamp": fetched_at.isoformat(),
"fetched_at": fetched_at.isoformat(),
"timeframes": {
"5min": [
{"datetime": "2026-07-04T09:55:00", "open": 100, "close": 110}
]
},
"is_fresh": True,
}
storage_manager_mock.get_market_data.return_value = original
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
result = cache.get_cached_data(db, "AG2606", "futures", ["5min"])
assert result is not None
assert result is not original
assert result["timeframes"]["5min"] == original["timeframes"]["5min"]
# 修改返回结果不应影响 StorageManager 返回的原对象
result["timeframes"]["5min"].append(
{"datetime": "2026-07-04T10:00:00", "open": 111, "close": 112}
)
assert len(original["timeframes"]["5min"]) == 1
def test_queries_all_periods_when_periods_is_none(self, db, storage_manager_mock):
"""periods=None 时优先走 StorageManager 查询全部周期。"""
fetched_at = datetime.now()
def mock_get_market_data(symbol, data_type, periods=None, end_time=None, max_candles=100):
return {
"symbol": symbol,
"type": data_type,
"current_price": 123.45,
"timestamp": fetched_at.isoformat(),
"fetched_at": fetched_at.isoformat(),
"timeframes": {"5min": [], "15min": []},
"is_fresh": True,
}
storage_manager_mock.get_market_data.side_effect = mock_get_market_data
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
result = cache.get_cached_data(db, "AG2606", "futures", periods=None)
storage_manager_mock.get_market_data.assert_called_once_with(
"AG2606", "futures", None, end_time=None, max_candles=100
)
assert result is not None
assert "5min" in result["timeframes"]
assert "15min" in result["timeframes"]
class TestCacheStatus:
"""is_cache_valid / check_cache_status / get_latest_cached 存储层兼容测试。"""
def _make_merged_result(self, symbol="AG2606", data_type="futures", periods=None, age_seconds=0):
fetched_at = datetime.now() - __import__("datetime").timedelta(seconds=age_seconds)
timeframes = {}
for p in (periods or ["5min", "15min"]):
timeframes[p] = [{"datetime": fetched_at.isoformat(), "open": 100, "close": 110}]
return {
"symbol": symbol,
"type": data_type,
"current_price": 123.45,
"timestamp": fetched_at.isoformat(),
"fetched_at": fetched_at.isoformat(),
"timeframes": timeframes,
"is_fresh": True,
}
def test_is_cache_valid_uses_storage_when_available(self, db, storage_manager_mock):
"""StorageManager 可用时优先判断缓存有效性。"""
storage_manager_mock.get_market_data.return_value = self._make_merged_result(age_seconds=10)
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
assert cache.is_cache_valid(db, "AG2606", "futures", "5min") is True
storage_manager_mock.get_market_data.assert_called_once_with(
"AG2606", "futures", ["5min"]
)
def test_is_cache_valid_falls_back_to_sqlite(self, db, storage_manager_mock):
"""StorageManager 无数据时降级到 SQLite 判断缓存有效性。"""
storage_manager_mock.get_market_data.return_value = None
_seed_sqlite_market_data(db)
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
assert cache.is_cache_valid(db, "AG2606", "futures", "5min") is True
def test_check_cache_status_uses_storage_when_available(self, db, storage_manager_mock):
"""StorageManager 可用时优先检查多周期缓存状态。"""
storage_manager_mock.get_market_data.return_value = self._make_merged_result()
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
status = cache.check_cache_status(db, "AG2606", "futures", ["5min", "15min"])
assert status["all_valid"] is True
assert set(status["valid_periods"]) == {"5min", "15min"}
assert status["missing_periods"] == []
def test_check_cache_status_falls_back_to_sqlite(self, db, storage_manager_mock):
"""StorageManager 无数据时降级到 SQLite 检查多周期缓存状态。"""
storage_manager_mock.get_market_data.return_value = None
_seed_sqlite_market_data(db)
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
status = cache.check_cache_status(db, "AG2606", "futures", ["5min", "15min"])
assert status["all_valid"] is True
assert set(status["valid_periods"]) == {"5min", "15min"}
def test_get_latest_cached_uses_storage_when_available(self, db, storage_manager_mock):
"""StorageManager 可用时优先获取最新缓存记录。"""
storage_manager_mock.get_market_data.return_value = self._make_merged_result()
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
records = cache.get_latest_cached(db, "AG2606", "futures")
assert len(records) == 2
periods = {r.period for r in records}
assert periods == {"5min", "15min"}
assert all(isinstance(r, MarketData) for r in records)
def test_get_latest_cached_falls_back_to_sqlite(self, db, storage_manager_mock):
"""StorageManager 无数据时降级到 SQLite 获取最新缓存记录。"""
storage_manager_mock.get_market_data.return_value = None
_seed_sqlite_market_data(db)
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
records = cache.get_latest_cached(db, "AG2606", "futures")
assert len(records) == 2
periods = {r.period for r in records}
assert periods == {"5min", "15min"}
class TestSymbolTimestamp:
"""合约时间戳函数封装测试。"""
def test_update_symbol_timestamp_uses_storage_when_available(self, db, storage_manager_mock):
"""StorageManager 可用时优先写入合约时间戳。"""
refresh_time = datetime(2026, 7, 4, 10, 0, 0)
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
cache.update_symbol_timestamp(db, "AG2606", "futures", refresh_time)
storage_manager_mock.save_symbol_timestamp.assert_called_once_with("AG2606", "futures", refresh_time)
sqlite_record = db.query(SymbolTimestamp).filter_by(symbol="AG2606").first()
assert sqlite_record is None
def test_update_symbol_timestamp_falls_back_to_sqlite(self, db, storage_manager_mock):
"""StorageManager 不可用时降级到 SQLite 写入合约时间戳。"""
storage_manager_mock.is_available.return_value = False
refresh_time = datetime(2026, 7, 4, 10, 0, 0)
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
cache.update_symbol_timestamp(db, "AG2606", "futures", refresh_time)
storage_manager_mock.save_symbol_timestamp.assert_not_called()
sqlite_record = db.query(SymbolTimestamp).filter_by(symbol="AG2606").first()
assert sqlite_record is not None
assert sqlite_record.last_refresh_at == refresh_time
assert sqlite_record.refresh_count == 1
def test_get_symbol_timestamp_uses_storage_when_available(self, db, storage_manager_mock):
"""StorageManager 可用时优先读取合约时间戳。"""
storage_manager_mock.get_symbol_timestamp.return_value = {
"last_refresh_at": datetime(2026, 7, 4, 10, 0, 0).isoformat(),
"refresh_count": 42,
}
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
result = cache.get_symbol_timestamp(db, "AG2606", "futures")
storage_manager_mock.get_symbol_timestamp.assert_called_once_with("AG2606", "futures")
assert result == datetime(2026, 7, 4, 10, 0, 0)
def test_get_symbol_timestamp_falls_back_to_sqlite(self, db, storage_manager_mock):
"""StorageManager 未命中时降级到 SQLite 读取合约时间戳。"""
storage_manager_mock.get_symbol_timestamp.return_value = None
_seed_sqlite_symbol_timestamp(db)
with patch("app.services.cache.get_storage_manager", return_value=storage_manager_mock):
from app.services import cache
result = cache.get_symbol_timestamp(db, "AG2606", "futures")
assert result == datetime(2026, 7, 4, 10, 0, 0)