Refa: Chat conversations /convsersation API to RESTFul (#13893)

### What problem does this PR solve?

Chat conversations /convsersation API to RESTFul.

### Type of change

- [x] Refactoring
This commit is contained in:
Yongteng Lei
2026-04-02 20:49:23 +08:00
committed by GitHub
parent bbb9b1df85
commit b7daf6285b
43 changed files with 1516 additions and 2002 deletions

View File

@@ -678,7 +678,9 @@ def mm_step_12_composer_and_single_send(ctx: FlowContext, step, snap):
def _on_completion_request(req):
if (
req.method.upper() in MM_REQUEST_METHOD_WHITELIST
and "/conversation/completion" in req.url
and "/api/v1/chats/" in req.url
and "/sessions/" in req.url
and req.url.rstrip("/").endswith("/completions")
):
completion_payloads.append(_mm_payload_from_request(req))
@@ -747,7 +749,7 @@ def mm_step_12_composer_and_single_send(ctx: FlowContext, step, snap):
page.remove_listener("request", _on_completion_request)
attach_path.unlink(missing_ok=True)
assert completion_payloads, "no /conversation/completion request was captured"
assert completion_payloads, "no chat session completion request was captured"
payloads_with_messages = [p for p in completion_payloads if p.get("messages")]
assert payloads_with_messages, "completion requests did not include messages"

View File

@@ -58,6 +58,28 @@ class _DummyArgs(dict):
return [value]
class _StubHeaders:
def __init__(self):
self._items = []
def add_header(self, key, value):
self._items.append((key, value))
def get(self, key, default=None):
for existing_key, value in reversed(self._items):
if existing_key == key:
return value
return default
class _StubResponse:
def __init__(self, body=None, mimetype=None, content_type=None):
self.body = body
self.mimetype = mimetype
self.content_type = content_type
self.headers = _StubHeaders()
def _passthrough_login_required(func):
@wraps(func)
async def _wrapper(*args, **kwargs):
@@ -125,6 +147,7 @@ def _load_chat_module(monkeypatch):
quart_mod = ModuleType("quart")
quart_mod.request = SimpleNamespace(args=_DummyArgs())
quart_mod.Response = _StubResponse
monkeypatch.setitem(sys.modules, "quart", quart_mod)
api_pkg = ModuleType("api")
@@ -144,6 +167,11 @@ def _load_chat_module(monkeypatch):
common_constants_mod = ModuleType("common.constants")
class _StubLLMType(str, Enum):
CHAT = "chat"
IMAGE2TEXT = "image2text"
RERANK = "rerank"
class _StubRetCode(int, Enum):
SUCCESS = 0
DATA_ERROR = 102
@@ -153,6 +181,7 @@ def _load_chat_module(monkeypatch):
VALID = "1"
INVALID = "0"
common_constants_mod.LLMType = _StubLLMType
common_constants_mod.RetCode = _StubRetCode
common_constants_mod.StatusEnum = _StubStatusEnum
monkeypatch.setitem(sys.modules, "common.constants", common_constants_mod)
@@ -213,8 +242,42 @@ def _load_chat_module(monkeypatch):
return [], 0
dialog_service_mod.DialogService = _StubDialogService
dialog_service_mod.async_ask = lambda *_args, **_kwargs: None
dialog_service_mod.async_chat = lambda *_args, **_kwargs: None
dialog_service_mod.gen_mindmap = lambda *_args, **_kwargs: None
monkeypatch.setitem(sys.modules, "api.db.services.dialog_service", dialog_service_mod)
conversation_service_mod = ModuleType("api.db.services.conversation_service")
class _StubConversationService:
@staticmethod
def query(**_kwargs):
return []
@staticmethod
def get_list(*_args, **_kwargs):
return []
@staticmethod
def get_by_id(_session_id):
return False, None
@staticmethod
def update_by_id(_session_id, _payload):
return True
@staticmethod
def delete_by_id(_session_id):
return True
@staticmethod
def save(**_kwargs):
return True
conversation_service_mod.ConversationService = _StubConversationService
conversation_service_mod.structure_answer = lambda *_args, **_kwargs: {}
monkeypatch.setitem(sys.modules, "api.db.services.conversation_service", conversation_service_mod)
kb_service_mod = ModuleType("api.db.services.knowledgebase_service")
class _StubKnowledgebaseService:
@@ -253,6 +316,24 @@ def _load_chat_module(monkeypatch):
tenant_llm_service_mod.TenantLLMService = _StubTenantLLMService
monkeypatch.setitem(sys.modules, "api.db.services.tenant_llm_service", tenant_llm_service_mod)
llm_service_mod = ModuleType("api.db.services.llm_service")
class _StubLLMBundle:
def __init__(self, *_args, **_kwargs):
pass
llm_service_mod.LLMBundle = _StubLLMBundle
monkeypatch.setitem(sys.modules, "api.db.services.llm_service", llm_service_mod)
search_service_mod = ModuleType("api.db.services.search_service")
search_service_mod.SearchService = SimpleNamespace()
monkeypatch.setitem(sys.modules, "api.db.services.search_service", search_service_mod)
tenant_model_service_mod = ModuleType("api.db.joint_services.tenant_model_service")
tenant_model_service_mod.get_model_config_by_type_and_name = lambda *_args, **_kwargs: {}
tenant_model_service_mod.get_tenant_default_model_by_type = lambda *_args, **_kwargs: {}
monkeypatch.setitem(sys.modules, "api.db.joint_services.tenant_model_service", tenant_model_service_mod)
user_service_mod = ModuleType("api.db.services.user_service")
class _StubTenantService:
@@ -283,12 +364,29 @@ def _load_chat_module(monkeypatch):
api_utils_mod.get_json_result = lambda data=None, message="", code=0: {"code": code, "data": data, "message": message}
api_utils_mod.get_request_json = lambda: _AwaitableValue({})
api_utils_mod.server_error_response = lambda ex: {"code": 500, "data": None, "message": str(ex)}
api_utils_mod.validate_request = lambda *_args, **_kwargs: (lambda func: func)
monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod)
tenant_utils_mod = ModuleType("api.utils.tenant_utils")
tenant_utils_mod.ensure_tenant_model_id_for_params = lambda _tenant_id, req: req
monkeypatch.setitem(sys.modules, "api.utils.tenant_utils", tenant_utils_mod)
rag_pkg = ModuleType("rag")
rag_pkg.__path__ = [str(repo_root / "rag")]
monkeypatch.setitem(sys.modules, "rag", rag_pkg)
rag_prompts_pkg = ModuleType("rag.prompts")
rag_prompts_pkg.__path__ = [str(repo_root / "rag" / "prompts")]
monkeypatch.setitem(sys.modules, "rag.prompts", rag_prompts_pkg)
rag_prompts_generator_mod = ModuleType("rag.prompts.generator")
rag_prompts_generator_mod.chunks_format = lambda reference: reference.get("chunks", []) if isinstance(reference, dict) else []
monkeypatch.setitem(sys.modules, "rag.prompts.generator", rag_prompts_generator_mod)
rag_prompts_template_mod = ModuleType("rag.prompts.template")
rag_prompts_template_mod.load_prompt = lambda *_args, **_kwargs: ""
monkeypatch.setitem(sys.modules, "rag.prompts.template", rag_prompts_template_mod)
spec = importlib.util.spec_from_file_location(module_name, module_path)
module = importlib.util.module_from_spec(spec)
module.manager = _DummyManager()
@@ -741,24 +839,148 @@ def test_list_chats_keeps_zero_pagination_semantics(monkeypatch):
assert calls[-1] == (0, 2)
assert len(res["data"]["chats"]) == 1
@pytest.mark.p2
def test_chat_session_create_and_update_guard_matrix_unit(monkeypatch):
module = _load_chat_module(monkeypatch)
_set_request_json(monkeypatch, module, {"name": "session"})
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [])
res = _run(module.create_session.__wrapped__("chat-1"))
assert res["message"] == "No authorization."
dia = SimpleNamespace(prompt_config={"prologue": "hello"})
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [dia])
monkeypatch.setattr(module.DialogService, "get_by_id", lambda _id: (True, dia))
monkeypatch.setattr(module.ConversationService, "save", lambda **_kwargs: None)
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (False, None))
res = _run(module.create_session.__wrapped__("chat-1"))
assert "Fail to create a session" in res["message"]
_set_request_json(monkeypatch, module, {})
monkeypatch.setattr(module.ConversationService, "query", lambda **_kwargs: [])
res = _run(module.update_session.__wrapped__("chat-1", "session-1"))
assert res["message"] == "Session not found!"
monkeypatch.setattr(module.ConversationService, "query", lambda **_kwargs: [SimpleNamespace(id="session-1")])
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [])
res = _run(module.update_session.__wrapped__("chat-1", "session-1"))
assert res["message"] == "No authorization."
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [SimpleNamespace(id="chat-1")])
_set_request_json(monkeypatch, module, {"message": []})
res = _run(module.update_session.__wrapped__("chat-1", "session-1"))
assert "`messages` cannot be changed." in res["message"]
_set_request_json(monkeypatch, module, {"reference": []})
res = _run(module.update_session.__wrapped__("chat-1", "session-1"))
assert "`reference` cannot be changed." in res["message"]
_set_request_json(monkeypatch, module, {"name": ""})
res = _run(module.update_session.__wrapped__("chat-1", "session-1"))
assert "`name` can not be empty." in res["message"]
_set_request_json(monkeypatch, module, {"name": "renamed"})
monkeypatch.setattr(module.ConversationService, "update_by_id", lambda *_args, **_kwargs: False)
res = _run(module.update_session.__wrapped__("chat-1", "session-1"))
assert res["message"] == "Session not found!"
@pytest.mark.p2
def test_chat_session_list_projection_unit(monkeypatch):
module = _load_chat_module(monkeypatch)
monkeypatch.setattr(
module,
"request",
SimpleNamespace(
args=SimpleNamespace(
get=lambda key, default=None: {
"keywords": "",
"page_size": 2,
"page": 1,
"page_size": 30,
"orderby": "create_time",
"desc": "true",
}.get(key, default),
getlist=lambda _key: [],
"id": None,
"name": None,
"user_id": None,
}.get(key, default)
)
),
)
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [SimpleNamespace(id="chat-1")])
monkeypatch.setattr(
module.ConversationService,
"get_list",
lambda *_args, **_kwargs: [
{
"id": "session-1",
"dialog_id": "chat-1",
"message": [{"role": "assistant", "content": "hello"}],
"reference": [],
}
],
)
res = module.list_chats.__wrapped__()
res = module.list_sessions.__wrapped__("chat-1")
assert res["data"][0]["chat_id"] == "chat-1"
assert res["data"][0]["messages"][0]["content"] == "hello"
monkeypatch.setattr(
module,
"request",
SimpleNamespace(
args=SimpleNamespace(
get=lambda key, default=None: {
"page": 1,
"page_size": 0,
"orderby": "create_time",
"desc": "true",
"id": None,
"name": None,
"user_id": None,
}.get(key, default)
)
),
)
res = module.list_sessions.__wrapped__("chat-1")
assert res["data"] == []
@pytest.mark.p2
def test_chat_session_delete_routes_partial_duplicate_unit(monkeypatch):
module = _load_chat_module(monkeypatch)
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [SimpleNamespace(id="chat-1")])
_set_request_json(monkeypatch, module, {})
res = _run(module.delete_sessions.__wrapped__("chat-1"))
assert res["code"] == 0
assert calls[-1] == (0, 2)
assert len(res["data"]["chats"]) == 1
monkeypatch.setattr(module.ConversationService, "delete_by_id", lambda *_args, **_kwargs: True)
def _conversation_query(**kwargs):
if "dialog_id" in kwargs and "id" not in kwargs:
return [SimpleNamespace(id="seed")]
if kwargs.get("id") == "ok":
return [SimpleNamespace(id="ok")]
return []
monkeypatch.setattr(module.ConversationService, "query", _conversation_query)
_set_request_json(monkeypatch, module, {"ids": ["ok", "bad"]})
monkeypatch.setattr(module, "check_duplicate_ids", lambda ids, _kind: (ids, []))
res = _run(module.delete_sessions.__wrapped__("chat-1"))
assert res["code"] == 0
assert res["data"]["success_count"] == 1
assert res["data"]["errors"] == ["The chat doesn't own the session bad"]
_set_request_json(monkeypatch, module, {"ids": ["bad"]})
monkeypatch.setattr(module, "check_duplicate_ids", lambda ids, _kind: (ids, []))
res = _run(module.delete_sessions.__wrapped__("chat-1"))
assert res["message"] == "The chat doesn't own the session bad"
_set_request_json(monkeypatch, module, {"ids": ["ok", "ok"]})
monkeypatch.setattr(module, "check_duplicate_ids", lambda ids, _kind: (["ok"], ["Duplicate session ids: ok"]))
res = _run(module.delete_sessions.__wrapped__("chat-1"))
assert res["code"] == 0
assert res["data"]["success_count"] == 1
assert res["data"]["errors"] == ["Duplicate session ids: ok"]

