""" 数据缓冲平台 - 双写一致性与数据迁移完整性验证测试 本测试通过内存 Fake Redis 与 mock/fake MySQL 验证: 1. save_market_data 成功后 MySQL 与 Redis 数据内容一致; 2. Redis 写入失败不影响 MySQL 持久化; 3. migrate_sqlite_to_mysql 的幂等性与迁移记录数正确性; 4. 使用真实内存数据库做端到端迁移验证时暴露的生产代码缺陷。 """ import json 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 from app.storage_manager import StorageManager class FakeRedis: """内存模拟 Redis 客户端,支持 setex / get / delete / keys / ping。""" def __init__(self, fail_on_write=False): self._store = {} self.fail_on_write = fail_on_write def ping(self): return True def get(self, key): return self._store.get(key) def setex(self, key, seconds, value): if self.fail_on_write: raise Exception("Redis 写入失败") self._store[key] = value def delete(self, *keys): if self.fail_on_write: raise Exception("Redis 删除失败") for key in keys: self._store.pop(key, None) def keys(self, pattern): prefix = pattern.replace("*", "") return [k for k in self._store.keys() if k.startswith(prefix)] def _make_mysql_memory(): """创建内存 MySQL 模拟引擎和会话工厂。""" engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(bind=engine) SessionLocal = sessionmaker(bind=engine) return engine, SessionLocal def _create_sqlite_source(tmp_path, market_count=2, timestamp_count=1): """创建包含测试数据的 SQLite 源数据库,并返回引擎与记录数。""" db_path = tmp_path / "test_source.db" engine = create_engine(f"sqlite:///{db_path}") Base.metadata.create_all(bind=engine) Session = sessionmaker(bind=engine) session = Session() fetched_at = datetime(2026, 7, 4, 10, 0, 0) for i in range(market_count): session.add( MarketData( symbol="AG2606", data_type="futures", period=f"{(i + 1) * 5}min", candles_json='[{"open": 100, "close": 110}]', current_price=123.45, fetched_at=fetched_at, candle_count=1, ) ) for _ in range(timestamp_count): session.add( SymbolTimestamp( symbol="AG2606", data_type="futures", last_refresh_at=fetched_at, refresh_count=5, ) ) session.commit() session.close() return engine, market_count, timestamp_count def _make_mysql_session(market_data_count=0, symbol_timestamp_count=0): """构造模拟 MySQL 会话,支持记录写入和计数查询。""" mysql_session = MagicMock() mysql_session.__enter__ = MagicMock(return_value=mysql_session) mysql_session.__exit__ = MagicMock(return_value=False) added_market_data = [] added_symbol_timestamps = [] def mock_query(model): query = MagicMock() if model is MarketData: query.count.return_value = market_data_count elif model is SymbolTimestamp: query.count.return_value = symbol_timestamp_count return query mysql_session.query.side_effect = mock_query mysql_session.add_all.side_effect = lambda records: ( added_market_data.extend(records) if records and isinstance(records[0], MarketData) else added_symbol_timestamps.extend(records) ) return mysql_session, added_market_data, added_symbol_timestamps @pytest.fixture def sample_market_data(): """构造与采集脚本格式一致的测试数据。""" return { "type": "futures", "current_price": 123.45, "timestamp": datetime(2026, 7, 4, 10, 0, 0).isoformat(), "timeframes": { "5min": [{"open": 100, "close": 110}], "15min": [{"open": 105, "close": 115}], }, } class TestDualWriteConsistency: """双写一致性验证。""" def test_save_market_data_writes_consistent_data_to_mysql_and_redis( self, sample_market_data ): """save_market_data 成功后,MySQL 与 Redis 中数据内容一致。""" redis_client = FakeRedis() mysql_engine, MySQLSessionLocal = _make_mysql_memory() manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine) result = manager.save_market_data("AG2606", sample_market_data) assert result is True # 验证 MySQL 持久化数据 with MySQLSessionLocal() as db: records = db.query(MarketData).filter_by(symbol="AG2606").all() assert len(records) == 2 mysql_by_period = {r.period: r for r in records} assert "5min" in mysql_by_period assert "15min" in mysql_by_period assert mysql_by_period["5min"].current_price == 123.45 assert mysql_by_period["15min"].current_price == 123.45 # 验证 Redis 缓存数据 redis_5min = json.loads(redis_client.get("market_data:AG2606:5min")) redis_15min = json.loads(redis_client.get("market_data:AG2606:15min")) assert redis_5min["current_price"] == 123.45 assert redis_15min["current_price"] == 123.45 assert redis_5min["candles"] == [{"open": 100, "close": 110}] assert redis_15min["candles"] == [{"open": 105, "close": 115}] # 验证 MySQL 与 Redis 内容一致 assert ( mysql_by_period["5min"].current_price == redis_5min["current_price"] ) assert ( mysql_by_period["15min"].current_price == redis_15min["current_price"] ) assert ( json.loads(mysql_by_period["5min"].candles_json) == redis_5min["candles"] ) assert ( json.loads(mysql_by_period["15min"].candles_json) == redis_15min["candles"] ) def test_redis_write_failure_does_not_affect_mysql_persistence( self, sample_market_data ): """Redis 写入失败时,MySQL 数据持久化不受影响,save_market_data 仍返回 True。""" redis_client = FakeRedis(fail_on_write=True) mysql_engine, MySQLSessionLocal = _make_mysql_memory() manager = StorageManager(redis_client=redis_client, mysql_engine=mysql_engine) result = manager.save_market_data("AG2606", sample_market_data) assert result is True # 验证 MySQL 仍有完整数据 with MySQLSessionLocal() 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"} # 验证 Redis 无缓存写入 assert redis_client.get("market_data:AG2606:5min") is None assert redis_client.get("market_data:AG2606:15min") is None class TestMigrationIntegrity: """数据迁移完整性验证。""" def test_migration_copies_expected_record_counts_from_sqlite_to_mysql( self, tmp_path ): """MySQL 为空时,migrate_sqlite_to_mysql 迁移 SQLite 中全部记录。""" sqlite_engine, sqlite_market_count, sqlite_timestamp_count = ( _create_sqlite_source(tmp_path) ) mysql_session, added_market_data, added_symbol_timestamps = ( _make_mysql_session(market_data_count=0, symbol_timestamp_count=0) ) with patch("app.migration.create_engine", return_value=sqlite_engine): with patch( "app.migration.MySQLSessionLocal", return_value=mysql_session ): from app.migration import migrate_sqlite_to_mysql result = migrate_sqlite_to_mysql() assert result is True assert len(added_market_data) == sqlite_market_count assert len(added_symbol_timestamps) == sqlite_timestamp_count assert added_market_data[0].symbol == "AG2606" assert added_market_data[0].period == "5min" assert added_symbol_timestamps[0].symbol == "AG2606" mysql_session.commit.assert_called_once() def test_migration_is_idempotent_when_mysql_has_data(self, tmp_path): """MySQL 已有数据时迁移被跳过,保持幂等性,不覆盖已有数据。""" sqlite_engine, _, _ = _create_sqlite_source(tmp_path) mysql_session, added_market_data, added_symbol_timestamps = ( _make_mysql_session(market_data_count=1, symbol_timestamp_count=1) ) with patch("app.migration.create_engine", return_value=sqlite_engine): with patch( "app.migration.MySQLSessionLocal", return_value=mysql_session ): from app.migration import migrate_sqlite_to_mysql result = migrate_sqlite_to_mysql() assert result is False assert len(added_market_data) == 0 assert len(added_symbol_timestamps) == 0 mysql_session.commit.assert_not_called() @pytest.mark.xfail( reason="app/migration.py 对 detached ORM 实例直接 add_all 不会触发 INSERT," "需使用 make_transient() 或重新构造记录。", strict=False, ) def test_migration_end_to_end_with_in_memory_databases(self, tmp_path): """使用真实内存数据库验证迁移前后数据条数一致(当前已知缺陷)。""" sqlite_engine, sqlite_market_count, sqlite_timestamp_count = ( _create_sqlite_source(tmp_path) ) mysql_engine, MySQLSessionLocal = _make_mysql_memory() with patch("app.migration.create_engine", return_value=sqlite_engine): with patch("app.migration.MySQLSessionLocal", MySQLSessionLocal): from app.migration import migrate_sqlite_to_mysql result = migrate_sqlite_to_mysql() assert result is True mysql_session = MySQLSessionLocal() try: assert mysql_session.query(MarketData).count() == sqlite_market_count assert ( mysql_session.query(SymbolTimestamp).count() == sqlite_timestamp_count ) finally: mysql_session.close()