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.
287 lines
12 KiB
287 lines
12 KiB
|
2 weeks ago
|
"""
|
||
|
|
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(2026, 7, 4, 10, 0, 0)
|
||
|
|
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 result is None
|
||
|
|
|
||
|
|
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()
|
||
|
|
|
||
|
|
def mock_get_market_data(symbol, data_type, period):
|
||
|
|
return {
|
||
|
|
"current_price": 123.45,
|
||
|
|
"timestamp": fetched_at.isoformat(),
|
||
|
|
"candles": [{"datetime": f"2026-07-04T09:{50 if period == '5min' else 45}:00", "open": 100, "close": 110}],
|
||
|
|
}
|
||
|
|
|
||
|
|
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", "15min"])
|
||
|
|
|
||
|
|
assert storage_manager_mock.get_market_data.call_count == 2
|
||
|
|
storage_manager_mock.get_market_data.assert_any_call("AG2606", "futures", "5min")
|
||
|
|
storage_manager_mock.get_market_data.assert_any_call("AG2606", "futures", "15min")
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
|
||
|
|
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)
|