""" 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)