Phase 6.4 - Modular Architecture: - Add lewooogo-brain adapters for LLM providers - Add lewooogo-data dual memory (Redis + PostgreSQL) - Implement consensus engine for multi-agent decisions - Add incident memory service for historical context Phase 9 - Agent Teams (Claude Agent SDK): - Add base agent class with Claude Sonnet 4 integration - Implement action planner, blast radius, and security agents - Add agent API endpoints and proposal workflow - Integrate ADR-009 OpenClaw Agent Teams architecture DevOps & CI/CD: - Add GitHub Actions CI/CD workflows (ci.yaml, cd.yaml) - Add pre-commit hooks and secrets baseline - Add docker-compose for local development - Update Kubernetes network policies Frontend Improvements: - Add auto-healing error boundary component - Update i18n messages for agent features - Enhance dual-state incident card with execution feedback Documentation: - Add 7 ADRs covering MCP, design system, architecture decisions - Update ARCHITECTURE_MEMORY.md with modular design - Add GLOBAL_RULES.md and SOUL.md for project identity Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
431 lines
13 KiB
Python
431 lines
13 KiB
Python
"""
|
||
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
|