View File

@@ -26,12 +26,8 @@ class TestAuthorization:
@pytest.mark.parametrize(
"invalid_auth, expected_code, expected_message",
[
(None, 0, "`Authorization` can't be empty"),
(
RAGFlowHttpApiAuth(INVALID_API_TOKEN),
109,
"Authentication error: API key is invalid!",
),
(None, 401, "<Unauthorized '401: Unauthorized'>"),
(RAGFlowHttpApiAuth(INVALID_API_TOKEN), 401, "<Unauthorized '401: Unauthorized'>"),
],
)
def test_invalid_auth(self, invalid_auth, expected_code, expected_message):
@@ -74,7 +70,7 @@ class TestSessionWithChatAssistantCreate:
"chat_assistant_id, expected_code, expected_message",
[
("", 100, "<MethodNotAllowed '405: Method Not Allowed'>"),
("invalid_chat_assistant_id", 102, "You do not own the assistant."),
("invalid_chat_assistant_id", 109, "No authorization."),
],
)
def test_invalid_chat_assistant_id(self, HttpApiAuth, chat_assistant_id, expected_code, expected_message):
@@ -115,5 +111,5 @@ class TestSessionWithChatAssistantCreate:
res = delete_chat_assistants(HttpApiAuth, {"ids": [chat_assistant_ids[0]]})
assert res["code"] == 0
res = create_session_with_chat_assistant(HttpApiAuth, chat_assistant_ids[0], {"name": "valid_name"})
assert res["code"] == 102
assert res["message"] == "You do not own the assistant."
assert res["code"] == 109
assert res["message"] == "No authorization."

