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

281 lines
9.6 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
Redis 缓存命中/未命中场景验证测试
覆盖:
1. 调用 save_market_data 写入数据后Redis 与 MySQL 同时存在一致数据
2. 首次调用 get_market_dataRedis 未命中)从 MySQL 读取并回填 Redis
3. 再次调用 get_market_dataRedis 命中)直接返回缓存数据,不访问 MySQL
4. 手动删除 Redis key 后,再次读取回源 MySQL 并重新回填 Redis
"""
import json
import logging
from datetime import datetime
from unittest.mock import MagicMock
import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
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
class FakeRedis:
"""轻量级 Redis 替身,仅支持本测试所需的方法。"""
def __init__(self):
self._store = {}
def ping(self):
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)]
def exists(self, key):
return key in self._store
@pytest.fixture
def mysql_engine():
"""提供已创建表结构的内存 SQLite 引擎,作为 MySQL 替身。"""
# 防御:修复上游 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:")
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 fake_redis():
return FakeRedis()
@pytest.fixture
def sample_data():
fetched_at = datetime(2026, 7, 4, 10, 0, 0)
return {
"type": "futures",
"current_price": 123.45,
"timestamp": 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}],
},
}
def _seed_mysql_market_data(engine, symbol="AG2606", data_type="futures"):
"""在 MySQL 替身中写入行情数据。"""
fetched_at = datetime(2026, 7, 4, 10, 0, 0)
Session = sessionmaker(bind=engine)
with Session() as db:
db.add(
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,
)
)
db.commit()
class TestSaveMarketData:
"""save_market_data 写入与缓存回填场景。"""
def test_save_market_data_writes_mysql_and_backfills_redis(
self, fake_redis, mysql_engine, sample_data
):
"""写入后 MySQL 与 Redis 均保存一致数据。"""
manager = StorageManager(
redis_client=fake_redis, mysql_engine=mysql_engine
)
assert manager.save_market_data("AG2606", sample_data) is True
# MySQL 中应写入两条记录
Session = sessionmaker(bind=mysql_engine)
with Session() as db:
records = db.query(MarketData).filter_by(symbol="AG2606").all()
assert len(records) == 2
periods = {r.period for r in records}
assert periods == {"5min", "15min"}
for r in records:
assert r.current_price == 123.45
# Redis 中应回填对应缓存键
assert fake_redis.exists("market_data:AG2606:5min")
assert fake_redis.exists("market_data:AG2606:15min")
cached_5min = json.loads(fake_redis.get("market_data:AG2606:5min"))
assert cached_5min["current_price"] == 123.45
assert cached_5min["candles"] == sample_data["timeframes"]["5min"]
class TestRedisCacheHitMiss:
"""Redis 缓存命中/未命中读取场景。"""
def test_first_read_miss_falls_back_to_mysql_and_backfills_redis(
self, fake_redis, mysql_engine
):
"""Redis 未命中时从 MySQL 读取并回填 Redis。"""
_seed_mysql_market_data(mysql_engine)
manager = StorageManager(
redis_client=fake_redis, mysql_engine=mysql_engine
)
assert fake_redis.get("market_data:AG2606:5min") is None
result = manager.get_market_data("AG2606", "futures", "5min")
expected = {
"current_price": 123.45,
"timestamp": datetime(2026, 7, 4, 10, 0, 0).isoformat(),
"candles": [{"datetime": "2026-07-04T09:55:00", "open": 100, "close": 110}],
}
assert result == expected
# Redis 应已被回填
assert fake_redis.exists("market_data:AG2606:5min")
cached = json.loads(fake_redis.get("market_data:AG2606:5min"))
assert cached == expected
def test_second_read_hit_returns_from_redis_without_mysql_access(
self, fake_redis, mysql_engine
):
"""Redis 命中时直接返回缓存,不再访问 MySQL。"""
_seed_mysql_market_data(mysql_engine)
manager = StorageManager(
redis_client=fake_redis, mysql_engine=mysql_engine
)
first = manager.get_market_data("AG2606", "futures", "5min")
assert fake_redis.exists("market_data:AG2606:5min")
# 将 MySQL 引擎替换为会抛异常的 mock以证明第二次读取不会访问 MySQL
bad_engine = MagicMock()
bad_engine.connect.side_effect = Exception("MySQL should not be accessed")
manager.mysql_engine = bad_engine
second = manager.get_market_data("AG2606", "futures", "5min")
assert second == first
bad_engine.connect.assert_not_called()
def test_after_redis_key_deleted_falls_back_to_mysql_again(
self, fake_redis, mysql_engine
):
"""手动删除 Redis key 后,读取回源 MySQL 并重新回填 Redis。"""
_seed_mysql_market_data(mysql_engine)
manager = StorageManager(
redis_client=fake_redis, mysql_engine=mysql_engine
)
first = manager.get_market_data("AG2606", "futures", "5min")
assert fake_redis.exists("market_data:AG2606:5min")
# 模拟 TTL 过期或手动失效
fake_redis.delete("market_data:AG2606:5min")
assert fake_redis.get("market_data:AG2606:5min") is None
second = manager.get_market_data("AG2606", "futures", "5min")
assert second == first
assert fake_redis.exists("market_data:AG2606:5min")
def test_cache_hit_data_matches_mysql_data(
self, fake_redis, mysql_engine
):
"""缓存命中时返回的数据应与 MySQL 中的数据一致。"""
_seed_mysql_market_data(mysql_engine)
manager = StorageManager(
redis_client=fake_redis, mysql_engine=mysql_engine
)
# 首次读取触发回填
from_redis = manager.get_market_data("AG2606", "futures", "5min")
# 直接从 MySQL 读取原始记录作为基准
Session = sessionmaker(bind=mysql_engine)
with Session() as db:
record = (
db.query(MarketData)
.filter_by(symbol="AG2606", data_type="futures", period="5min")
.first()
)
assert record is not None
assert from_redis["current_price"] == record.current_price
assert from_redis["candles"] == json.loads(record.candles_json)
class TestCacheLogMessages:
"""验证缓存命中/未命中的日志输出。"""
@pytest.mark.xfail(
reason="当前 StorageManager 未输出 'Redis 缓存命中' 日志,仅记录行为断言",
strict=True,
)
def test_cache_hit_logs_redis_hit(self, fake_redis, mysql_engine, caplog):
"""Redis 命中时日志应包含 'Redis 缓存命中'"""
_seed_mysql_market_data(mysql_engine)
manager = StorageManager(
redis_client=fake_redis, mysql_engine=mysql_engine
)
manager.get_market_data("AG2606", "futures", "5min") # 回填
with caplog.at_level(logging.INFO, logger="app.storage_manager"):
manager.get_market_data("AG2606", "futures", "5min") # 命中
assert "Redis 缓存命中" in caplog.text
@pytest.mark.xfail(
reason="当前 StorageManager 未输出 'Redis 未命中,从 MySQL 读取' 日志,仅记录行为断言",
strict=True,
)
def test_cache_miss_logs_redis_miss_and_mysql_read(
self, fake_redis, mysql_engine, caplog
):
"""Redis 未命中并回源 MySQL 时日志应包含 'Redis 未命中,从 MySQL 读取'"""
_seed_mysql_market_data(mysql_engine)
manager = StorageManager(
redis_client=fake_redis, mysql_engine=mysql_engine
)
with caplog.at_level(logging.INFO, logger="app.storage_manager"):
manager.get_market_data("AG2606", "futures", "5min")
assert "Redis 未命中,从 MySQL 读取" in caplog.text