Files
xerrors--yuxi/backend/test/unit/routers/test_knowledge_router_cleanup.py
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

642 lines
25 KiB
Python

from inspect import signature
from io import BytesIO
from types import SimpleNamespace
import pytest
from fastapi import FastAPI, HTTPException, UploadFile
from httpx import ASGITransport, AsyncClient
from server.routers import knowledge_router
pytestmark = pytest.mark.asyncio
class FakeTaskContext:
def __init__(self):
self.result = None
async def set_message(self, message: str) -> None:
return None
async def set_progress(self, progress: float, message: str | None = None) -> None:
return None
async def set_result(self, result: dict) -> None:
self.result = result
async def raise_if_cancelled(self) -> None:
return None
async def test_upload_file_does_not_expose_legacy_allow_jsonl_query():
assert "allow_jsonl" not in signature(knowledge_router.upload_file).parameters
async def test_document_file_exists_returns_boolean_for_relative_path(monkeypatch):
captured = {}
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
captured["ensure"] = (kb_id, operation)
async def fake_document_file_exists(kb_id: str, filename: str) -> bool:
captured["exists"] = (kb_id, filename)
return True
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
monkeypatch.setattr(knowledge_router.knowledge_base, "document_file_exists", fake_document_file_exists)
result = await knowledge_router.document_file_exists(
"kb_1",
filename=" google_drive/shared_drives/engineering/playbook.txt ",
current_user=SimpleNamespace(uid="user_1"),
)
assert result == {
"kb_id": "kb_1",
"filename": "google_drive/shared_drives/engineering/playbook.txt",
"exists": True,
}
assert captured == {
"ensure": ("kb_1", "文档存在性检查"),
"exists": ("kb_1", "google_drive/shared_drives/engineering/playbook.txt"),
}
async def test_document_file_exists_route_accepts_filename_with_slashes(monkeypatch):
async def fake_admin_user():
return SimpleNamespace(uid="user_1")
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
return None
async def fake_document_file_exists(kb_id: str, filename: str) -> bool:
assert kb_id == "kb_1"
assert filename == "google_drive/shared_drives/engineering/playbook.txt"
return True
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
monkeypatch.setattr(knowledge_router.knowledge_base, "document_file_exists", fake_document_file_exists)
app = FastAPI()
app.include_router(knowledge_router.knowledge, prefix="/api")
app.dependency_overrides[knowledge_router.get_admin_user] = fake_admin_user
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get(
"/api/knowledge/databases/kb_1/documents/exists",
params={"filename": "google_drive/shared_drives/engineering/playbook.txt"},
)
assert response.status_code == 200, response.text
assert response.json() == {
"kb_id": "kb_1",
"filename": "google_drive/shared_drives/engineering/playbook.txt",
"exists": True,
}
async def test_document_file_exists_rejects_blank_filename(monkeypatch):
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
return None
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
with pytest.raises(HTTPException) as exc_info:
await knowledge_router.document_file_exists(
"kb_1",
filename=" ",
current_user=SimpleNamespace(uid="user_1"),
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "filename is required"
async def test_upload_file_rejects_jsonl_uploads():
upload = UploadFile(filename="dataset.jsonl", file=BytesIO(b'{"query":"hello"}\n'))
with pytest.raises(HTTPException) as exc_info:
await knowledge_router.upload_file(upload, kb_id=None, current_user=SimpleNamespace(uid="user_1"))
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "Unsupported file type: .jsonl"
async def test_upload_file_rejects_oversized_file(monkeypatch):
monkeypatch.setattr(knowledge_router, "MAX_UPLOAD_SIZE_BYTES", 5)
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
return None
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
upload = UploadFile(filename="demo.txt", file=BytesIO(b"123456"))
with pytest.raises(HTTPException) as exc_info:
await knowledge_router.upload_file(upload, kb_id="kb_1", current_user=SimpleNamespace(uid="user_1"))
assert exc_info.value.status_code == 400
assert "100 MB" in exc_info.value.detail
async def test_upload_file_invalid_kb_fails_before_read_or_minio(monkeypatch):
calls = {"read": 0, "upload": 0}
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
raise HTTPException(status_code=404, detail=f"知识库 {kb_id} 不存在")
async def fake_read_upload_with_limit(*_args, **_kwargs) -> bytes:
calls["read"] += 1
return b"demo"
async def fake_upload_to_minio(*_args, **_kwargs) -> str:
calls["upload"] += 1
return "minio://knowledgebases/kb_1/upload/demo.txt"
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
monkeypatch.setattr(knowledge_router, "read_upload_with_limit", fake_read_upload_with_limit)
monkeypatch.setattr(knowledge_router, "aupload_file_to_minio", fake_upload_to_minio)
upload = UploadFile(filename="demo.txt", file=BytesIO(b"demo"))
with pytest.raises(HTTPException) as exc_info:
await knowledge_router.upload_file(upload, kb_id="missing", current_user=SimpleNamespace(uid="user_1"))
assert exc_info.value.status_code == 404
assert calls == {"read": 0, "upload": 0}
async def test_upload_file_read_only_kb_fails_before_read_or_minio(monkeypatch):
calls = {"read": 0, "upload": 0}
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
raise HTTPException(status_code=400, detail="只支持检索,不支持文档上传")
async def fake_read_upload_with_limit(*_args, **_kwargs) -> bytes:
calls["read"] += 1
return b"demo"
async def fake_upload_to_minio(*_args, **_kwargs) -> str:
calls["upload"] += 1
return "minio://knowledgebases/kb_1/upload/demo.txt"
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
monkeypatch.setattr(knowledge_router, "read_upload_with_limit", fake_read_upload_with_limit)
monkeypatch.setattr(knowledge_router, "aupload_file_to_minio", fake_upload_to_minio)
upload = UploadFile(filename="demo.txt", file=BytesIO(b"demo"))
with pytest.raises(HTTPException) as exc_info:
await knowledge_router.upload_file(upload, kb_id="readonly", current_user=SimpleNamespace(uid="user_1"))
assert exc_info.value.status_code == 400
assert calls == {"read": 0, "upload": 0}
async def test_markdown_endpoint_rejects_oversized_file(monkeypatch):
monkeypatch.setattr(knowledge_router, "MAX_UPLOAD_SIZE_BYTES", 5)
upload = UploadFile(filename="demo.txt", file=BytesIO(b"123456"))
with pytest.raises(HTTPException) as exc_info:
await knowledge_router.mark_it_down(upload, current_user=SimpleNamespace(uid="user_1"))
assert exc_info.value.status_code == 400
assert "100 MB" in exc_info.value.detail
async def test_index_documents_uses_uid_for_operator(monkeypatch):
captured = {}
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
return None
async def fake_get_database_info(kb_id: str) -> dict:
return {"name": "测试知识库"}
async def fake_index_file(kb_id: str, file_id: str, operator_id: str | None = None, params: dict | None = None):
captured["operator_id"] = operator_id
return {"file_id": file_id, "status": "indexed"}
async def fake_enqueue(name: str, task_type: str, payload: dict, coroutine):
await coroutine(FakeTaskContext())
return SimpleNamespace(id="task_1")
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
monkeypatch.setattr(knowledge_router.knowledge_base, "get_database_info", fake_get_database_info)
monkeypatch.setattr(knowledge_router.knowledge_base, "index_file", fake_index_file)
monkeypatch.setattr(knowledge_router.tasker, "enqueue", fake_enqueue)
result = await knowledge_router.index_documents(
"kb_1",
["file_1"],
params={},
current_user=SimpleNamespace(id="numeric-id", uid="uid-user"),
)
assert result["status"] == "queued"
assert captured["operator_id"] == "uid-user"
async def test_parse_documents_rejects_oversized_direct_batch():
file_ids = [f"file_{index}" for index in range(knowledge_router.MAX_DIRECT_DOCUMENT_ACTION_FILE_IDS + 1)]
with pytest.raises(HTTPException) as exc_info:
await knowledge_router.parse_documents(
"kb_1",
file_ids,
current_user=SimpleNamespace(uid="uid-user"),
)
assert exc_info.value.status_code == 400
assert str(knowledge_router.MAX_DIRECT_DOCUMENT_ACTION_FILE_IDS) in exc_info.value.detail
async def test_parse_pending_documents_enqueues_status_scoped_task(monkeypatch):
captured = {"list_calls": [], "parsed": []}
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
captured["ensure"] = (kb_id, operation)
async def fake_get_database_info(kb_id: str) -> dict:
return {"name": "测试知识库", "stats": {"pending_parse_count": 2}}
async def fake_list_document_file_ids_by_statuses(kb_id: str, *, statuses, after_file_id, limit):
captured["list_calls"].append(
{"kb_id": kb_id, "statuses": statuses, "after_file_id": after_file_id, "limit": limit}
)
return ["file_1", "file_2"] if after_file_id is None else []
async def fake_parse_file(kb_id: str, file_id: str, operator_id: str | None = None):
captured["parsed"].append({"kb_id": kb_id, "file_id": file_id, "operator_id": operator_id})
return {"file_id": file_id, "status": "parsed"}
async def fake_enqueue_unique_by_payload(**kwargs):
captured["payload"] = kwargs["payload"]
captured["payload_match"] = kwargs["payload_match"]
captured["statuses"] = kwargs["statuses"]
await kwargs["coroutine"](FakeTaskContext())
return SimpleNamespace(id="task_1"), True
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
monkeypatch.setattr(knowledge_router.knowledge_base, "get_database_info", fake_get_database_info)
monkeypatch.setattr(
knowledge_router.knowledge_base,
"list_document_file_ids_by_statuses",
fake_list_document_file_ids_by_statuses,
)
monkeypatch.setattr(knowledge_router.knowledge_base, "parse_file", fake_parse_file)
monkeypatch.setattr(knowledge_router.tasker, "enqueue_unique_by_payload", fake_enqueue_unique_by_payload)
result = await knowledge_router.parse_pending_documents(
"kb_1",
current_user=SimpleNamespace(uid="uid-user"),
)
assert result["status"] == "queued"
assert result["task_id"] == "task_1"
assert captured["ensure"] == ("kb_1", "文档解析")
assert captured["payload_match"] == {"kb_id": "kb_1", "scope": "pending", "action": "parse"}
assert captured["statuses"] == knowledge_router.ACTIVE_DOCUMENT_ACTION_TASK_STATUSES
assert captured["payload"]["statuses"] == knowledge_router.PENDING_PARSE_STATUSES
assert captured["list_calls"] == [
{
"kb_id": "kb_1",
"statuses": knowledge_router.PENDING_PARSE_STATUSES,
"after_file_id": None,
"limit": knowledge_router.DOCUMENT_ACTION_BATCH_SIZE,
},
{
"kb_id": "kb_1",
"statuses": knowledge_router.PENDING_PARSE_STATUSES,
"after_file_id": "file_2",
"limit": knowledge_router.DOCUMENT_ACTION_BATCH_SIZE,
},
]
assert captured["parsed"] == [
{"kb_id": "kb_1", "file_id": "file_1", "operator_id": "uid-user"},
{"kb_id": "kb_1", "file_id": "file_2", "operator_id": "uid-user"},
]
async def test_index_pending_documents_uses_pending_statuses_and_params(monkeypatch):
captured = {"list_calls": [], "updated": [], "indexed": []}
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
captured["ensure"] = (kb_id, operation)
async def fake_get_database_info(kb_id: str) -> dict:
return {"name": "测试知识库", "stats": {"pending_index_count": 2}}
async def fake_list_document_file_ids_by_statuses(kb_id: str, *, statuses, after_file_id, limit):
captured["list_calls"].append(
{"kb_id": kb_id, "statuses": statuses, "after_file_id": after_file_id, "limit": limit}
)
return ["file_1", "file_2"] if after_file_id is None else []
async def fake_update_file_params(kb_id: str, file_id: str, params: dict, operator_id: str | None = None):
captured["updated"].append({"kb_id": kb_id, "file_id": file_id, "params": params, "operator_id": operator_id})
async def fake_index_file(kb_id: str, file_id: str, operator_id: str | None = None, params: dict | None = None):
captured["indexed"].append({"kb_id": kb_id, "file_id": file_id, "operator_id": operator_id, "params": params})
return {"file_id": file_id, "status": "indexed"}
async def fake_enqueue_unique_by_payload(**kwargs):
captured["payload"] = kwargs["payload"]
captured["payload_match"] = kwargs["payload_match"]
await kwargs["coroutine"](FakeTaskContext())
return SimpleNamespace(id="task_1"), True
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
monkeypatch.setattr(knowledge_router.knowledge_base, "get_database_info", fake_get_database_info)
monkeypatch.setattr(
knowledge_router.knowledge_base,
"list_document_file_ids_by_statuses",
fake_list_document_file_ids_by_statuses,
)
monkeypatch.setattr(knowledge_router.knowledge_base, "update_file_params", fake_update_file_params)
monkeypatch.setattr(knowledge_router.knowledge_base, "index_file", fake_index_file)
monkeypatch.setattr(knowledge_router.tasker, "enqueue_unique_by_payload", fake_enqueue_unique_by_payload)
params = {"chunk_preset_id": "general"}
result = await knowledge_router.index_pending_documents(
"kb_1",
payload=knowledge_router.PendingIndexDocumentsRequest(params=params),
current_user=SimpleNamespace(uid="uid-user"),
)
assert result["status"] == "queued"
assert captured["ensure"] == ("kb_1", "文档入库")
assert captured["payload_match"] == {"kb_id": "kb_1", "scope": "pending", "action": "index"}
assert captured["payload"]["statuses"] == knowledge_router.PENDING_INDEX_STATUSES
assert captured["payload"]["params"] == params
assert captured["list_calls"][0]["statuses"] == knowledge_router.PENDING_INDEX_STATUSES
assert captured["updated"] == [
{"kb_id": "kb_1", "file_id": "file_1", "params": params, "operator_id": "uid-user"},
{"kb_id": "kb_1", "file_id": "file_2", "params": params, "operator_id": "uid-user"},
]
assert captured["indexed"] == [
{"kb_id": "kb_1", "file_id": "file_1", "operator_id": "uid-user", "params": params},
{"kb_id": "kb_1", "file_id": "file_2", "operator_id": "uid-user", "params": params},
]
async def test_add_documents_auto_index_returns_one_final_result_per_item(monkeypatch):
context = FakeTaskContext()
item = "minio://knowledgebases/kb_1/upload/demo.txt"
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
return None
async def fake_get_database_info(kb_id: str) -> dict:
return {"name": "测试知识库"}
async def fake_add_file_record(kb_id: str, item_path: str, params: dict, operator_id: str | None = None):
return {"file_id": "file_1", "status": "indexing"}
async def fake_parse_file(kb_id: str, file_id: str, operator_id: str | None = None):
return {"file_id": file_id, "status": "parsed"}
async def fake_update_file_params(kb_id: str, file_id: str, params: dict, operator_id: str | None = None):
return None
async def fake_index_file(kb_id: str, file_id: str, operator_id: str | None = None, params: dict | None = None):
return {"file_id": file_id, "status": "indexed"}
async def fake_enqueue(name: str, task_type: str, payload: dict, coroutine):
await coroutine(context)
return SimpleNamespace(id="task_1")
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
monkeypatch.setattr(knowledge_router.knowledge_base, "get_database_info", fake_get_database_info)
monkeypatch.setattr(knowledge_router.knowledge_base, "add_file_record", fake_add_file_record)
monkeypatch.setattr(knowledge_router.knowledge_base, "parse_file", fake_parse_file)
monkeypatch.setattr(knowledge_router.knowledge_base, "update_file_params", fake_update_file_params)
monkeypatch.setattr(knowledge_router.knowledge_base, "index_file", fake_index_file)
monkeypatch.setattr(knowledge_router.tasker, "enqueue", fake_enqueue)
result = await knowledge_router.add_documents(
"kb_1",
[item],
params={"content_type": "file", "auto_index": True, "content_hashes": {item: "hash_1"}},
current_user=SimpleNamespace(uid="uid-user"),
)
assert result["status"] == "queued"
assert context.result["submitted"] == 1
assert context.result["failed"] == 0
assert context.result["items"] == [{"file_id": "file_1", "status": "indexed"}]
async def test_add_documents_auto_index_treats_error_none_as_success(monkeypatch):
"""成功入库的文件元数据会携带 error=None,不应被统计为失败 (#793)。"""
context = FakeTaskContext()
item = "minio://knowledgebases/kb_1/upload/demo.txt"
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
return None
async def fake_get_database_info(kb_id: str) -> dict:
return {"name": "测试知识库"}
async def fake_add_file_record(kb_id: str, item_path: str, params: dict, operator_id: str | None = None):
return {"file_id": "file_1", "status": "indexing"}
async def fake_parse_file(kb_id: str, file_id: str, operator_id: str | None = None):
return {"file_id": file_id, "status": "parsed", "error": None}
async def fake_update_file_params(kb_id: str, file_id: str, params: dict, operator_id: str | None = None):
return None
async def fake_index_file(kb_id: str, file_id: str, operator_id: str | None = None, params: dict | None = None):
return {"file_id": file_id, "status": "indexed", "error": None}
async def fake_enqueue(name: str, task_type: str, payload: dict, coroutine):
await coroutine(context)
return SimpleNamespace(id="task_1")
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
monkeypatch.setattr(knowledge_router.knowledge_base, "get_database_info", fake_get_database_info)
monkeypatch.setattr(knowledge_router.knowledge_base, "add_file_record", fake_add_file_record)
monkeypatch.setattr(knowledge_router.knowledge_base, "parse_file", fake_parse_file)
monkeypatch.setattr(knowledge_router.knowledge_base, "update_file_params", fake_update_file_params)
monkeypatch.setattr(knowledge_router.knowledge_base, "index_file", fake_index_file)
monkeypatch.setattr(knowledge_router.tasker, "enqueue", fake_enqueue)
result = await knowledge_router.add_documents(
"kb_1",
[item],
params={"content_type": "file", "auto_index": True, "content_hashes": {item: "hash_1"}},
current_user=SimpleNamespace(uid="uid-user"),
)
assert result["status"] == "queued"
assert context.result["submitted"] == 1
assert context.result["failed"] == 0
assert context.result["items"] == [{"file_id": "file_1", "status": "indexed", "error": None}]
async def test_add_uploaded_documents_rejects_empty_items(monkeypatch):
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
return None
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
with pytest.raises(HTTPException) as exc_info:
await knowledge_router.add_uploaded_documents(
"kb_1",
knowledge_router.AddUploadedDocumentsRequest(items=[], params={}),
current_user=SimpleNamespace(uid="uid-user"),
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "items must not be empty"
async def test_add_uploaded_documents_rejects_non_minio_url(monkeypatch):
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
return None
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
with pytest.raises(HTTPException) as exc_info:
await knowledge_router.add_uploaded_documents(
"kb_1",
knowledge_router.AddUploadedDocumentsRequest(
items=["https://example.com/demo.txt"],
params={"content_hashes": {"https://example.com/demo.txt": "hash_1"}},
),
current_user=SimpleNamespace(uid="uid-user"),
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "File source must be a MinIO URL"
async def test_add_uploaded_documents_rejects_missing_content_hash(monkeypatch):
item = "minio://knowledgebases/kb_1/upload/demo.txt"
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
return None
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
with pytest.raises(HTTPException) as exc_info:
await knowledge_router.add_uploaded_documents(
"kb_1",
knowledge_router.AddUploadedDocumentsRequest(items=[item], params={}),
current_user=SimpleNamespace(uid="uid-user"),
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == f"Missing content_hash for file: {item}"
async def test_add_uploaded_documents_creates_records_without_task(monkeypatch):
item = "minio://knowledgebases/kb_1/upload/demo.txt"
captured = {}
async def fake_ensure_database_supports_documents(kb_id: str, operation: str) -> None:
return None
async def fake_add_file_record(kb_id: str, item_path: str, params: dict, operator_id: str | None = None):
captured["kb_id"] = kb_id
captured["item"] = item_path
captured["params"] = params
captured["operator_id"] = operator_id
return {"file_id": "file_1", "status": "uploaded", "filename": "demo.txt"}
async def fail_enqueue(*_args, **_kwargs):
raise AssertionError("documents/add must not enqueue tasker work")
monkeypatch.setattr(
knowledge_router,
"_ensure_database_supports_documents",
fake_ensure_database_supports_documents,
)
monkeypatch.setattr(knowledge_router.knowledge_base, "add_file_record", fake_add_file_record)
monkeypatch.setattr(knowledge_router.tasker, "enqueue", fail_enqueue)
result = await knowledge_router.add_uploaded_documents(
"kb_1",
knowledge_router.AddUploadedDocumentsRequest(
items=[item],
params={
"content_hashes": {item: "hash_1"},
"file_sizes": {item: 4},
"source_paths": {item: "docs/demo.txt"},
},
),
current_user=SimpleNamespace(uid="uid-user"),
)
assert result["status"] == "success"
assert result["added"] == 1
assert result["failed"] == 0
assert result["items"][0]["file_id"] == "file_1"
assert captured == {
"kb_id": "kb_1",
"item": item,
"params": {
"content_hashes": {item: "hash_1"},
"file_sizes": {item: 4},
"source_path": "docs/demo.txt",
},
"operator_id": "uid-user",
}