View File

@@ -26,12 +26,8 @@ class TestAuthorization:
@pytest.mark.parametrize(
"invalid_auth, expected_code, expected_message",
[
(None, 0, "`Authorization` can't be empty"),
(
RAGFlowHttpApiAuth(INVALID_API_TOKEN),
109,
"Authentication error: API key is invalid!",
),
(None, 401, "<Unauthorized '401: Unauthorized'>"),
(RAGFlowHttpApiAuth(INVALID_API_TOKEN), 401, "<Unauthorized '401: Unauthorized'>"),
],
)
def test_invalid_auth(self, invalid_auth, expected_code, expected_message):
@@ -48,8 +44,8 @@ class TestSessionWithChatAssistantDelete:
("", 100, "<MethodNotAllowed '405: Method Not Allowed'>"),
(
"invalid_chat_assistant_id",
102,
"You don't own the chat",
109,
"No authorization.",
),
],
)
@@ -146,6 +142,7 @@ class TestSessionWithChatAssistantDelete:
pytest.param("not json", 100, """AttributeError("\'str\' object has no attribute \'get\'")""", 5, marks=pytest.mark.skip),
pytest.param(lambda r: {"ids": r[:1]}, 0, "", 4, marks=pytest.mark.p3),
pytest.param(lambda r: {"ids": r}, 0, "", 0, marks=pytest.mark.p1),
pytest.param({"delete_all": True}, 0, "", 0, marks=pytest.mark.p1),
pytest.param({"ids": []}, 0, "", 5, marks=pytest.mark.p3),
],
)

View File

@@ -27,12 +27,8 @@ class TestAuthorization:
@pytest.mark.parametrize(
"invalid_auth, expected_code, expected_message",
[
(None, 0, "`Authorization` can't be empty"),
(
RAGFlowHttpApiAuth(INVALID_API_TOKEN),
109,
"Authentication error: API key is invalid!",
),
(None, 401, "<Unauthorized '401: Unauthorized'>"),
(RAGFlowHttpApiAuth(INVALID_API_TOKEN), 401, "<Unauthorized '401: Unauthorized'>"),
],
)
def test_invalid_auth(self, invalid_auth, expected_code, expected_message):
@@ -246,5 +242,5 @@ class TestSessionsWithChatAssistantList:
assert res["code"] == 0
res = list_session_with_chat_assistants(HttpApiAuth, chat_assistant_id)
assert res["code"] == 102
assert "You don't own the assistant" in res["message"]
assert res["code"] == 109
assert res["message"] == "No authorization."

View File

@@ -530,18 +530,7 @@ def _load_session_module(monkeypatch):
def test_create_and_update_guard_matrix(monkeypatch):
module = _load_session_module(monkeypatch)
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"name": "session"}))
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [])
res = _run(inspect.unwrap(module.create)("tenant-1", "chat-1"))
assert res["message"] == "You do not own the assistant."
dia = SimpleNamespace(prompt_config={"prologue": "hello"})
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [dia])
monkeypatch.setattr(module.ConversationService, "save", lambda **_kwargs: None)
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (False, None))
res = _run(inspect.unwrap(module.create)("tenant-1", "chat-1"))
assert "Fail to create a session" in res["message"]
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({}))
monkeypatch.setattr(module, "request", SimpleNamespace(args=_Args()))
monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: [SimpleNamespace(id="agent-1")])
@@ -556,34 +545,6 @@ def test_create_and_update_guard_matrix(monkeypatch):
res = _run(inspect.unwrap(module.create_agent_session)("tenant-1", "agent-1"))
assert res["message"] == "You cannot access the agent."
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({}))
monkeypatch.setattr(module.ConversationService, "query", lambda **_kwargs: [])
res = _run(inspect.unwrap(module.update)("tenant-1", "chat-1", "session-1"))
assert res["message"] == "Session does not exist"
monkeypatch.setattr(module.ConversationService, "query", lambda **_kwargs: [SimpleNamespace(id="session-1")])
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [])
res = _run(inspect.unwrap(module.update)("tenant-1", "chat-1", "session-1"))
assert res["message"] == "You do not own the session"
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [SimpleNamespace(id="chat-1")])
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"message": []}))
res = _run(inspect.unwrap(module.update)("tenant-1", "chat-1", "session-1"))
assert "`message` can not be change" in res["message"]
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"reference": []}))
res = _run(inspect.unwrap(module.update)("tenant-1", "chat-1", "session-1"))
assert "`reference` can not be change" in res["message"]
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"name": ""}))
res = _run(inspect.unwrap(module.update)("tenant-1", "chat-1", "session-1"))
assert "`name` can not be empty" in res["message"]
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"name": "renamed"}))
monkeypatch.setattr(module.ConversationService, "update_by_id", lambda *_args, **_kwargs: False)
res = _run(inspect.unwrap(module.update)("tenant-1", "chat-1", "session-1"))
assert res["message"] == "Session updates error"
@pytest.mark.p2
def test_chat_completion_metadata_and_stream_paths(monkeypatch):
@@ -929,44 +890,6 @@ def test_agent_completions_stream_and_nonstream_unit(monkeypatch):
assert res["data"].startswith("**ERROR**")
@pytest.mark.p2
def test_list_session_projection_unit(monkeypatch):
module = _load_session_module(monkeypatch)
monkeypatch.setattr(module, "request", SimpleNamespace(args=_Args({})))
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [SimpleNamespace(id="chat-1")])
convs = [
{
"id": "session-1",
"dialog_id": "chat-1",
"message": [{"role": "assistant", "content": "hello", "prompt": "internal"}],
"reference": [
{
"chunks": [
{
"chunk_id": "chunk-1",
"content_with_weight": "weighted",
"doc_id": "doc-1",
"docnm_kwd": "doc-name",
"kb_id": "kb-1",
"image_id": "img-1",
"positions": [1, 2],
}
]
}
],
}
]
monkeypatch.setattr(module.ConversationService, "get_list", lambda *_args, **_kwargs: convs)
res = _run(inspect.unwrap(module.list_session)("tenant-1", "chat-1"))
assert res["data"][0]["chat_id"] == "chat-1"
assert "reference" not in res["data"][0]
assert "prompt" not in res["data"][0]["messages"][0]
assert res["data"][0]["messages"][0]["reference"][0]["positions"] == [1, 2]
@pytest.mark.p2
def test_list_agent_session_projection_unit(monkeypatch):
module = _load_session_module(monkeypatch)
@@ -1020,41 +943,6 @@ def test_list_agent_session_projection_unit(monkeypatch):
def test_delete_routes_partial_duplicate_unit(monkeypatch):
module = _load_session_module(monkeypatch)
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [SimpleNamespace(id="chat-1")])
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({}))
res = _run(inspect.unwrap(module.delete)("tenant-1", "chat-1"))
assert res["code"] == 0
monkeypatch.setattr(module.ConversationService, "delete_by_id", lambda *_args, **_kwargs: True)
def _conversation_query(**kwargs):
if "id" not in kwargs:
return [SimpleNamespace(id="seed")]
if kwargs["id"] == "ok":
return [SimpleNamespace(id="ok")]
return []
monkeypatch.setattr(module.ConversationService, "query", _conversation_query)
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"ids": ["ok", "bad"]}))
monkeypatch.setattr(module, "check_duplicate_ids", lambda ids, _kind: (ids, []))
res = _run(inspect.unwrap(module.delete)("tenant-1", "chat-1"))
assert res["code"] == 0
assert res["data"]["success_count"] == 1
assert res["data"]["errors"] == ["The chat doesn't own the session bad"]
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"ids": ["bad"]}))
monkeypatch.setattr(module, "check_duplicate_ids", lambda ids, _kind: (ids, []))
res = _run(inspect.unwrap(module.delete)("tenant-1", "chat-1"))
assert res["message"] == "The chat doesn't own the session bad"
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"ids": ["ok", "ok"]}))
monkeypatch.setattr(module, "check_duplicate_ids", lambda ids, _kind: (["ok"], ["Duplicate session ids: ok"]))
res = _run(inspect.unwrap(module.delete)("tenant-1", "chat-1"))
assert res["code"] == 0
assert res["data"]["success_count"] == 1
assert res["data"]["errors"] == ["Duplicate session ids: ok"]
monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: [SimpleNamespace(id="agent-1")])
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({}))
res = _run(inspect.unwrap(module.delete_agent_session)("tenant-1", "agent-1"))

