Skip to content

Commit 86e50b8

Browse files
committed
test(kb): add kb manager resilience tests
cover initialization failure and recovery scenarios to guard against regressions in kb error handling include reference assets under refs for test validation
1 parent 7e41af5 commit 86e50b8

1 file changed

Lines changed: 305 additions & 0 deletions

File tree

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
"""
2+
Unit tests for knowledge base manager resilience behavior.
3+
4+
Tests the following scenarios:
5+
1. update_kb preserves old instance when re-initialization fails
6+
2. update_kb switches instance only after new instance initializes successfully
7+
3. _ensure_vec_db clears stale init_error after successful initialization
8+
9+
These tests use lazy imports and mocks to avoid circular import issues
10+
in the astrbot core module chain.
11+
"""
12+
13+
import sys
14+
import types
15+
from pathlib import Path
16+
from unittest.mock import AsyncMock, MagicMock, patch
17+
18+
import pytest
19+
20+
21+
@pytest.fixture
22+
def stub_provider_manager_module():
23+
"""Stub provider manager module to avoid circular imports in unit tests."""
24+
original_module = sys.modules.get("astrbot.core.provider.manager")
25+
stub_module = types.ModuleType("astrbot.core.provider.manager")
26+
27+
class ProviderManager: ...
28+
29+
setattr(stub_module, "ProviderManager", ProviderManager)
30+
sys.modules["astrbot.core.provider.manager"] = stub_module
31+
32+
try:
33+
yield
34+
finally:
35+
if original_module is not None:
36+
sys.modules["astrbot.core.provider.manager"] = original_module
37+
else:
38+
sys.modules.pop("astrbot.core.provider.manager", None)
39+
40+
41+
@pytest.fixture
42+
def mock_provider_manager():
43+
"""Create a mock ProviderManager."""
44+
manager = MagicMock()
45+
manager.get_provider_by_id = AsyncMock()
46+
manager.acm = MagicMock()
47+
manager.acm.default_conf = {}
48+
return manager
49+
50+
51+
@pytest.fixture
52+
def mock_kb_db():
53+
"""Create a mock KBSQLiteDatabase."""
54+
db = MagicMock()
55+
db.get_db = MagicMock()
56+
db.list_kbs = AsyncMock(return_value=[])
57+
db.get_kb_by_id = AsyncMock()
58+
return db
59+
60+
61+
@pytest.fixture
62+
def mock_knowledge_base():
63+
"""Create a mock KnowledgeBase instance."""
64+
# Use lazy import to avoid circular import
65+
from astrbot.core.knowledge_base.models import KnowledgeBase
66+
67+
kb = KnowledgeBase(
68+
kb_name="test_kb",
69+
description="Test knowledge base",
70+
emoji="📚",
71+
embedding_provider_id="test-embedding-provider",
72+
rerank_provider_id=None,
73+
chunk_size=512,
74+
chunk_overlap=50,
75+
top_k_dense=50,
76+
top_k_sparse=50,
77+
top_m_final=5,
78+
)
79+
return kb
80+
81+
82+
@pytest.fixture
83+
def mock_embedding_provider():
84+
"""Create a mock EmbeddingProvider."""
85+
provider = MagicMock()
86+
provider.get_embeddings_batch = AsyncMock(return_value=[[0.1, 0.2, 0.3]])
87+
return provider
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_update_kb_preserves_old_instance_when_reinit_fails(
92+
stub_provider_manager_module,
93+
mock_provider_manager,
94+
mock_kb_db,
95+
mock_knowledge_base,
96+
mock_embedding_provider,
97+
):
98+
"""
99+
Test that update_kb preserves the old KBHelper instance when
100+
re-initialization fails, ensuring the knowledge base remains available.
101+
"""
102+
# Lazy import to avoid circular import
103+
from astrbot.core.knowledge_base.kb_helper import KBHelper
104+
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
105+
106+
# Setup: create an existing KBHelper with working vec_db
107+
mock_provider_manager.get_provider_by_id.return_value = mock_embedding_provider
108+
109+
# Create KBHelper using __new__ to avoid __init__ side effects
110+
old_helper = KBHelper.__new__(KBHelper)
111+
old_helper.kb = mock_knowledge_base
112+
old_helper.prov_mgr = mock_provider_manager
113+
old_helper.kb_db = mock_kb_db
114+
old_helper.kb_root_dir = "/tmp/test_kb"
115+
old_helper.chunker = MagicMock()
116+
old_helper.init_error = None
117+
old_helper.vec_db = MagicMock() # Simulate existing working vec_db
118+
old_helper.terminate = AsyncMock()
119+
120+
# Create KBManager and inject the existing helper
121+
kb_mgr = KnowledgeBaseManager.__new__(KnowledgeBaseManager)
122+
kb_mgr.provider_manager = mock_provider_manager
123+
kb_mgr.kb_db = mock_kb_db
124+
kb_mgr.kb_insts = {mock_knowledge_base.kb_id: old_helper}
125+
kb_mgr.retrieval_manager = MagicMock()
126+
127+
# Mock KBHelper creation to simulate initialization failure
128+
with patch.object(KBHelper, "initialize", new_callable=AsyncMock) as mock_init:
129+
# First call (for new_helper) should fail
130+
mock_init.side_effect = Exception("Embedding provider unavailable")
131+
132+
# Execute update_kb with a different embedding provider
133+
result = await kb_mgr.update_kb(
134+
kb_id=mock_knowledge_base.kb_id,
135+
kb_name="updated_kb",
136+
embedding_provider_id="new-embedding-provider",
137+
)
138+
139+
# Verify: the old helper should be returned, not a new one
140+
assert result is not None
141+
assert result is old_helper
142+
assert kb_mgr.kb_insts[mock_knowledge_base.kb_id] is old_helper
143+
144+
# Verify: old helper's vec_db should still be available
145+
assert hasattr(result, "vec_db")
146+
assert result.vec_db is not None
147+
148+
# Verify: failure does not replace the existing helper state
149+
assert result.init_error is None
150+
assert result.kb.kb_name == "test_kb"
151+
assert result.kb.embedding_provider_id == "test-embedding-provider"
152+
153+
154+
@pytest.mark.asyncio
155+
async def test_update_kb_switches_instance_only_after_new_reinit_success(
156+
stub_provider_manager_module,
157+
mock_provider_manager,
158+
mock_kb_db,
159+
mock_knowledge_base,
160+
mock_embedding_provider,
161+
):
162+
"""
163+
Test that update_kb only switches to the new KBHelper instance
164+
after the new instance successfully initializes.
165+
"""
166+
# Lazy import to avoid circular import
167+
from astrbot.core.knowledge_base.kb_helper import KBHelper
168+
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
169+
170+
# Setup: create an existing KBHelper
171+
mock_provider_manager.get_provider_by_id.return_value = mock_embedding_provider
172+
173+
old_helper = KBHelper.__new__(KBHelper)
174+
old_helper.kb = mock_knowledge_base
175+
old_helper.prov_mgr = mock_provider_manager
176+
old_helper.kb_db = mock_kb_db
177+
old_helper.kb_root_dir = "/tmp/test_kb"
178+
old_helper.chunker = MagicMock()
179+
old_helper.init_error = None
180+
old_helper.vec_db = MagicMock()
181+
old_helper.terminate = AsyncMock()
182+
183+
kb_mgr = KnowledgeBaseManager.__new__(KnowledgeBaseManager)
184+
kb_mgr.provider_manager = mock_provider_manager
185+
kb_mgr.kb_db = mock_kb_db
186+
kb_mgr.kb_insts = {mock_knowledge_base.kb_id: old_helper}
187+
kb_mgr.retrieval_manager = MagicMock()
188+
189+
# Mock session context for database operations
190+
mock_session = MagicMock()
191+
mock_session.add = MagicMock()
192+
mock_session.commit = AsyncMock()
193+
mock_session.refresh = AsyncMock()
194+
195+
mock_db_context = MagicMock()
196+
mock_db_context.__aenter__ = AsyncMock(return_value=mock_session)
197+
mock_db_context.__aexit__ = AsyncMock()
198+
mock_kb_db.get_db.return_value = mock_db_context
199+
200+
# Mock KBHelper.initialize to succeed
201+
with patch.object(KBHelper, "initialize", new_callable=AsyncMock) as mock_init:
202+
mock_init.return_value = None
203+
204+
# Execute update_kb
205+
result = await kb_mgr.update_kb(
206+
kb_id=mock_knowledge_base.kb_id,
207+
kb_name="updated_kb",
208+
embedding_provider_id="new-embedding-provider",
209+
)
210+
211+
# Verify: a new helper should be returned
212+
assert result is not None
213+
assert result is not old_helper
214+
assert result.init_error is None
215+
assert kb_mgr.kb_insts[mock_knowledge_base.kb_id] is result
216+
217+
# Verify: old helper should be terminated
218+
old_helper.terminate.assert_called_once()
219+
220+
221+
@pytest.mark.asyncio
222+
async def test_ensure_vec_db_clears_stale_init_error(
223+
stub_provider_manager_module,
224+
mock_provider_manager,
225+
mock_kb_db,
226+
mock_knowledge_base,
227+
mock_embedding_provider,
228+
):
229+
"""
230+
Test that _ensure_vec_db clears the init_error attribute
231+
after successful initialization, removing stale error state.
232+
"""
233+
# Lazy import to avoid circular import
234+
from astrbot.core.knowledge_base.kb_helper import KBHelper
235+
236+
# Setup: create KBHelper with stale init_error
237+
mock_provider_manager.get_provider_by_id.return_value = mock_embedding_provider
238+
239+
helper = KBHelper.__new__(KBHelper)
240+
helper.kb = mock_knowledge_base
241+
helper.prov_mgr = mock_provider_manager
242+
helper.kb_db = mock_kb_db
243+
helper.kb_root_dir = "/tmp/test_kb"
244+
helper.chunker = MagicMock()
245+
helper.init_error = "Previous initialization failed"
246+
helper.kb_dir = Path("/tmp/test_kb") / mock_knowledge_base.kb_id
247+
helper.kb_medias_dir = helper.kb_dir / "medias" / mock_knowledge_base.kb_id
248+
helper.kb_files_dir = helper.kb_dir / "files" / mock_knowledge_base.kb_id
249+
250+
# Mock FaissVecDB initialization
251+
mock_vec_db = MagicMock()
252+
mock_vec_db.initialize = AsyncMock()
253+
mock_vec_db.close = AsyncMock()
254+
255+
with patch(
256+
"astrbot.core.knowledge_base.kb_helper.FaissVecDB",
257+
return_value=mock_vec_db,
258+
):
259+
# Execute _ensure_vec_db
260+
await helper._ensure_vec_db()
261+
262+
# Verify: init_error should be cleared
263+
assert helper.init_error is None
264+
assert helper.vec_db is mock_vec_db
265+
266+
267+
@pytest.mark.asyncio
268+
async def test_ensure_vec_db_sets_init_error_on_failure(
269+
stub_provider_manager_module,
270+
mock_provider_manager,
271+
mock_kb_db,
272+
mock_knowledge_base,
273+
):
274+
"""
275+
Test that _ensure_vec_db does NOT clear init_error when
276+
initialization fails, preserving the error state.
277+
"""
278+
# Lazy import to avoid circular import
279+
from astrbot.core.knowledge_base.kb_helper import KBHelper
280+
281+
# Setup: provider unavailable
282+
mock_provider_manager.get_provider_by_id.return_value = None
283+
284+
helper = KBHelper.__new__(KBHelper)
285+
helper.kb = mock_knowledge_base
286+
helper.prov_mgr = mock_provider_manager
287+
helper.kb_db = mock_kb_db
288+
helper.kb_root_dir = "/tmp/test_kb"
289+
helper.chunker = MagicMock()
290+
helper.init_error = "Previous initialization failed"
291+
helper.kb_dir = Path("/tmp/test_kb") / mock_knowledge_base.kb_id
292+
helper.kb_medias_dir = helper.kb_dir / "medias" / mock_knowledge_base.kb_id
293+
helper.kb_files_dir = helper.kb_dir / "files" / mock_knowledge_base.kb_id
294+
295+
# Execute _ensure_vec_db - should raise exception
296+
try:
297+
await helper._ensure_vec_db()
298+
pytest.fail("Expected exception but none was raised")
299+
except ValueError as e:
300+
# Verify: exception should be raised
301+
assert "无法找到" in str(e) or "未配置" in str(e)
302+
303+
# Verify: init_error should NOT be cleared (still has previous error)
304+
# Note: _ensure_vec_db doesn't set init_error; that's done by the caller
305+
assert helper.init_error is not None

0 commit comments

Comments
 (0)