""" DualMemoryProvider 單元測試 ============================= 測試案例: - test_cache_aside_hit: Working Memory 命中 - test_cache_aside_miss_backfill: Working miss,Episodic 命中並回填 - test_dual_write: 雙層寫入 - test_graceful_degradation: 優雅降級 (單層失敗不影響整體) - test_delete: 雙層刪除 - test_exists: 存在檢查 - test_sync: 手動同步 """ import pytest from unittest.mock import patch, AsyncMock, MagicMock, PropertyMock from pydantic import BaseModel from lewooogo_data.providers.dual_memory import DualMemoryProvider from lewooogo_data.providers.redis_memory import RedisMemoryProvider from lewooogo_data.providers.pg_memory import PgMemoryProvider class SampleModel(BaseModel): """測試用的 Pydantic Model""" id: str name: str value: int = 0 # ============================================================================= # Fixtures # ============================================================================= @pytest.fixture def mock_working(): """Mock Working Memory (Redis)""" mock = AsyncMock(spec=RedisMemoryProvider) mock._storage = {} async def mock_load(key): return mock._storage.get(key) async def mock_save(key, data, ttl=None): mock._storage[key] = data return True async def mock_delete(key): if key in mock._storage: del mock._storage[key] return True return False async def mock_exists(key): return key in mock._storage mock.load = mock_load mock.save = mock_save mock.delete = mock_delete mock.exists = mock_exists return mock @pytest.fixture def mock_episodic(): """Mock Episodic Memory (PostgreSQL)""" mock = AsyncMock(spec=PgMemoryProvider) mock._storage = {} async def mock_load(key): return mock._storage.get(key) async def mock_save(key, data, ttl=None): mock._storage[key] = data return True async def mock_delete(key): if key in mock._storage: del mock._storage[key] return True return False async def mock_exists(key): return key in mock._storage mock.load = mock_load mock.save = mock_save mock.delete = mock_delete mock.exists = mock_exists return mock @pytest.fixture def dual_provider(mock_working, mock_episodic): """建立已 mock 的 DualMemoryProvider""" provider = DualMemoryProvider( model_class=SampleModel, key_prefix="test", working_ttl=604800, ) # 替換內部 provider provider._working = mock_working provider._episodic = mock_episodic return provider # ============================================================================= # Cache-Aside 模式測試 # ============================================================================= class TestDualMemoryCacheAside: """Cache-Aside 模式測試""" @pytest.mark.asyncio async def test_cache_aside_hit(self, dual_provider, mock_working, mock_episodic): """ 測試 Working Memory 命中 策略: Working 有資料 → 直接返回,不查 Episodic """ # Arrange data = SampleModel(id="hit-1", name="Cache Hit", value=100) mock_working._storage["hit-1"] = data # Act result = await dual_provider.load("hit-1") # Assert assert result is not None assert result.id == "hit-1" assert result.name == "Cache Hit" # Episodic 不應被查詢 (透過檢查 storage 未被訪問) assert "hit-1" not in mock_episodic._storage @pytest.mark.asyncio async def test_cache_aside_miss_backfill(self, dual_provider, mock_working, mock_episodic): """ 測試 Working miss,Episodic 命中並回填 策略: 1. Working miss 2. 查 Episodic,命中 3. 回填到 Working """ # Arrange - 只在 Episodic 有資料 data = SampleModel(id="miss-1", name="From PG", value=200) mock_episodic._storage["miss-1"] = data # Working 是空的 assert "miss-1" not in mock_working._storage # Act result = await dual_provider.load("miss-1") # Assert assert result is not None assert result.id == "miss-1" assert result.name == "From PG" # 驗證回填到 Working assert "miss-1" in mock_working._storage assert mock_working._storage["miss-1"].name == "From PG" @pytest.mark.asyncio async def test_cache_aside_total_miss(self, dual_provider, mock_working, mock_episodic): """測試兩層都沒有資料""" # Act result = await dual_provider.load("nonexistent") # Assert assert result is None # ============================================================================= # 雙層寫入測試 # ============================================================================= class TestDualMemoryWrite: """雙層寫入測試""" @pytest.mark.asyncio async def test_dual_write(self, dual_provider, mock_working, mock_episodic): """ 測試雙層同步寫入 統帥鐵律: Working + Episodic 必須同步寫入 """ # Arrange data = SampleModel(id="write-1", name="Dual Write", value=300) # Act result = await dual_provider.save("write-1", data) # Assert assert result is True # 驗證兩層都有資料 assert "write-1" in mock_working._storage assert "write-1" in mock_episodic._storage assert mock_working._storage["write-1"].name == "Dual Write" assert mock_episodic._storage["write-1"].name == "Dual Write" # ============================================================================= # 優雅降級測試 # ============================================================================= class TestDualMemoryGracefulDegradation: """優雅降級測試""" @pytest.mark.asyncio async def test_working_failure_continues(self, dual_provider, mock_working, mock_episodic): """ 測試 Working 失敗但 Episodic 成功 統帥鐵律: 任一層失敗不影響整體 """ # Arrange - Working save 會失敗 async def failing_save(key, data, ttl=None): raise Exception("Redis connection failed") mock_working.save = failing_save data = SampleModel(id="degrade-1", name="Degraded", value=400) # Act result = await dual_provider.save("degrade-1", data) # Assert - 至少 Episodic 成功,整體算成功 assert result is True assert "degrade-1" in mock_episodic._storage @pytest.mark.asyncio async def test_episodic_failure_continues(self, dual_provider, mock_working, mock_episodic): """測試 Episodic 失敗但 Working 成功""" # Arrange - Episodic save 會失敗 async def failing_save(key, data, ttl=None): raise Exception("PostgreSQL connection failed") mock_episodic.save = failing_save data = SampleModel(id="degrade-2", name="Degraded 2", value=500) # Act result = await dual_provider.save("degrade-2", data) # Assert - 至少 Working 成功,整體算成功 assert result is True assert "degrade-2" in mock_working._storage @pytest.mark.asyncio async def test_both_failure(self, dual_provider, mock_working, mock_episodic): """測試兩層都失敗""" # Arrange - 兩層都會失敗 async def failing_save(key, data, ttl=None): return False mock_working.save = failing_save mock_episodic.save = failing_save data = SampleModel(id="fail-all", name="All Failed", value=0) # Act result = await dual_provider.save("fail-all", data) # Assert assert result is False # ============================================================================= # 刪除測試 # ============================================================================= class TestDualMemoryDelete: """刪除相關測試""" @pytest.mark.asyncio async def test_delete_both_layers(self, dual_provider, mock_working, mock_episodic): """測試雙層刪除""" # Arrange - 兩層都有資料 data = SampleModel(id="del-1", name="To Delete", value=0) mock_working._storage["del-1"] = data mock_episodic._storage["del-1"] = data # Act result = await dual_provider.delete("del-1") # Assert assert result is True assert "del-1" not in mock_working._storage assert "del-1" not in mock_episodic._storage @pytest.mark.asyncio async def test_delete_only_working(self, dual_provider, mock_working, mock_episodic): """測試只有 Working 有資料時刪除""" data = SampleModel(id="del-2", name="Only Working", value=1) mock_working._storage["del-2"] = data # Act result = await dual_provider.delete("del-2") # Assert - 至少一層成功 assert result is True assert "del-2" not in mock_working._storage @pytest.mark.asyncio async def test_delete_nonexistent(self, dual_provider, mock_working, mock_episodic): """測試刪除不存在的資料""" # Act result = await dual_provider.delete("nonexistent") # Assert assert result is False # ============================================================================= # 存在檢查測試 # ============================================================================= class TestDualMemoryExists: """存在檢查測試""" @pytest.mark.asyncio async def test_exists_in_working(self, dual_provider, mock_working, mock_episodic): """測試只在 Working 有資料""" data = SampleModel(id="exist-1", name="In Working", value=1) mock_working._storage["exist-1"] = data # Act result = await dual_provider.exists("exist-1") # Assert assert result is True @pytest.mark.asyncio async def test_exists_in_episodic(self, dual_provider, mock_working, mock_episodic): """測試只在 Episodic 有資料""" data = SampleModel(id="exist-2", name="In Episodic", value=2) mock_episodic._storage["exist-2"] = data # Act result = await dual_provider.exists("exist-2") # Assert assert result is True @pytest.mark.asyncio async def test_exists_nowhere(self, dual_provider, mock_working, mock_episodic): """測試兩層都沒有""" # Act result = await dual_provider.exists("nonexistent") # Assert assert result is False # ============================================================================= # 同步測試 # ============================================================================= class TestDualMemorySync: """同步相關測試""" @pytest.mark.asyncio async def test_sync_to_working(self, dual_provider, mock_working, mock_episodic): """測試從 Episodic 同步到 Working""" # Arrange - 只在 Episodic 有資料 data = SampleModel(id="sync-1", name="To Sync", value=100) mock_episodic._storage["sync-1"] = data # Act result = await dual_provider.sync_to_working("sync-1") # Assert assert result is True assert "sync-1" in mock_working._storage assert mock_working._storage["sync-1"].name == "To Sync" @pytest.mark.asyncio async def test_sync_to_working_nonexistent(self, dual_provider, mock_working, mock_episodic): """測試同步不存在的資料到 Working""" # Act result = await dual_provider.sync_to_working("nonexistent") # Assert assert result is False @pytest.mark.asyncio async def test_sync_to_episodic(self, dual_provider, mock_working, mock_episodic): """測試從 Working 同步到 Episodic""" # Arrange - 只在 Working 有資料 data = SampleModel(id="sync-2", name="To Persist", value=200) mock_working._storage["sync-2"] = data # Act result = await dual_provider.sync_to_episodic("sync-2") # Assert assert result is True assert "sync-2" in mock_episodic._storage assert mock_episodic._storage["sync-2"].name == "To Persist" @pytest.mark.asyncio async def test_sync_to_episodic_nonexistent(self, dual_provider, mock_working, mock_episodic): """測試同步不存在的資料到 Episodic""" # Act result = await dual_provider.sync_to_episodic("nonexistent") # Assert assert result is False # ============================================================================= # Property 測試 # ============================================================================= class TestDualMemoryProperties: """Property 測試""" def test_working_property(self, dual_provider, mock_working): """測試 working property""" assert dual_provider.working is mock_working def test_episodic_property(self, dual_provider, mock_episodic): """測試 episodic property""" assert dual_provider.episodic is mock_episodic