View File

@@ -27,12 +27,8 @@ class TestAuthorization:
@pytest.mark.parametrize(
"invalid_auth, expected_code, expected_message",
[
(None, 0, "`Authorization` can't be empty"),
(
RAGFlowHttpApiAuth(INVALID_API_TOKEN),
109,
"Authentication error: API key is invalid!",
),
(None, 401, "<Unauthorized '401: Unauthorized'>"),
(RAGFlowHttpApiAuth(INVALID_API_TOKEN), 401, "<Unauthorized '401: Unauthorized'>"),
],
)
def test_invalid_auth(self, invalid_auth, expected_code, expected_message):
@@ -72,7 +68,7 @@ class TestSessionWithChatAssistantUpdate:
@pytest.mark.parametrize(
"chat_assistant_id, expected_code, expected_message",
[
(INVALID_ID_32, 102, "Session does not exist"),
(INVALID_ID_32, 109, "No authorization."),
],
)
def test_invalid_chat_assistant_id(self, HttpApiAuth, add_sessions_with_chat_assistant_func, chat_assistant_id, expected_code, expected_message):
@@ -86,7 +82,7 @@ class TestSessionWithChatAssistantUpdate:
"session_id, expected_code, expected_message",
[
("", 100, "<MethodNotAllowed '405: Method Not Allowed'>"),
("invalid_session_id", 102, "Session does not exist"),
("invalid_session_id", 102, "Session not found!"),
],
)
def test_invalid_session_id(self, HttpApiAuth, add_sessions_with_chat_assistant_func, session_id, expected_code, expected_message):
@@ -145,5 +141,5 @@ class TestSessionWithChatAssistantUpdate:
chat_assistant_id, session_ids = add_sessions_with_chat_assistant_func
delete_chat_assistants(HttpApiAuth, {"ids": [chat_assistant_id]})
res = update_session_with_chat_assistant(HttpApiAuth, chat_assistant_id, session_ids[0], {"name": "valid_name"})
assert res["code"] == 102
assert res["message"] == "You do not own the session"
assert res["code"] == 109
assert res["message"] == "No authorization."

View File

@@ -0,0 +1,87 @@
#
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import pytest
from ragflow_sdk import RAGFlow
from ragflow_sdk.modules.chat import Chat
from ragflow_sdk.modules.session import Session
class _DummyResponse:
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
@pytest.fixture(scope="session")
def auth():
return "unit-auth"
@pytest.fixture(scope="session", autouse=True)
def set_tenant_info():
return None
@pytest.mark.p2
def test_chat_create_session_raises_server_error_message(monkeypatch):
client = RAGFlow("token", "http://localhost:9380")
chat = Chat(client, {"id": "chat-1"})
monkeypatch.setattr(
chat,
"post",
lambda *_args, **_kwargs: _DummyResponse({"code": 102, "message": "`name` can not be empty."}),
)
with pytest.raises(Exception) as exception_info:
chat.create_session(name="")
assert "`name` can not be empty." in str(exception_info.value), str(exception_info.value)
@pytest.mark.p2
def test_chat_list_sessions_forwards_restful_query_params(monkeypatch):
client = RAGFlow("token", "http://localhost:9380")
chat = Chat(client, {"id": "chat-1"})
calls = []
def _ok_get(path, params=None):
calls.append((path, params))
return _DummyResponse(
{
"code": 0,
"data": [
{"id": "session-1", "chat_id": "chat-1", "name": "one"},
{"id": "session-2", "chat_id": "chat-1", "name": "two"},
],
}
)
monkeypatch.setattr(chat, "get", _ok_get)
sessions = chat.list_sessions(page=2, page_size=2, orderby="create_time", desc=False, id="session-1", name="one", user_id="user-1")
assert len(sessions) == 2, str(sessions)
assert all(isinstance(item, Session) for item in sessions), str(sessions)
assert calls[-1][0] == "/chats/chat-1/sessions"
assert calls[-1][1]["page_size"] == 2
assert calls[-1][1]["name"] == "one"
assert calls[-1][1]["user_id"] == "user-1"
all_sessions = chat.list_sessions(page_size=0)
assert len(all_sessions) == 2, str(all_sessions)
assert calls[-1][1]["page_size"] == 0

View File

