mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
* fix(kb): clean up KBMedia records on document delete, fix update_kb_stats - delete_document_by_id now deletes associated KBMedia records before removing the KBDocument row, preventing orphaned media data. - update_kb_stats now passes kb_id filter to vec_db.count_documents, fixing chunk count that previously counted across all KBs. - add tests for document deletion cleanup and update_kb_stats behavior. * fix(kb): merge media and document deletion into single transaction
200 lines
5.7 KiB
Python
200 lines
5.7 KiB
Python
"""
|
|
Unit tests for knowledge base document cleanup behavior.
|
|
|
|
Tests the following scenarios:
|
|
1. delete_document_by_id cleans up kb_media records
|
|
2. update_kb_stats counts chunks for the correct KB
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from astrbot.core.knowledge_base.kb_db_sqlite import KBSQLiteDatabase
|
|
from astrbot.core.knowledge_base.models import KBDocument, KBMedia, KnowledgeBase
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def kb_db(tmp_path):
|
|
"""Create a real KBSQLiteDatabase backed by a temporary file."""
|
|
db_path = str(tmp_path / "test_kb.db")
|
|
db = KBSQLiteDatabase(db_path)
|
|
await db.initialize()
|
|
await db.migrate_to_v1()
|
|
return db
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def seeded_kb(kb_db):
|
|
"""Seed a knowledge base and return its kb_id."""
|
|
kb = KnowledgeBase(
|
|
kb_name="Test KB",
|
|
description="A test knowledge base",
|
|
embedding_provider_id="test-embedding",
|
|
)
|
|
async with kb_db.get_db() as session, session.begin():
|
|
session.add(kb)
|
|
await session.flush()
|
|
kb_id = kb.kb_id
|
|
return kb_id
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def seeded_doc(kb_db, seeded_kb):
|
|
"""Seed a document in the knowledge base and return (kb_id, doc_id)."""
|
|
doc = KBDocument(
|
|
kb_id=seeded_kb,
|
|
doc_name="test_doc.txt",
|
|
file_type="txt",
|
|
file_size=100,
|
|
file_path="",
|
|
)
|
|
async with kb_db.get_db() as session, session.begin():
|
|
session.add(doc)
|
|
await session.flush()
|
|
doc_id = doc.doc_id
|
|
return seeded_kb, doc_id
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def seeded_media(kb_db, seeded_doc):
|
|
"""Seed media records linked to the document."""
|
|
kb_id, doc_id = seeded_doc
|
|
|
|
media1 = KBMedia(
|
|
doc_id=doc_id,
|
|
kb_id=kb_id,
|
|
media_type="image",
|
|
file_name="img1.png",
|
|
file_path="/tmp/fake/img1.png",
|
|
file_size=1024,
|
|
mime_type="image/png",
|
|
)
|
|
media2 = KBMedia(
|
|
doc_id=doc_id,
|
|
kb_id=kb_id,
|
|
media_type="image",
|
|
file_name="img2.png",
|
|
file_path="/tmp/fake/img2.png",
|
|
file_size=2048,
|
|
mime_type="image/png",
|
|
)
|
|
async with kb_db.get_db() as session, session.begin():
|
|
session.add(media1)
|
|
session.add(media2)
|
|
await session.flush()
|
|
|
|
return kb_id, doc_id, (media1.media_id, media2.media_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_document_cleans_media_records(kb_db, seeded_media):
|
|
"""删除文档时, kb_media 表中关联的多媒体记录应一并被删除。"""
|
|
kb_id, doc_id, (media_id1, media_id2) = seeded_media
|
|
|
|
# 验证 media 记录存在
|
|
media_list = await kb_db.list_media_by_doc(doc_id)
|
|
assert len(media_list) == 2
|
|
|
|
# Mock vec_db
|
|
mock_vec_db = MagicMock()
|
|
mock_vec_db.delete_documents = AsyncMock()
|
|
|
|
await kb_db.delete_document_by_id(doc_id, mock_vec_db)
|
|
|
|
# 验证 media 记录已删除
|
|
remaining = await kb_db.list_media_by_doc(doc_id)
|
|
assert remaining == []
|
|
|
|
# 验证 vec_db 也被调用
|
|
mock_vec_db.delete_documents.assert_awaited_once_with(
|
|
metadata_filters={"kb_doc_id": doc_id},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_document_keeps_other_doc_media(kb_db, seeded_kb):
|
|
"""删除一个文档时, 其他文档的多媒体记录不应受影响。"""
|
|
kb_id = seeded_kb
|
|
|
|
# 创建文档 A
|
|
doc_a = KBDocument(
|
|
kb_id=kb_id, doc_name="doc_a.txt", file_type="txt", file_size=100, file_path=""
|
|
)
|
|
# 创建文档 B
|
|
doc_b = KBDocument(
|
|
kb_id=kb_id, doc_name="doc_b.txt", file_type="txt", file_size=200, file_path=""
|
|
)
|
|
async with kb_db.get_db() as session, session.begin():
|
|
session.add(doc_a)
|
|
session.add(doc_b)
|
|
await session.flush()
|
|
doc_a_id = doc_a.doc_id
|
|
doc_b_id = doc_b.doc_id
|
|
|
|
# 为文档 B 创建 media
|
|
media_b = KBMedia(
|
|
doc_id=doc_b_id,
|
|
kb_id=kb_id,
|
|
media_type="image",
|
|
file_name="b.png",
|
|
file_path="/tmp/fake/b.png",
|
|
file_size=512,
|
|
mime_type="image/png",
|
|
)
|
|
async with kb_db.get_db() as session, session.begin():
|
|
session.add(media_b)
|
|
|
|
mock_vec_db = MagicMock()
|
|
mock_vec_db.delete_documents = AsyncMock()
|
|
|
|
await kb_db.delete_document_by_id(doc_a_id, mock_vec_db)
|
|
|
|
# 文档 B 的 media 应仍在
|
|
remaining_b = await kb_db.list_media_by_doc(doc_b_id)
|
|
assert len(remaining_b) == 1
|
|
assert remaining_b[0].file_name == "b.png"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_document_removes_doc_record(kb_db, seeded_media):
|
|
"""删除文档时, KBDocument 记录应被删除。"""
|
|
kb_id, doc_id, _ = seeded_media
|
|
|
|
mock_vec_db = MagicMock()
|
|
mock_vec_db.delete_documents = AsyncMock()
|
|
|
|
await kb_db.delete_document_by_id(doc_id, mock_vec_db)
|
|
|
|
doc = await kb_db.get_document_by_id(doc_id)
|
|
assert doc is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_kb_stats_counts_chunks_for_single_kb(kb_db, seeded_kb):
|
|
"""update_kb_stats 应只统计指定知识库的 chunk 数量。"""
|
|
kb_id1 = seeded_kb
|
|
|
|
# 创建第二个知识库
|
|
kb2 = KnowledgeBase(
|
|
kb_name="Second KB",
|
|
description="Another test knowledge base",
|
|
embedding_provider_id="test-embedding",
|
|
)
|
|
async with kb_db.get_db() as session, session.begin():
|
|
session.add(kb2)
|
|
await session.flush()
|
|
kb_id2 = kb2.kb_id
|
|
|
|
# Mock vec_db: count_documents 应被调用并带有 kb_id 过滤
|
|
mock_vec_db = MagicMock()
|
|
mock_vec_db.count_documents = AsyncMock(return_value=5)
|
|
|
|
await kb_db.update_kb_stats(kb_id1, mock_vec_db)
|
|
|
|
# 验证 count_documents 传入了正确的 metadata_filter
|
|
mock_vec_db.count_documents.assert_awaited_once_with(
|
|
metadata_filter={"kb_id": kb_id1},
|
|
)
|