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_storage_manager.py

932 lines
36 KiB

import importlib
import json
import logging
import time
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
from app.storage_manager import StorageManager, get_storage_manager
class TestStorageManagerSkeleton:
"""StorageManager 骨架与降级检测测试。"""
def test_init_holds_client_and_engine_references(self):
"""__init__ 应持有 redis_client / mysql_engine 引用。"""
redis_client = MagicMock()
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
assert manager.redis_client is redis_client
assert manager.mysql_engine is mysql_engine
def test_init_sets_default_flags(self):
"""初始化时应将可用性标志设为 False。"""
manager = StorageManager(redis_client=None, mysql_engine=None)
assert manager.redis_available is False
assert manager.mysql_available is False
def test_check_redis_returns_cached_result_within_interval(self):
"""30 秒间隔内应返回缓存的 Redis 可用性结果,不重新探测。"""
redis_client = MagicMock()
redis_client.ping.side_effect = Exception("redis down")
manager = StorageManager(redis_client=redis_client, mysql_engine=None)
first = manager.check_redis()
redis_client.ping.side_effect = None
second = manager.check_redis()
assert first is False
assert second is False
assert redis_client.ping.call_count == 1
def test_check_redis_recovers_after_interval(self):
"""超过检测间隔后Redis 恢复应返回 True。"""
redis_client = MagicMock()
redis_client.ping.side_effect = Exception("redis down")
manager = StorageManager(
redis_client=redis_client, mysql_engine=None, check_interval=0.1
)
assert manager.check_redis() is False
redis_client.ping.side_effect = None
time.sleep(0.15)
assert manager.check_redis() is True
def test_check_redis_false_when_client_is_none(self):
"""redis_client 为 None 时 check_redis 返回 False。"""
manager = StorageManager(redis_client=None, mysql_engine=None)
assert manager.check_redis() is False
def test_check_mysql_returns_cached_result_within_interval(self):
"""30 秒间隔内应返回缓存的 MySQL 可用性结果,不重新探测。"""
mysql_engine = MagicMock()
mysql_engine.connect.side_effect = Exception("mysql down")
manager = StorageManager(redis_client=None, mysql_engine=mysql_engine)
first = manager.check_mysql()
mysql_engine.connect.side_effect = None
second = manager.check_mysql()
assert first is False
assert second is False
assert mysql_engine.connect.call_count == 1
def test_check_mysql_recovers_after_interval(self):
"""超过检测间隔后MySQL 恢复应返回 True。"""
mysql_engine = MagicMock()
mysql_engine.connect.side_effect = Exception("mysql down")
manager = StorageManager(
redis_client=None, mysql_engine=mysql_engine, check_interval=0.1
)
assert manager.check_mysql() is False
mysql_engine.connect.side_effect = None
time.sleep(0.15)
assert manager.check_mysql() is True
def test_check_mysql_false_when_engine_is_none(self):
"""mysql_engine 为 None 时 check_mysql 返回 False。"""
manager = StorageManager(redis_client=None, mysql_engine=None)
assert manager.check_mysql() is False
def test_is_available_true_when_redis_available(self):
"""Redis 可用时 is_available 返回 True。"""
redis_client = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=None)
assert manager.is_available() is True
def test_is_available_true_when_mysql_available(self):
"""MySQL 可用时 is_available 返回 True。"""
mysql_engine = MagicMock()
manager = StorageManager(redis_client=None, mysql_engine=mysql_engine)
assert manager.is_available() is True
def test_is_available_false_when_both_unavailable(self):
"""Redis 与 MySQL 均不可用时 is_available 返回 False。"""
redis_client = MagicMock()
redis_client.ping.side_effect = Exception("redis down")
mysql_engine = MagicMock()
mysql_engine.connect.side_effect = Exception("mysql down")
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
assert manager.is_available() is False
def test_initialize_checks_backends_and_logs(self, caplog):
"""initialize 应检测后端可用性并输出日志。"""
redis_client = MagicMock()
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
with caplog.at_level(logging.INFO):
manager.initialize()
assert "Redis 可用" in caplog.text
assert "MySQL 可用" in caplog.text
def test_initialize_logs_fallback_when_unavailable(self, caplog):
"""后端不可用时 initialize 应输出降级日志。"""
manager = StorageManager(redis_client=None, mysql_engine=None)
with caplog.at_level(logging.WARNING):
manager.initialize()
assert "Redis 不可用" in caplog.text
assert "MySQL 不可用" in caplog.text
class TestStorageManagerSingleton:
"""全局单例工厂函数测试。"""
def test_get_storage_manager_returns_same_instance(self):
"""多次调用 get_storage_manager 应返回同一实例。"""
first = get_storage_manager()
second = get_storage_manager()
assert first is second
def test_get_storage_manager_returns_storage_manager(self):
"""get_storage_manager 应返回 StorageManager 实例。"""
manager = get_storage_manager()
assert isinstance(manager, StorageManager)
class TestStorageManagerReadLogic:
"""StorageManager Redis → MySQL 读取逻辑测试。"""
def test_get_market_data_returns_redis_value_on_cache_hit(self):
"""Redis 命中时直接返回反序列化数据,不访问 MySQL。"""
redis_client = MagicMock()
mysql_engine = MagicMock()
expected = {
"current_price": 123.45,
"timestamp": "2026-07-04T10:00:00",
"candles": [{"open": 100, "close": 110}],
}
redis_client.get.return_value = json.dumps(expected)
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
result = manager.get_market_data("AG2606", "futures", "5min")
assert result == expected
redis_client.get.assert_called_once_with("market_data:AG2606:5min")
mysql_engine.connect.assert_not_called()
def test_get_market_data_falls_back_to_mysql_and_backfills_redis(self):
"""Redis 未命中时从 MySQL 读取并回填 RedisTTL 30 天)。"""
redis_client = MagicMock()
redis_client.get.return_value = None
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
fetched_at = datetime(2026, 7, 4, 10, 0, 0)
record = MagicMock()
record.current_price = 123.45
record.fetched_at = fetched_at
record.candles_json = json.dumps([{"open": 100, "close": 110}])
mock_query = MagicMock()
mock_query.filter_by.return_value.first.return_value = record
mock_session = MagicMock()
mock_session.return_value = mock_session
mock_session.query.return_value = mock_query
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_sessionmaker = MagicMock(return_value=mock_session)
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
result = manager.get_market_data("AG2606", "futures", "5min")
expected = {
"current_price": 123.45,
"timestamp": "2026-07-04T10:00:00",
"candles": [{"open": 100, "close": 110}],
}
assert result == expected
mock_sessionmaker.assert_called_once_with(bind=mysql_engine)
mock_query.filter_by.assert_called_once_with(
symbol="AG2606", data_type="futures", period="5min"
)
redis_client.setex.assert_called_once_with(
"market_data:AG2606:5min", 2592000, json.dumps(expected, ensure_ascii=False)
)
def test_get_market_data_returns_none_when_mysql_unavailable(self):
"""Redis 和 MySQL 均不可用时返回 None由调用方降级到 SQLite。"""
redis_client = MagicMock()
redis_client.ping.side_effect = Exception("redis down")
mysql_engine = MagicMock()
mysql_engine.connect.side_effect = Exception("mysql down")
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
result = manager.get_market_data("AG2606", "futures", "5min")
assert result is None
def test_get_market_data_falls_back_to_mysql_when_redis_read_fails(self):
"""Redis 读取失败但 MySQL 可用时,应降级到 MySQL 并回填 Redis。"""
redis_client = MagicMock()
redis_client.get.side_effect = Exception("redis read failed")
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
fetched_at = datetime(2026, 7, 4, 10, 0, 0)
record = MagicMock()
record.current_price = 123.45
record.fetched_at = fetched_at
record.candles_json = json.dumps([{"open": 100, "close": 110}])
mock_query = MagicMock()
mock_query.filter_by.return_value.first.return_value = record
mock_session = MagicMock()
mock_session.return_value = mock_session
mock_session.query.return_value = mock_query
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_sessionmaker = MagicMock(return_value=mock_session)
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
result = manager.get_market_data("AG2606", "futures", "5min")
expected = {
"current_price": 123.45,
"timestamp": "2026-07-04T10:00:00",
"candles": [{"open": 100, "close": 110}],
}
assert result == expected
redis_client.setex.assert_called_once_with(
"market_data:AG2606:5min", 2592000, json.dumps(expected, ensure_ascii=False)
)
def test_get_market_data_returns_none_when_mysql_read_fails(self):
"""Redis 不可用且 MySQL 读取失败时返回 None。"""
redis_client = MagicMock()
redis_client.ping.side_effect = Exception("redis down")
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
mock_session = MagicMock()
mock_session.return_value = mock_session
mock_session.query.side_effect = Exception("mysql read failed")
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_sessionmaker = MagicMock(return_value=mock_session)
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
result = manager.get_market_data("AG2606", "futures", "5min")
assert result is None
def test_get_symbol_timestamp_returns_redis_value_on_cache_hit(self):
"""Redis 命中时直接返回合约时间戳数据,不访问 MySQL。"""
redis_client = MagicMock()
mysql_engine = MagicMock()
expected = {
"last_refresh_at": "2026-07-04T10:00:00",
"refresh_count": 42,
}
redis_client.get.return_value = json.dumps(expected)
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
result = manager.get_symbol_timestamp("AG2606", "futures")
assert result == expected
redis_client.get.assert_called_once_with("symbol_timestamps:AG2606")
mysql_engine.connect.assert_not_called()
def test_get_symbol_timestamp_falls_back_to_mysql_and_backfills_redis(self):
"""Redis 未命中时从 MySQL 读取时间戳并回填 RedisTTL 30 天)。"""
redis_client = MagicMock()
redis_client.get.return_value = None
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
last_refresh = datetime(2026, 7, 4, 10, 0, 0)
record = MagicMock()
record.last_refresh_at = last_refresh
record.refresh_count = 42
mock_query = MagicMock()
mock_query.filter_by.return_value.first.return_value = record
mock_session = MagicMock()
mock_session.return_value = mock_session
mock_session.query.return_value = mock_query
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_sessionmaker = MagicMock(return_value=mock_session)
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
result = manager.get_symbol_timestamp("AG2606", "futures")
expected = {
"last_refresh_at": "2026-07-04T10:00:00",
"refresh_count": 42,
}
assert result == expected
mock_sessionmaker.assert_called_once_with(bind=mysql_engine)
mock_query.filter_by.assert_called_once_with(symbol="AG2606", data_type="futures")
redis_client.setex.assert_called_once_with(
"symbol_timestamps:AG2606", 2592000, json.dumps(expected, ensure_ascii=False)
)
def test_is_fresh_true_within_cache_ttl(self):
"""数据在 CACHE_TTL_SECONDS默认 300 秒)内时 is_fresh 为 True。"""
manager = StorageManager(redis_client=None, mysql_engine=None)
fetched_at = datetime(2026, 7, 4, 10, 0, 0)
now = fetched_at + timedelta(seconds=60)
mock_datetime = MagicMock()
mock_datetime.now.return_value = now
mock_datetime.fromisoformat = datetime.fromisoformat
with patch("app.storage_manager.datetime", mock_datetime):
result = manager._merge_period_results(
"AG2606",
"futures",
{
"5min": {
"current_price": 123.45,
"timestamp": fetched_at.isoformat(),
"candles": [],
}
},
)
assert result["is_fresh"] is True
def test_is_fresh_false_beyond_cache_ttl(self):
"""数据超过 CACHE_TTL_SECONDS默认 300 秒)时 is_fresh 为 False。"""
manager = StorageManager(redis_client=None, mysql_engine=None)
fetched_at = datetime(2026, 7, 4, 10, 0, 0)
now = fetched_at + timedelta(seconds=301)
mock_datetime = MagicMock()
mock_datetime.now.return_value = now
mock_datetime.fromisoformat = datetime.fromisoformat
with patch("app.storage_manager.datetime", mock_datetime):
result = manager._merge_period_results(
"AG2606",
"futures",
{
"5min": {
"current_price": 123.45,
"timestamp": fetched_at.isoformat(),
"candles": [],
}
},
)
assert result["is_fresh"] is False
class TestStorageManagerWriteLogic:
"""StorageManager 双写一致性测试。"""
def test_save_market_data_deletes_cache_writes_mysql_and_updates_redis(self):
"""MySQL 写入成功时,应先删缓存、再写 MySQL、最后回填 Redis。"""
redis_client = MagicMock()
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
fetched_at = datetime(2026, 7, 4, 10, 0, 0)
data = {
"type": "futures",
"current_price": 123.45,
"timestamp": fetched_at.isoformat(),
"timeframes": {
"5min": [{"open": 100, "close": 110}],
"15min": [{"open": 105, "close": 115}],
},
}
records = []
def mock_filter_by(symbol=None, data_type=None, period=None):
existing = next(
(
r
for r in records
if r.symbol == symbol and r.data_type == data_type and r.period == period
),
None,
)
fb_query = MagicMock()
fb_query.first.return_value = existing
return fb_query
mock_query = MagicMock()
mock_query.filter_by.side_effect = mock_filter_by
mock_session = MagicMock()
mock_session.return_value = mock_session
mock_session.query.return_value = mock_query
mock_session.add.side_effect = records.append
mock_session.commit = MagicMock()
mock_session.rollback = MagicMock()
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_sessionmaker = MagicMock(return_value=mock_session)
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
with patch("app.storage_manager.datetime") as mock_datetime:
mock_datetime.now.return_value = fetched_at
result = manager.save_market_data("AG2606", data)
assert result is True
assert len(records) == 2
assert records[0].symbol == "AG2606"
assert records[0].data_type == "futures"
assert records[0].period == "5min"
assert records[0].current_price == 123.45
assert records[0].candle_count == 1
assert records[0].fetched_at == fetched_at
redis_client.delete.assert_called_once_with(
"market_data:AG2606:5min", "market_data:AG2606:15min"
)
redis_client.setex.assert_any_call(
"market_data:AG2606:5min",
2592000,
json.dumps(
{
"current_price": 123.45,
"timestamp": fetched_at.isoformat(),
"candles": [{"open": 100, "close": 110}],
},
ensure_ascii=False,
),
)
redis_client.setex.assert_any_call(
"market_data:AG2606:15min",
2592000,
json.dumps(
{
"current_price": 123.45,
"timestamp": fetched_at.isoformat(),
"candles": [{"open": 105, "close": 115}],
},
ensure_ascii=False,
),
)
def test_save_market_data_returns_false_and_skips_redis_on_mysql_failure(self):
"""MySQL 写入失败时应回滚事务,不更新 Redis并返回 False。"""
redis_client = MagicMock()
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
data = {
"type": "futures",
"current_price": 123.45,
"timestamp": datetime(2026, 7, 4, 10, 0, 0).isoformat(),
"timeframes": {"5min": [{"open": 100, "close": 110}]},
}
mock_session = MagicMock()
mock_session.return_value = mock_session
mock_session.commit.side_effect = Exception("mysql write failed")
mock_session.rollback = MagicMock()
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_sessionmaker = MagicMock(return_value=mock_session)
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
result = manager.save_market_data("AG2606", data)
assert result is False
redis_client.delete.assert_called_once_with("market_data:AG2606:5min")
redis_client.setex.assert_not_called()
mock_session.rollback.assert_called_once()
def test_save_market_data_returns_true_when_redis_update_fails(self, caplog):
"""MySQL 成功但 Redis 回填失败时,应返回 True 并记录警告日志。"""
redis_client = MagicMock()
redis_client.setex.side_effect = Exception("redis write failed")
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
fetched_at = datetime(2026, 7, 4, 10, 0, 0)
data = {
"type": "futures",
"current_price": 123.45,
"timestamp": fetched_at.isoformat(),
"timeframes": {"5min": [{"open": 100, "close": 110}]},
}
mock_session = MagicMock()
mock_session.return_value = mock_session
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_sessionmaker = MagicMock(return_value=mock_session)
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
with patch("app.storage_manager.datetime") as mock_datetime:
mock_datetime.now.return_value = fetched_at
with caplog.at_level(logging.WARNING):
result = manager.save_market_data("AG2606", data)
assert result is True
mock_session.commit.assert_called_once()
assert "Redis 更新失败" in caplog.text
def test_save_market_data_uses_input_timestamp(self):
"""save_market_data 应优先使用输入数据中的 timestamp而非 datetime.now()。"""
redis_client = MagicMock()
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
input_timestamp = datetime(2026, 1, 15, 8, 30, 0)
data = {
"type": "futures",
"current_price": 123.45,
"timestamp": input_timestamp.isoformat(),
"timeframes": {"5min": [{"open": 100, "close": 110}]},
}
records = []
def mock_filter_by(symbol=None, data_type=None, period=None):
existing = next(
(
r
for r in records
if r.symbol == symbol and r.data_type == data_type and r.period == period
),
None,
)
fb_query = MagicMock()
fb_query.first.return_value = existing
return fb_query
mock_query = MagicMock()
mock_query.filter_by.side_effect = mock_filter_by
mock_session = MagicMock()
mock_session.return_value = mock_session
mock_session.query.return_value = mock_query
mock_session.add.side_effect = records.append
mock_session.commit = MagicMock()
mock_session.rollback = MagicMock()
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_sessionmaker = MagicMock(return_value=mock_session)
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
result = manager.save_market_data("AG2606", data)
assert result is True
assert len(records) == 1
assert records[0].fetched_at == input_timestamp
redis_client.setex.assert_called_once_with(
"market_data:AG2606:5min",
2592000,
json.dumps(
{
"current_price": 123.45,
"timestamp": input_timestamp.isoformat(),
"candles": [{"open": 100, "close": 110}],
},
ensure_ascii=False,
),
)
def test_save_market_data_falls_back_to_now_on_invalid_timestamp(self):
"""timestamp 无法解析时应回退到当前时间。"""
redis_client = MagicMock()
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
fallback_time = datetime(2026, 3, 1, 12, 0, 0)
data = {
"type": "futures",
"current_price": 123.45,
"timestamp": "not-a-valid-timestamp",
"timeframes": {"5min": [{"open": 100, "close": 110}]},
}
records = []
def mock_filter_by(symbol=None, data_type=None, period=None):
existing = next(
(
r
for r in records
if r.symbol == symbol and r.data_type == data_type and r.period == period
),
None,
)
fb_query = MagicMock()
fb_query.first.return_value = existing
return fb_query
mock_query = MagicMock()
mock_query.filter_by.side_effect = mock_filter_by
mock_session = MagicMock()
mock_session.return_value = mock_session
mock_session.query.return_value = mock_query
mock_session.add.side_effect = records.append
mock_session.commit = MagicMock()
mock_session.rollback = MagicMock()
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_sessionmaker = MagicMock(return_value=mock_session)
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
with patch("app.storage_manager._datetime.datetime") as mock_datetime:
mock_datetime.now.return_value = fallback_time
mock_datetime.fromisoformat.side_effect = ValueError(
"Invalid isoformat string"
)
result = manager.save_market_data("AG2606", data)
assert result is True
assert len(records) == 1
assert records[0].fetched_at == fallback_time
def test_save_market_data_writes_mysql_when_redis_unavailable(self):
"""Redis 不可用但 MySQL 可用时save_market_data 应直接写入 MySQL 并返回 True。"""
redis_client = MagicMock()
redis_client.ping.side_effect = Exception("redis down")
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
fetched_at = datetime(2026, 7, 4, 10, 0, 0)
data = {
"type": "futures",
"current_price": 123.45,
"timestamp": fetched_at.isoformat(),
"timeframes": {"5min": [{"open": 100, "close": 110}]},
}
records = []
def mock_filter_by(symbol=None, data_type=None, period=None):
existing = next(
(
r
for r in records
if r.symbol == symbol and r.data_type == data_type and r.period == period
),
None,
)
fb_query = MagicMock()
fb_query.first.return_value = existing
return fb_query
mock_query = MagicMock()
mock_query.filter_by.side_effect = mock_filter_by
mock_session = MagicMock()
mock_session.return_value = mock_session
mock_session.query.return_value = mock_query
mock_session.add.side_effect = records.append
mock_session.commit = MagicMock()
mock_session.rollback = MagicMock()
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_sessionmaker = MagicMock(return_value=mock_session)
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
result = manager.save_market_data("AG2606", data)
assert result is True
assert len(records) == 1
assert records[0].symbol == "AG2606"
redis_client.delete.assert_not_called()
redis_client.setex.assert_not_called()
def test_save_market_data_returns_true_when_timeframes_empty(self):
"""timeframes 为空时save_market_data 应直接返回 True 且不操作缓存。"""
redis_client = MagicMock()
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
data = {
"type": "futures",
"current_price": 123.45,
"timestamp": datetime(2026, 7, 4, 10, 0, 0).isoformat(),
"timeframes": {},
}
mock_session = MagicMock()
mock_session.return_value = mock_session
mock_session.commit = MagicMock()
mock_session.rollback = MagicMock()
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_sessionmaker = MagicMock(return_value=mock_session)
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
result = manager.save_market_data("AG2606", data)
assert result is True
mock_session.commit.assert_called_once()
redis_client.delete.assert_not_called()
redis_client.setex.assert_not_called()
def test_save_symbol_timestamp_deletes_cache_writes_mysql_and_updates_redis(self):
"""合约时间戳双写成功时,应先删缓存、再写 MySQL、最后回填 Redis。"""
redis_client = MagicMock()
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
refresh_time = datetime(2026, 7, 4, 10, 0, 0)
records = []
def mock_filter_by(symbol=None, data_type=None):
existing = next(
(r for r in records if r.symbol == symbol and r.data_type == data_type),
None,
)
fb_query = MagicMock()
fb_query.first.return_value = existing
return fb_query
mock_query = MagicMock()
mock_query.filter_by.side_effect = mock_filter_by
mock_session = MagicMock()
mock_session.return_value = mock_session
mock_session.query.return_value = mock_query
mock_session.add.side_effect = records.append
mock_session.commit = MagicMock()
mock_session.rollback = MagicMock()
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_sessionmaker = MagicMock(return_value=mock_session)
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
result = manager.save_symbol_timestamp("AG2606", "futures", refresh_time)
assert result is True
assert len(records) == 1
assert records[0].symbol == "AG2606"
assert records[0].data_type == "futures"
assert records[0].last_refresh_at == refresh_time
assert records[0].refresh_count == 1
redis_client.delete.assert_called_once_with("symbol_timestamps:AG2606")
redis_client.setex.assert_called_once_with(
"symbol_timestamps:AG2606",
2592000,
json.dumps(
{"last_refresh_at": refresh_time.isoformat(), "refresh_count": 1},
ensure_ascii=False,
),
)
def test_save_symbol_timestamp_returns_false_on_mysql_failure(self):
"""合约时间戳 MySQL 写入失败时应返回 False 且不更新 Redis。"""
redis_client = MagicMock()
mysql_engine = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
mock_session = MagicMock()
mock_session.return_value = mock_session
mock_session.commit.side_effect = Exception("mysql write failed")
mock_session.rollback = MagicMock()
mock_session.__enter__ = MagicMock(return_value=mock_session)
mock_session.__exit__ = MagicMock(return_value=False)
mock_sessionmaker = MagicMock(return_value=mock_session)
with patch("app.storage_manager.sessionmaker", mock_sessionmaker):
result = manager.save_symbol_timestamp(
"AG2606", "futures", datetime(2026, 7, 4, 10, 0, 0)
)
assert result is False
redis_client.delete.assert_called_once_with("symbol_timestamps:AG2606")
redis_client.setex.assert_not_called()
mock_session.rollback.assert_called_once()
def test_delete_cache_removes_market_data_keys(self):
"""delete_cache 应批量删除指定周期的 Redis 缓存键。"""
redis_client = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=None)
manager.delete_cache("AG2606", ["5min", "15min"])
redis_client.delete.assert_called_once_with(
"market_data:AG2606:5min", "market_data:AG2606:15min"
)
class TestStorageManagerConfig:
"""配置读取相关测试。"""
def test_redis_ttl_reads_config_value(self):
"""StorageManager 应分别读取 app.config.REDIS_TTL_SECONDS 与 CACHE_TTL_SECONDS。"""
import app.config as config
import app.storage_manager as sm
original_redis_ttl = config.REDIS_TTL_SECONDS
original_cache_ttl = config.CACHE_TTL_SECONDS
original_singleton = sm._storage_manager
config.REDIS_TTL_SECONDS = 1234
config.CACHE_TTL_SECONDS = 5678
try:
importlib.reload(sm)
assert sm.REDIS_TTL_SECONDS == 1234
assert sm.CACHE_TTL_SECONDS == 5678
finally:
config.REDIS_TTL_SECONDS = original_redis_ttl
config.CACHE_TTL_SECONDS = original_cache_ttl
importlib.reload(sm)
sm._storage_manager = original_singleton
class TestStorageManagerAtomicWrite:
"""save_market_data_with_timestamp 原子写入测试。"""
def test_save_market_data_with_timestamp_writes_market_data_and_timestamp(self):
"""同一 MySQL 事务中写入行情数据与合约时间戳。"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models import Base, MarketData, SymbolTimestamp
redis_client = MagicMock()
mysql_engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=mysql_engine)
manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine)
fetched_at = datetime(2026, 7, 4, 10, 0, 0)
data = {
"type": "futures",
"current_price": 123.45,
"timestamp": fetched_at.isoformat(),
"timeframes": {
"5min": [{"open": 100, "close": 110}],
"15min": [{"open": 105, "close": 115}],
},
}
refresh_time = datetime(2026, 7, 4, 10, 1, 0)
result = manager.save_market_data_with_timestamp("AG2606", data, refresh_time)
assert result is True
SessionLocal = sessionmaker(bind=mysql_engine)
with SessionLocal() as db:
market_records = db.query(MarketData).filter_by(symbol="AG2606").all()
assert len(market_records) == 2
periods = {r.period for r in market_records}
assert periods == {"5min", "15min"}
ts = db.query(SymbolTimestamp).filter_by(symbol="AG2606", data_type="futures").first()
assert ts is not None
assert ts.last_refresh_at == refresh_time
assert ts.refresh_count == 1
redis_client.delete.assert_called_once_with(
"market_data:AG2606:5min",
"market_data:AG2606:15min",
"symbol_timestamps:AG2606",
)
def test_save_market_data_with_timestamp_returns_false_on_mysql_failure(self):
"""MySQL 写入失败时返回 False不同步时间戳。"""
redis_client = MagicMock()
manager = StorageManager(redis_client=redis_client, mysql_engine=None)
data = {
"type": "futures",
"current_price": 123.45,
"timestamp": datetime(2026, 7, 4, 10, 0, 0).isoformat(),
"timeframes": {"5min": [{"open": 100, "close": 110}]},
}
result = manager.save_market_data_with_timestamp(
"AG2606", data, datetime(2026, 7, 4, 10, 1, 0)
)
assert result is False
redis_client.setex.assert_not_called()