@@ -1,801 +0,0 @@
#
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import asyncio
import importlib.util
import sys
from copy import deepcopy
from pathlib import Path
from types import ModuleType, SimpleNamespace
import pytest
from anyio import Path as AsyncPath
class _DummyManager:
def route(self, *_args, **_kwargs):
def decorator(func):
return func
return decorator
class _AwaitableValue:
def __init__(self, value):
self._value = value
def __await__(self):
async def _co():
return self._value
return _co().__await__()
class _DummyRequest:
def __init__(self, *, args=None, headers=None, form=None, files=None):
self.args = args or {}
self.headers = headers or {}
self.form = _AwaitableValue(form or {})
self.files = _AwaitableValue(files or {})
self.method = "POST"
self.content_length = 0
class _DummyConversation:
def __init__(self, *, conv_id="conv-1", dialog_id="dialog-1", message=None, reference=None):
self.id = conv_id
self.dialog_id = dialog_id
self.message = message if message is not None else []
self.reference = reference if reference is not None else []
def to_dict(self):
return {
"id": self.id,
"dialog_id": self.dialog_id,
"message": deepcopy(self.message),
"reference": deepcopy(self.reference),
}
class _DummyDialog:
def __init__(self, *, dialog_id="dialog-1", tenant_id="tenant-1", icon="avatar.png"):
self.id = dialog_id
self.tenant_id = tenant_id
self.icon = icon
self.prompt_config = {"prologue": "hello"}
self.llm_id = ""
self.llm_setting = {}
def to_dict(self):
return {
"id": self.id,
"icon": self.icon,
"tenant_id": self.tenant_id,
"prompt_config": deepcopy(self.prompt_config),
}
class _DummyUploadedFile:
def __init__(self, filename):
self.filename = filename
self.saved_path = None
async def save(self, path):
self.saved_path = path
await AsyncPath(path).write_bytes(b"audio-bytes")
def _run(coro):
return asyncio.run(coro)
def _load_conversation_module(monkeypatch):
repo_root = Path(__file__).resolve().parents[4]
common_pkg = ModuleType("common")
common_pkg.__path__ = [str(repo_root / "common")]
monkeypatch.setitem(sys.modules, "common", common_pkg)
deepdoc_pkg = ModuleType("deepdoc")
deepdoc_parser_pkg = ModuleType("deepdoc.parser")
deepdoc_parser_pkg.__path__ = []
class _StubPdfParser:
pass
class _StubExcelParser:
pass
class _StubDocxParser:
pass
deepdoc_parser_pkg.PdfParser = _StubPdfParser
deepdoc_parser_pkg.ExcelParser = _StubExcelParser
deepdoc_parser_pkg.DocxParser = _StubDocxParser
deepdoc_pkg.parser = deepdoc_parser_pkg
monkeypatch.setitem(sys.modules, "deepdoc", deepdoc_pkg)
monkeypatch.setitem(sys.modules, "deepdoc.parser", deepdoc_parser_pkg)
deepdoc_excel_module = ModuleType("deepdoc.parser.excel_parser")
deepdoc_excel_module.RAGFlowExcelParser = _StubExcelParser
monkeypatch.setitem(sys.modules, "deepdoc.parser.excel_parser", deepdoc_excel_module)
deepdoc_parser_utils = ModuleType("deepdoc.parser.utils")
deepdoc_parser_utils.get_text = lambda *_args, **_kwargs: ""
monkeypatch.setitem(sys.modules, "deepdoc.parser.utils", deepdoc_parser_utils)
monkeypatch.setitem(sys.modules, "xgboost", ModuleType("xgboost"))
apps_mod = ModuleType("api.apps")
apps_mod.current_user = SimpleNamespace(id="user-1")
apps_mod.login_required = lambda func: func
monkeypatch.setitem(sys.modules, "api.apps", apps_mod)
# Create user_service module with TenantService stub if not already exists
if "api.db.services.user_service" not in sys.modules:
user_service_mod = ModuleType("api.db.services.user_service")
user_service_mod.UserService = SimpleNamespace() # Dummy UserService class
user_service_mod.TenantService = SimpleNamespace(
get_info_by=lambda _uid: [],
get_by_id=lambda _uid: (False, None)
)
user_service_mod.UserTenantService = SimpleNamespace(
query=lambda **_kwargs: []
)
monkeypatch.setitem(sys.modules, "api.db.services.user_service", user_service_mod)
module_name = "test_conversation_routes_unit_module"
module_path = repo_root / "api" / "apps" / "conversation_app.py"
spec = importlib.util.spec_from_file_location(module_name, module_path)
module = importlib.util.module_from_spec(spec)
module.manager = _DummyManager()
monkeypatch.setitem(sys.modules, module_name, module)
spec.loader.exec_module(module)
return module
def _set_request_json(monkeypatch, module, payload):
monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue(deepcopy(payload)))
async def _read_sse_text(response):
chunks = []
async for chunk in response.response:
if isinstance(chunk, bytes):
chunks.append(chunk.decode("utf-8"))
else:
chunks.append(chunk)
return "".join(chunks)
@pytest.fixture(scope="session")
def auth():
return "unit-auth"
@pytest.fixture(scope="session", autouse=True)
def set_tenant_info():
return None
@pytest.mark.p2
def test_set_conversation_update_create_and_errors(monkeypatch):
module = _load_conversation_module(monkeypatch)
long_name = "n" * 300
create_payload = {
"conversation_id": "conv-new",
"dialog_id": "dialog-1",
"is_new": True,
"name": long_name,
}
_set_request_json(monkeypatch, module, create_payload)
saved = {}
monkeypatch.setattr(module.DialogService, "get_by_id", lambda _id: (True, _DummyDialog()))
monkeypatch.setattr(module.ConversationService, "save", lambda **kwargs: saved.update(kwargs) or True)
res = _run(module.set_conversation())
assert res["code"] == 0
assert len(res["data"]["name"]) == 255
assert saved["user_id"] == "user-1"
update_payload = {
"conversation_id": "conv-1",
"dialog_id": "dialog-1",
"is_new": False,
"name": "rename",
}
_set_request_json(monkeypatch, module, update_payload)
monkeypatch.setattr(module.ConversationService, "update_by_id", lambda *_args, **_kwargs: False)
res = _run(module.set_conversation())
assert "Conversation not found" in res["message"]
_set_request_json(monkeypatch, module, update_payload)
monkeypatch.setattr(module.ConversationService, "update_by_id", lambda *_args, **_kwargs: True)
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (False, None))
res = _run(module.set_conversation())
assert "Fail to update" in res["message"]
_set_request_json(monkeypatch, module, update_payload)
monkeypatch.setattr(module.ConversationService, "update_by_id", lambda *_args, **_kwargs: True)
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (True, _DummyConversation(conv_id="conv-1")))
res = _run(module.set_conversation())
assert res["code"] == 0
assert res["data"]["id"] == "conv-1"
_set_request_json(monkeypatch, module, update_payload)
def _raise_update(*_args, **_kwargs):
raise RuntimeError("update boom")
monkeypatch.setattr(module.ConversationService, "update_by_id", _raise_update)
res = _run(module.set_conversation())
assert res["code"] == module.RetCode.EXCEPTION_ERROR
assert "update boom" in res["message"]
missing_dialog_payload = {
"conversation_id": "conv-2",
"dialog_id": "dialog-missing",
"is_new": True,
"name": "create",
}
_set_request_json(monkeypatch, module, missing_dialog_payload)
monkeypatch.setattr(module.DialogService, "get_by_id", lambda _id: (False, None))
res = _run(module.set_conversation())
assert res["message"] == "Dialog not found"
_set_request_json(monkeypatch, module, missing_dialog_payload)
def _raise_dialog(_id):
raise RuntimeError("dialog boom")
monkeypatch.setattr(module.DialogService, "get_by_id", _raise_dialog)
res = _run(module.set_conversation())
assert res["code"] == module.RetCode.EXCEPTION_ERROR
assert "dialog boom" in res["message"]
@pytest.mark.p2
def test_get_and_getsse_authorization_and_reference_paths(monkeypatch):
module = _load_conversation_module(monkeypatch)
conv = _DummyConversation(reference=[{"doc": "d"}, ["already-formatted"]])
monkeypatch.setattr(module, "request", _DummyRequest(args={"conversation_id": "conv-1"}))
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (True, conv))
monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-1")])
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [SimpleNamespace(icon="bot-avatar")])
monkeypatch.setattr(module, "chunks_format", lambda _ref: [{"chunk": "normalized"}])
res = _run(module.get())
assert res["code"] == 0
assert res["data"]["avatar"] == "bot-avatar"
assert res["data"]["reference"][0]["chunks"] == [{"chunk": "normalized"}]
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (False, None))
res = _run(module.get())
assert res["message"] == "Conversation not found!"
monkeypatch.setattr(module, "request", _DummyRequest(args={"conversation_id": "conv-1"}))
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (True, conv))
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [])
res = _run(module.get())
assert res["code"] == module.RetCode.OPERATING_ERROR
assert "Only owner of conversation" in res["message"]
def _raise_get(*_args, **_kwargs):
raise RuntimeError("get boom")
monkeypatch.setattr(module.ConversationService, "get_by_id", _raise_get)
res = _run(module.get())
assert res["code"] == module.RetCode.EXCEPTION_ERROR
assert "get boom" in res["message"]
monkeypatch.setattr(module, "request", _DummyRequest(headers={"Authorization": "Bearer"}))
res = module.getsse("dialog-1")
assert "Authorization is not valid" in res["message"]
monkeypatch.setattr(module, "request", _DummyRequest(headers={"Authorization": "Bearer token-1"}))
monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [])
res = module.getsse("dialog-1")
assert "API key is invalid" in res["message"]
monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [SimpleNamespace()])
monkeypatch.setattr(module.DialogService, "get_by_id", lambda _id: (False, None))
res = module.getsse("dialog-1")
assert res["message"] == "Dialog not found!"
monkeypatch.setattr(module.DialogService, "get_by_id", lambda _id: (True, _DummyDialog()))
res = module.getsse("dialog-1")
assert res["code"] == 0
assert res["data"]["avatar"] == "avatar.png"
assert "icon" not in res["data"]
def _raise_getsse(_id):
raise RuntimeError("getsse boom")
monkeypatch.setattr(module.DialogService, "get_by_id", _raise_getsse)
res = module.getsse("dialog-1")
assert res["code"] == module.RetCode.EXCEPTION_ERROR
assert "getsse boom" in res["message"]
@pytest.mark.p2
def test_rm_and_list_conversation_guards(monkeypatch):
module = _load_conversation_module(monkeypatch)
_set_request_json(monkeypatch, module, {"conversation_ids": ["conv-1"]})
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (False, None))
res = _run(module.rm())
assert "Conversation not found" in res["message"]
conv = _DummyConversation(conv_id="conv-1", dialog_id="dialog-1")
_set_request_json(monkeypatch, module, {"conversation_ids": ["conv-1"]})
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (True, conv))
monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-1")])
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [])
res = _run(module.rm())
assert res["code"] == module.RetCode.OPERATING_ERROR
deleted = []
_set_request_json(monkeypatch, module, {"conversation_ids": ["conv-1"]})
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [SimpleNamespace(id="dialog-1")])
monkeypatch.setattr(module.ConversationService, "delete_by_id", lambda cid: deleted.append(cid) or True)
res = _run(module.rm())
assert res["code"] == 0
assert res["data"] is True
assert deleted == ["conv-1"]
_set_request_json(monkeypatch, module, {"conversation_ids": ["conv-1"]})
def _raise_rm(*_args, **_kwargs):
raise RuntimeError("rm boom")
monkeypatch.setattr(module.ConversationService, "get_by_id", _raise_rm)
res = _run(module.rm())
assert res["code"] == module.RetCode.EXCEPTION_ERROR
assert "rm boom" in res["message"]
monkeypatch.setattr(module, "request", _DummyRequest(args={"dialog_id": "dialog-1"}))
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [])
res = _run(module.list_conversation())
assert res["code"] == module.RetCode.OPERATING_ERROR
assert "Only owner of dialog" in res["message"]
monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [SimpleNamespace(id="dialog-1")])
monkeypatch.setattr(module.ConversationService, "model", SimpleNamespace(create_time="create_time"))
monkeypatch.setattr(module.ConversationService, "query", lambda **_kwargs: [_DummyConversation(conv_id="c1"), _DummyConversation(conv_id="c2")])
res = _run(module.list_conversation())
assert res["code"] == 0
assert [x["id"] for x in res["data"]] == ["c1", "c2"]
def _raise_list(**_kwargs):
raise RuntimeError("list boom")
monkeypatch.setattr(module.ConversationService, "query", _raise_list)
res = _run(module.list_conversation())
assert res["code"] == module.RetCode.EXCEPTION_ERROR
assert "list boom" in res["message"]
@pytest.mark.p2
def test_completion_stream_and_nonstream_branches(monkeypatch):
module = _load_conversation_module(monkeypatch)
conv = _DummyConversation(conv_id="conv-1", dialog_id="dialog-1", reference=[])
dia = _DummyDialog(dialog_id="dialog-1", tenant_id="tenant-1")
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (True, conv))
monkeypatch.setattr(module.DialogService, "get_by_id", lambda _id: (True, dia))
monkeypatch.setattr(module, "structure_answer", lambda _conv, ans, message_id, conv_id: {"answer": ans["answer"], "id": message_id, "conversation_id": conv_id, "reference": []})
updates = []
monkeypatch.setattr(module.ConversationService, "update_by_id", lambda conv_id, payload: updates.append((conv_id, payload)) or True)
stream_payload = {
"conversation_id": "conv-1",
"messages": [
{"role": "system", "content": "ignored"},
{"role": "assistant", "content": "ignored-first-assistant"},
{"role": "user", "content": "hello", "id": "m-1"},
],
"stream": True,
}
async def _stream_ok(_dia, sanitized, *_args, **_kwargs):
assert [m["role"] for m in sanitized] == ["user"]
yield {"answer": "sse-ok"}
monkeypatch.setattr(module, "async_chat", _stream_ok)
_set_request_json(monkeypatch, module, stream_payload)
resp = _run(module.completion.__wrapped__())
assert resp.headers["Content-Type"].startswith("text/event-stream")
sse_text = _run(_read_sse_text(resp))
assert "sse-ok" in sse_text
assert '"data": true' in sse_text
assert updates
async def _stream_error(_dia, _sanitized, *_args, **_kwargs):
raise RuntimeError("stream explode")
if False:
yield {"answer": "never"}
monkeypatch.setattr(module, "async_chat", _stream_error)
_set_request_json(monkeypatch, module, stream_payload)
resp = _run(module.completion.__wrapped__())
sse_text = _run(_read_sse_text(resp))
assert "**ERROR**: stream explode" in sse_text
async def _non_stream(_dia, _sanitized, **_kwargs):
yield {"answer": "plain-ok"}
monkeypatch.setattr(module, "async_chat", _non_stream)
_set_request_json(
monkeypatch,
module,
{
"conversation_id": "conv-1",
"messages": [{"role": "user", "content": "plain", "id": "m-2"}],
"stream": False,
},
)
res = _run(module.completion.__wrapped__())
assert res["code"] == 0
assert res["data"]["answer"] == "plain-ok"
monkeypatch.setattr(module.TenantLLMService, "get_api_key", lambda **_kwargs: False)
_set_request_json(
monkeypatch,
module,
{
"conversation_id": "conv-1",
"messages": [{"role": "user", "content": "embed", "id": "m-3"}],
"llm_id": "bad-model",
"stream": False,
},
)
res = _run(module.completion.__wrapped__())
assert "Cannot use specified model bad-model" in res["message"]
monkeypatch.setattr(module.TenantLLMService, "get_api_key", lambda **_kwargs: "api-key")
_set_request_json(
monkeypatch,
module,
{
"conversation_id": "conv-1",
"messages": [{"role": "user", "content": "embed", "id": "m-4"}],
"llm_id": "glm-4",
"temperature": 0.7,
"top_p": 0.2,
"stream": False,
},
)
res = _run(module.completion.__wrapped__())
assert res["code"] == 0
assert dia.llm_id == "glm-4"
assert dia.llm_setting == {"temperature": 0.7, "top_p": 0.2}
_set_request_json(
monkeypatch,
module,
{
"conversation_id": "missing",
"messages": [{"role": "user", "content": "x", "id": "m-5"}],
"stream": False,
},
)
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (False, None))
res = _run(module.completion.__wrapped__())
assert res["message"] == "Conversation not found!"
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (True, conv))
monkeypatch.setattr(module.DialogService, "get_by_id", lambda _id: (False, None))
_set_request_json(
monkeypatch,
module,
{
"conversation_id": "conv-1",
"messages": [{"role": "user", "content": "x", "id": "m-6"}],
"stream": False,
},
)
res = _run(module.completion.__wrapped__())
assert res["message"] == "Dialog not found!"
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (_ for _ in ()).throw(RuntimeError("completion boom")))
_set_request_json(
monkeypatch,
module,
{
"conversation_id": "conv-1",
"messages": [{"role": "user", "content": "x", "id": "m-7"}],
"stream": False,
},
)
res = _run(module.completion.__wrapped__())
assert res["code"] == module.RetCode.EXCEPTION_ERROR
assert "completion boom" in res["message"]
@pytest.mark.p2
def test_sequence2txt_validation_and_transcription_paths(monkeypatch):
module = _load_conversation_module(monkeypatch)
monkeypatch.setattr(module, "request", _DummyRequest(form={"stream": "false"}, files={}))
res = _run(module.sequence2txt())
assert "Missing 'file'" in res["message"]
bad_file = _DummyUploadedFile("audio.txt")
monkeypatch.setattr(module, "request", _DummyRequest(form={"stream": "false"}, files={"file": bad_file}))
res = _run(module.sequence2txt())
assert "Unsupported audio format" in res["message"]
wav_file = _DummyUploadedFile("audio.wav")
monkeypatch.setattr(module, "request", _DummyRequest(form={"stream": "false"}, files={"file": wav_file}))
monkeypatch.setattr(module, "get_tenant_default_model_by_type", lambda *_args, **_kwargs: (_ for _ in ()).throw(LookupError("Tenant not found")))
res = _run(module.sequence2txt())
assert res["message"] == "Tenant not found"
wav_file = _DummyUploadedFile("audio.wav")
monkeypatch.setattr(module, "request", _DummyRequest(form={"stream": "false"}, files={"file": wav_file}))
monkeypatch.setattr(module, "get_tenant_default_model_by_type", lambda *_args, **_kwargs: (_ for _ in ()).throw(Exception("No default speech2text model is set.")))
res = _run(module.sequence2txt())
assert res["message"] == "No default speech2text model is set."
class _SyncAsr:
def transcription(self, _path):
return "transcribed text"
def stream_transcription(self, _path):
return []
wav_file = _DummyUploadedFile("audio.wav")
monkeypatch.setattr(module, "request", _DummyRequest(form={"stream": "false"}, files={"file": wav_file}))
monkeypatch.setattr(
module,
"get_tenant_default_model_by_type",
lambda *_args, **_kwargs: {"llm_factory": "test", "llm_name": "asr-model", "model_type": module.LLMType.SPEECH2TEXT.value},
)
monkeypatch.setattr(module, "LLMBundle", lambda *_args, **_kwargs: _SyncAsr())
monkeypatch.setattr(module.os, "remove", lambda _path: (_ for _ in ()).throw(RuntimeError("remove failed")))
res = _run(module.sequence2txt())
assert res["code"] == 0
assert res["data"]["text"] == "transcribed text"
class _StreamAsr:
def transcription(self, _path):
return ""
def stream_transcription(self, _path):
yield {"event": "partial", "text": "hello"}
wav_file = _DummyUploadedFile("audio.wav")
monkeypatch.setattr(module, "request", _DummyRequest(form={"stream": "true"}, files={"file": wav_file}))
monkeypatch.setattr(module, "LLMBundle", lambda *_args, **_kwargs: _StreamAsr())
resp = _run(module.sequence2txt())
assert resp.headers["Content-Type"].startswith("text/event-stream")
sse_text = _run(_read_sse_text(resp))
assert '"event": "partial"' in sse_text
class _ErrorStreamAsr:
def transcription(self, _path):
return ""
def stream_transcription(self, _path):
raise RuntimeError("stream asr boom")
wav_file = _DummyUploadedFile("audio.wav")
monkeypatch.setattr(module, "request", _DummyRequest(form={"stream": "true"}, files={"file": wav_file}))
monkeypatch.setattr(module, "LLMBundle", lambda *_args, **_kwargs: _ErrorStreamAsr())
resp = _run(module.sequence2txt())
sse_text = _run(_read_sse_text(resp))
assert "stream asr boom" in sse_text
@pytest.mark.p2
def test_tts_request_parse_entry(monkeypatch):
module = _load_conversation_module(monkeypatch)
_set_request_json(monkeypatch, module, {"text": "A。B"})
monkeypatch.setattr(module, "get_tenant_default_model_by_type", lambda *_args, **_kwargs: (_ for _ in ()).throw(LookupError("Tenant not found")))
res = _run(module.tts())
assert res["message"] == "Tenant not found"
monkeypatch.setattr(module, "get_tenant_default_model_by_type", lambda *_args, **_kwargs: (_ for _ in ()).throw(Exception("No default tts model is set.")))
res = _run(module.tts())
assert res["message"] == "No default tts model is set."
class _TTSOk:
def tts(self, txt):
if not txt:
return []
yield f"chunk-{txt}".encode("utf-8")
monkeypatch.setattr(
module,
"get_tenant_default_model_by_type",
lambda *_args, **_kwargs: {"llm_factory": "test", "llm_name": "tts-x", "model_type": module.LLMType.TTS.value},
)
monkeypatch.setattr(module, "LLMBundle", lambda *_args, **_kwargs: _TTSOk())
resp = _run(module.tts())
assert resp.mimetype == "audio/mpeg"
assert resp.headers.get("Cache-Control") == "no-cache"
assert resp.headers.get("Connection") == "keep-alive"
assert resp.headers.get("X-Accel-Buffering") == "no"
stream_text = _run(_read_sse_text(resp))
assert "chunk-A" in stream_text
assert "chunk-B" in stream_text
class _TTSErr:
def tts(self, _txt):
raise RuntimeError("tts boom")
monkeypatch.setattr(module, "LLMBundle", lambda *_args, **_kwargs: _TTSErr())
resp = _run(module.tts())
stream_text = _run(_read_sse_text(resp))
assert '"code": 500' in stream_text
assert "**ERROR**: tts boom" in stream_text
@pytest.mark.p2
def test_delete_msg_and_thumbup_matrix_unit(monkeypatch):
module = _load_conversation_module(monkeypatch)
updates = []
monkeypatch.setattr(module.ConversationService, "update_by_id", lambda conv_id, payload: updates.append((conv_id, payload)) or True)
_set_request_json(monkeypatch, module, {"conversation_id": "missing", "message_id": "pair-1"})
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (False, None))
res = _run(module.delete_msg.__wrapped__())
assert res["message"] == "Conversation not found!"
conv = _DummyConversation(
conv_id="conv-del",
message=[
{"id": "other", "role": "user"},
{"id": "pair-1", "role": "user"},
{"id": "pair-1", "role": "assistant"},
],
reference=[{"chunks": [{"id": "c1"}]}],
)
_set_request_json(monkeypatch, module, {"conversation_id": "conv-del", "message_id": "pair-1"})
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (True, conv))
res = _run(module.delete_msg.__wrapped__())
assert res["code"] == 0
assert [m["id"] for m in res["data"]["message"]] == ["other"]
assert res["data"]["reference"] == []
assert updates[-1][0] == "conv-del"
_set_request_json(monkeypatch, module, {"conversation_id": "missing", "message_id": "assistant-1", "thumbup": True})
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (False, None))
res = _run(module.thumbup.__wrapped__())
assert res["message"] == "Conversation not found!"
conv_up = _DummyConversation(
conv_id="conv-up",
message=[{"id": "assistant-1", "role": "assistant", "feedback": "old"}],
)
_set_request_json(monkeypatch, module, {"conversation_id": "conv-up", "message_id": "assistant-1", "thumbup": True})
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (True, conv_up))
res = _run(module.thumbup.__wrapped__())
assert res["code"] == 0
assert res["data"]["message"][0]["thumbup"] is True
assert "feedback" not in res["data"]["message"][0]
conv_down = _DummyConversation(conv_id="conv-down", message=[{"id": "assistant-2", "role": "assistant"}])
_set_request_json(
monkeypatch,
module,
{"conversation_id": "conv-down", "message_id": "assistant-2", "thumbup": False, "feedback": "needs sources"},
)
monkeypatch.setattr(module.ConversationService, "get_by_id", lambda _id: (True, conv_down))
res = _run(module.thumbup.__wrapped__())
assert res["code"] == 0
assert res["data"]["message"][0]["thumbup"] is False
assert res["data"]["message"][0]["feedback"] == "needs sources"
@pytest.mark.p2
def test_ask_about_stream_search_config_matrix_unit(monkeypatch):
module = _load_conversation_module(monkeypatch)
_set_request_json(monkeypatch, module, {"question": "q", "kb_ids": ["kb-1"], "search_id": "search-1"})
monkeypatch.setattr(module.SearchService, "get_detail", lambda _sid: {"search_config": {"mode": "test"}})
captured = {}
async def _fake_async_ask(question, kb_ids, uid, search_config=None):
captured["question"] = question
captured["kb_ids"] = kb_ids
captured["uid"] = uid
captured["search_config"] = search_config
yield {"answer": "first"}
raise RuntimeError("ask boom")
monkeypatch.setattr(module, "async_ask", _fake_async_ask)
resp = _run(module.ask_about.__wrapped__())
assert resp.headers["Content-Type"] == "text/event-stream; charset=utf-8"
sse_text = _run(_read_sse_text(resp))
assert '"answer": "first"' in sse_text
assert "**ERROR**: ask boom" in sse_text
assert '"data": true' in sse_text.lower()
assert captured == {"question": "q", "kb_ids": ["kb-1"], "uid": "user-1", "search_config": {"mode": "test"}}
@pytest.mark.p2
def test_mindmap_and_related_questions_matrix_unit(monkeypatch):
module = _load_conversation_module(monkeypatch)
def _search_detail(_sid):
return {
"tenant_id": "tenant-x",
"search_config": {
"kb_ids": ["kb-2", "kb-3"],
"chat_id": "chat-x",
"llm_setting": {"temperature": 0.2, "parameter": {"k": "v"}},
},
}
monkeypatch.setattr(module.SearchService, "get_detail", _search_detail)
_set_request_json(monkeypatch, module, {"question": "mindmap-q", "kb_ids": ["kb-1", "kb-2"], "search_id": "search-1"})
mindmap_calls = {}
async def _gen_ok(question, kb_ids, tenant_id, search_config):
mindmap_calls["question"] = question
mindmap_calls["kb_ids"] = set(kb_ids)
mindmap_calls["tenant_id"] = tenant_id
mindmap_calls["search_config"] = search_config
return {"nodes": [question]}
monkeypatch.setattr(module, "gen_mindmap", _gen_ok)
res = _run(module.mindmap.__wrapped__())
assert res["code"] == 0
assert res["data"] == {"nodes": ["mindmap-q"]}
assert mindmap_calls["kb_ids"] == {"kb-1", "kb-2", "kb-3"}
assert mindmap_calls["tenant_id"] == "tenant-x"
assert set(mindmap_calls["search_config"]["kb_ids"]) == {"kb-1", "kb-2", "kb-3"}
async def _gen_error(*_args, **_kwargs):
return {"error": "mindmap boom"}
monkeypatch.setattr(module, "gen_mindmap", _gen_error)
res = _run(module.mindmap.__wrapped__())
assert "mindmap boom" in res["message"]
llm_calls = {}
class _FakeChat:
async def async_chat(self, prompt, messages, options):
llm_calls["prompt"] = prompt
llm_calls["messages"] = messages
llm_calls["options"] = options
return "1. Alpha\n2. Beta\nignored"
def _fake_bundle(tenant_id, model_config, lang="Chinese", **kwargs):
llm_calls["bundle"] = (tenant_id, model_config)
return _FakeChat()
monkeypatch.setattr(module, "LLMBundle", _fake_bundle)
monkeypatch.setattr(module, "load_prompt", lambda name: f"prompt-{name}")
monkeypatch.setattr(
module,
"get_model_config_by_type_and_name",
lambda *_args, **_kwargs: {"llm_factory": "test", "llm_name": "chat-x", "model_type": module.LLMType.CHAT.value},
)
_set_request_json(monkeypatch, module, {"question": "solar", "search_id": "search-1"})
res = _run(module.related_questions.__wrapped__())
assert res["code"] == 0
assert res["data"] == ["Alpha", "Beta"]
assert llm_calls["bundle"][0] == "user-1"
assert llm_calls["options"] == {"temperature": 0.2}
assert llm_calls["prompt"] == "prompt-related_question"
assert "Keywords: solar" in llm_calls["messages"][0]["content"]