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>
272 lines
8.4 KiB
Python
272 lines
8.4 KiB
Python
"""
|
|
PgMemoryProvider 單元測試
|
|
===========================
|
|
|
|
測試案例:
|
|
- test_save_and_load: 儲存並讀取
|
|
- test_upsert: 更新已存在的資料
|
|
- test_delete: 刪除
|
|
- test_exists: 存在檢查
|
|
- test_sqlite_forbidden: SQLite 禁止測試
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import patch, AsyncMock, MagicMock
|
|
from pydantic import BaseModel
|
|
|
|
from lewooogo_data.providers.pg_memory import (
|
|
PgMemoryProvider,
|
|
get_database_url,
|
|
DEFAULT_PG_URL,
|
|
)
|
|
|
|
|
|
class SampleModel(BaseModel):
|
|
"""測試用的 Pydantic Model"""
|
|
id: str
|
|
name: str
|
|
value: int = 0
|
|
|
|
|
|
# =============================================================================
|
|
# 資料庫 URL 測試
|
|
# =============================================================================
|
|
|
|
class TestDatabaseUrl:
|
|
"""資料庫 URL 相關測試"""
|
|
|
|
def test_default_url(self):
|
|
"""測試預設 URL"""
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
url = get_database_url()
|
|
assert url == DEFAULT_PG_URL
|
|
|
|
def test_env_url(self):
|
|
"""測試環境變數 URL"""
|
|
custom_url = "postgresql+asyncpg://user:pass@localhost:5432/mydb"
|
|
with patch.dict("os.environ", {"DATABASE_URL": custom_url}):
|
|
url = get_database_url()
|
|
assert url == custom_url
|
|
|
|
def test_sqlite_forbidden(self):
|
|
"""測試 SQLite 被禁止"""
|
|
sqlite_url = "sqlite:///test.db"
|
|
with patch.dict("os.environ", {"DATABASE_URL": sqlite_url}):
|
|
url = get_database_url()
|
|
# 統帥鐵律: SQLite 被禁止,應返回預設 PostgreSQL
|
|
assert url == DEFAULT_PG_URL
|
|
assert "sqlite" not in url.lower()
|
|
|
|
|
|
# =============================================================================
|
|
# PgMemoryProvider 基本功能測試
|
|
# =============================================================================
|
|
|
|
class TestPgMemoryProviderBasic:
|
|
"""基本功能測試"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load(self, patch_pg_session, mock_session):
|
|
"""測試載入資料"""
|
|
# Arrange
|
|
provider = PgMemoryProvider(model_class=SampleModel)
|
|
|
|
# 預先放入資料
|
|
expected_data = SampleModel(id="pg-1", name="PG Test", value=100)
|
|
mock_session._storage["pg-1"] = expected_data
|
|
|
|
# Act
|
|
result = await provider.load("pg-1")
|
|
|
|
# Assert
|
|
assert result is not None
|
|
assert result.id == "pg-1"
|
|
assert result.name == "PG Test"
|
|
assert result.value == 100
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_nonexistent(self, patch_pg_session, mock_session):
|
|
"""測試載入不存在的資料"""
|
|
provider = PgMemoryProvider(model_class=SampleModel)
|
|
|
|
# Act
|
|
result = await provider.load("nonexistent")
|
|
|
|
# Assert
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save(self, patch_pg_session, mock_session):
|
|
"""測試儲存資料"""
|
|
provider = PgMemoryProvider(model_class=SampleModel)
|
|
data = SampleModel(id="pg-2", name="Save Test", value=200)
|
|
|
|
# Act
|
|
result = await provider.save("pg-2", data)
|
|
|
|
# Assert
|
|
assert result is True
|
|
mock_session.add.assert_called_once()
|
|
mock_session.commit.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save_ttl_ignored(self, patch_pg_session, mock_session):
|
|
"""測試儲存時 TTL 被忽略 (PostgreSQL 永久保存)"""
|
|
provider = PgMemoryProvider(model_class=SampleModel)
|
|
data = SampleModel(id="pg-3", name="TTL Ignored", value=300)
|
|
|
|
# Act - 傳入 TTL 應被忽略
|
|
result = await provider.save("pg-3", data, ttl_seconds=3600)
|
|
|
|
# Assert
|
|
assert result is True
|
|
mock_session.add.assert_called_once()
|
|
|
|
|
|
# =============================================================================
|
|
# 刪除測試
|
|
# =============================================================================
|
|
|
|
class TestPgMemoryProviderDelete:
|
|
"""刪除相關測試"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_existing(self, patch_pg_session, mock_session):
|
|
"""測試刪除存在的資料"""
|
|
provider = PgMemoryProvider(model_class=SampleModel)
|
|
|
|
# 預先放入資料
|
|
existing_data = SampleModel(id="del-1", name="To Delete", value=0)
|
|
mock_session._storage["del-1"] = existing_data
|
|
|
|
# Act
|
|
result = await provider.delete("del-1")
|
|
|
|
# Assert
|
|
assert result is True
|
|
mock_session.delete.assert_called_once()
|
|
mock_session.commit.assert_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_nonexistent(self, patch_pg_session, mock_session):
|
|
"""測試刪除不存在的資料"""
|
|
provider = PgMemoryProvider(model_class=SampleModel)
|
|
|
|
# Act
|
|
result = await provider.delete("nonexistent")
|
|
|
|
# Assert
|
|
assert result is False
|
|
|
|
|
|
# =============================================================================
|
|
# 存在檢查測試
|
|
# =============================================================================
|
|
|
|
class TestPgMemoryProviderExists:
|
|
"""存在檢查測試"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exists_true(self, patch_pg_session, mock_session):
|
|
"""測試存在的資料"""
|
|
provider = PgMemoryProvider(model_class=SampleModel)
|
|
|
|
# 預先放入資料
|
|
existing_data = SampleModel(id="exist-1", name="Exists", value=1)
|
|
mock_session._storage["exist-1"] = existing_data
|
|
|
|
# Act
|
|
result = await provider.exists("exist-1")
|
|
|
|
# Assert
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_exists_false(self, patch_pg_session, mock_session):
|
|
"""測試不存在的資料"""
|
|
provider = PgMemoryProvider(model_class=SampleModel)
|
|
|
|
# Act
|
|
result = await provider.exists("nonexistent")
|
|
|
|
# Assert
|
|
assert result is False
|
|
|
|
|
|
# =============================================================================
|
|
# 更新測試
|
|
# =============================================================================
|
|
|
|
class TestPgMemoryProviderUpdate:
|
|
"""更新相關測試"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_existing(self, patch_pg_session, mock_session):
|
|
"""測試更新存在的資料"""
|
|
provider = PgMemoryProvider(model_class=SampleModel)
|
|
|
|
# 預先放入資料 (使用可變物件)
|
|
class MutableModel:
|
|
def __init__(self):
|
|
self.id = "upd-1"
|
|
self.name = "Original"
|
|
self.value = 10
|
|
|
|
existing = MutableModel()
|
|
mock_session._storage["upd-1"] = existing
|
|
|
|
# Act
|
|
result = await provider.update("upd-1", {"value": 99, "name": "Updated"})
|
|
|
|
# Assert
|
|
assert result is True
|
|
assert existing.value == 99
|
|
assert existing.name == "Updated"
|
|
mock_session.commit.assert_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_nonexistent(self, patch_pg_session, mock_session):
|
|
"""測試更新不存在的資料"""
|
|
provider = PgMemoryProvider(model_class=SampleModel)
|
|
|
|
# Act
|
|
result = await provider.update("nonexistent", {"value": 999})
|
|
|
|
# Assert
|
|
assert result is False
|
|
|
|
|
|
# =============================================================================
|
|
# 錯誤處理測試
|
|
# =============================================================================
|
|
|
|
class TestPgMemoryProviderErrors:
|
|
"""錯誤處理測試"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_without_model_class(self, patch_pg_session):
|
|
"""測試無 model_class 時 load 拋異常"""
|
|
provider = PgMemoryProvider(model_class=None)
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValueError, match="model_class is required"):
|
|
await provider.load("any-key")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_without_model_class(self, patch_pg_session):
|
|
"""測試無 model_class 時 delete 拋異常"""
|
|
provider = PgMemoryProvider(model_class=None)
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValueError, match="model_class is required"):
|
|
await provider.delete("any-key")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_without_model_class(self, patch_pg_session):
|
|
"""測試無 model_class 時 update 拋異常"""
|
|
provider = PgMemoryProvider(model_class=None)
|
|
|
|
# Act & Assert
|
|
with pytest.raises(ValueError, match="model_class is required"):
|
|
await provider.update("any-key", {"value": 1})
|