Files
AstrBot/tests/unit/test_knowledge_base_service_contract.py
lxfight ea9e3421d9 fix: align knowledge base CRUD API contract with kb_name, canonical payload fields, and matching OpenAPI types (#9000)
Warning: Potential breaking changes in /kb related openapi.

* fix(kb): align CRUD API contract end-to-end

Unify knowledge base create/update/list with kb_name, canonical_payload,
optional list pagination with total, and matching OpenAPI plus dashboard types.

* fix: restore _model_dict references, add KnowledgeBaseCreateRequest, fix OpenAPI allOf validation

- Replace 3 remaining _model_dict() calls with payload.model_dump(exclude_none=True)
- Add KnowledgeBaseCreateRequest schema with required kb_name + embedding_provider_id
- Remove additionalProperties: false from OpenAPI allOf sub-schema to avoid validation failures
- Use Pydantic Field(alias="name") + populate_by_name=True for legacy name migration
- Delegate _canonical_kb_payload to KnowledgeBaseRequest to eliminate duplicated logic
- Add getattr default None for update_kb to prevent AttributeError
- Add validation error tests: missing kb_name, missing embedding_provider_id

* fix: address knowledge base contract follow-ups

* fix: preserve knowledge base create error shape
2026-07-03 22:18:56 +08:00

267 lines
7.7 KiB
Python

from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import Request
from astrbot.core.provider.provider import EmbeddingProvider
from astrbot.dashboard.api.knowledge_bases import (
list_knowledge_bases,
)
from astrbot.dashboard.schemas import (
KnowledgeBaseRequest,
)
from astrbot.dashboard.services.knowledge_base_service import (
KnowledgeBaseService,
KnowledgeBaseServiceError,
)
class FakeEmbeddingProvider(EmbeddingProvider):
def __init__(self):
super().__init__({}, {})
async def get_embedding(self, text: str) -> list[float]:
return [0.1, 0.2]
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
return [[0.1, 0.2] for _ in text]
def get_dim(self) -> int:
return 2
def make_service(kb_manager) -> KnowledgeBaseService:
service = KnowledgeBaseService.__new__(KnowledgeBaseService)
service.core_lifecycle = SimpleNamespace(kb_manager=kb_manager)
service.upload_progress = {}
service.upload_tasks = {}
return service
def make_kb(kb_id: str, kb_name: str):
return SimpleNamespace(
kb_id=kb_id,
kb_name=kb_name,
description="description",
emoji="book",
embedding_provider_id="embedding-1",
rerank_provider_id="rerank-1",
chunk_size=512,
chunk_overlap=50,
top_k_dense=50,
top_k_sparse=50,
top_m_final=5,
model_dump=lambda: {"kb_id": kb_id, "kb_name": kb_name},
)
def make_request(query_string: bytes) -> Request:
return Request(
{
"type": "http",
"method": "GET",
"path": "/api/v1/knowledge-bases",
"query_string": query_string,
"headers": [],
}
)
@pytest.mark.asyncio
async def test_list_kbs_applies_pagination():
kb_manager = MagicMock()
kb_manager.list_kbs = AsyncMock(
return_value=[
make_kb("kb-1", "one"),
make_kb("kb-2", "two"),
make_kb("kb-3", "three"),
]
)
kb_manager.get_kb = AsyncMock(
side_effect=lambda kb_id: SimpleNamespace(init_error=None)
)
service = make_service(kb_manager)
result = await service.list_kbs(page=2, page_size=2)
assert result == {
"items": [{"kb_id": "kb-3", "kb_name": "three"}],
"page": 2,
"page_size": 2,
"total": 3,
}
@pytest.mark.asyncio
async def test_list_route_uses_default_page_size_without_query_params():
service = MagicMock()
service.list_kbs = AsyncMock(return_value={"items": [], "total": 0})
response = await list_knowledge_bases(
make_request(b""),
_auth=object(),
service=service,
)
assert response["status"] == "ok"
service.list_kbs.assert_awaited_once_with(page=1, page_size=20)
@pytest.mark.asyncio
async def test_list_route_uses_default_page_size_when_page_is_explicit():
service = MagicMock()
service.list_kbs = AsyncMock(return_value={"items": [], "total": 0})
response = await list_knowledge_bases(
make_request(b"page=2"),
_auth=object(),
service=service,
)
assert response["status"] == "ok"
service.list_kbs.assert_awaited_once_with(page=2, page_size=20)
@pytest.mark.asyncio
async def test_create_kb_accepts_legacy_name_field():
kb = make_kb("kb-1", "From Name")
kb_manager = MagicMock()
kb_manager.provider_manager.get_provider_by_id = AsyncMock(
return_value=FakeEmbeddingProvider()
)
kb_manager.create_kb = AsyncMock(return_value=SimpleNamespace(kb=kb))
service = make_service(kb_manager)
result, message = await service.create_kb(
{
"name": "From Name",
"embedding_provider_id": "embedding-1",
"top_k_dense": 12,
"top_k_sparse": 8,
"top_m_final": 3,
}
)
assert message == "创建知识库成功"
assert result == {"kb_id": "kb-1", "kb_name": "From Name"}
kb_manager.create_kb.assert_awaited_once_with(
kb_name="From Name",
description=None,
emoji=None,
embedding_provider_id="embedding-1",
rerank_provider_id=None,
chunk_size=None,
chunk_overlap=None,
top_k_dense=12,
top_k_sparse=8,
top_m_final=3,
)
@pytest.mark.asyncio
async def test_update_kb_preserves_omitted_fields():
kb = make_kb("kb-1", "Docs")
kb_manager = MagicMock()
kb_manager.get_kb = AsyncMock(return_value=SimpleNamespace(kb=kb))
kb_manager.update_kb = AsyncMock(return_value=SimpleNamespace(kb=kb))
service = make_service(kb_manager)
await service.update_kb({"kb_id": "kb-1", "chunk_size": 1024})
kb_manager.update_kb.assert_awaited_once_with(
kb_id="kb-1",
kb_name="Docs",
description="description",
emoji="book",
embedding_provider_id="embedding-1",
rerank_provider_id="rerank-1",
chunk_size=1024,
chunk_overlap=50,
top_k_dense=50,
top_k_sparse=50,
top_m_final=5,
)
@pytest.mark.asyncio
async def test_update_kb_allows_explicit_rerank_provider_clear():
kb = make_kb("kb-1", "Docs")
kb_manager = MagicMock()
kb_manager.get_kb = AsyncMock(return_value=SimpleNamespace(kb=kb))
kb_manager.update_kb = AsyncMock(return_value=SimpleNamespace(kb=kb))
service = make_service(kb_manager)
await service.update_kb({"kb_id": "kb-1", "rerank_provider_id": None})
kb_manager.update_kb.assert_awaited_once()
assert kb_manager.update_kb.await_args.kwargs["rerank_provider_id"] is None
def test_knowledge_base_schemas_match_service_contract():
create_payload = KnowledgeBaseRequest(
kb_name="Docs",
name="Legacy",
emoji="book",
top_k_dense=12,
top_k_sparse=8,
top_m_final=3,
kb_id="body-kb-id",
).canonical_payload()
assert create_payload == {
"kb_name": "Docs",
"emoji": "book",
"top_k_dense": 12,
"top_k_sparse": 8,
"top_m_final": 3,
}
assert "kb_id" not in create_payload
def test_knowledge_base_request_preserves_explicit_null_updates():
payload = KnowledgeBaseRequest(rerank_provider_id=None).canonical_payload()
assert payload == {"rerank_provider_id": None}
def test_knowledge_base_request_omits_unset_none_fields():
payload = KnowledgeBaseRequest(kb_name="Docs").canonical_payload()
assert payload == {"kb_name": "Docs"}
def test_knowledge_base_request_uses_legacy_name_as_input_alias():
payload = KnowledgeBaseRequest(name="Legacy Name").canonical_payload()
assert payload == {"kb_name": "Legacy Name"}
@pytest.mark.asyncio
async def test_create_kb_raises_when_kb_name_is_missing():
kb_manager = MagicMock()
service = make_service(kb_manager)
with pytest.raises(KnowledgeBaseServiceError, match="知识库名称不能为空"):
await service.create_kb({"embedding_provider_id": "embedding-1"})
@pytest.mark.asyncio
async def test_create_kb_raises_when_embedding_provider_is_missing():
kb_manager = MagicMock()
service = make_service(kb_manager)
with pytest.raises(KnowledgeBaseServiceError, match="缺少参数 embedding_provider_id"):
await service.create_kb({"kb_name": "Test KB"})
@pytest.mark.asyncio
async def test_create_kb_raises_when_embedding_provider_is_invalid():
kb_manager = MagicMock()
kb_manager.provider_manager.get_provider_by_id = AsyncMock(return_value=None)
service = make_service(kb_manager)
with pytest.raises(KnowledgeBaseServiceError, match="嵌入模型不存在或类型错误"):
await service.create_kb(
{"kb_name": "Test KB", "embedding_provider_id": "missing-provider"}
)