diff --git a/test/testcases/test_http_api/test_dataset_management/test_update_dataset.py b/test/testcases/test_http_api/test_dataset_management/test_update_dataset.py index a123797ced..a502be2b52 100644 --- a/test/testcases/test_http_api/test_dataset_management/test_update_dataset.py +++ b/test/testcases/test_http_api/test_dataset_management/test_update_dataset.py @@ -125,7 +125,8 @@ class TestDatasetUpdate: @pytest.mark.p1 @given(name=valid_names()) @example("a" * 128) - @settings(max_examples=20, suppress_health_check=[HealthCheck.function_scoped_fixture]) + # Network-bound API call; disable Hypothesis deadline to avoid flaky timeouts. + @settings(max_examples=20, suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None) def test_name(self, HttpApiAuth, add_dataset_func, name): dataset_id = add_dataset_func payload = {"name": name} diff --git a/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py b/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py index 7660ae6c36..9d0e6c2ddb 100644 --- a/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py +++ b/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py @@ -77,6 +77,15 @@ class _StubResponse: self.headers = _StubHeaders() +class _DummyUploadFile: + def __init__(self, filename): + self.filename = filename + self.saved_path = None + + async def save(self, path): + self.saved_path = path + + def _run(coro): return asyncio.run(coro) @@ -96,6 +105,16 @@ async def _collect_stream(body): return items +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + def _load_session_module(monkeypatch): repo_root = Path(__file__).resolve().parents[4] common_pkg = ModuleType("common") @@ -1125,3 +1144,355 @@ def test_searchbots_retrieval_test_embedded_matrix_unit(monkeypatch): assert retrieval_capture["rank_feature"] == ["label-1"] assert retrieval_capture["rerank_mdl"] is not None assert any(call[1] == module.LLMType.EMBEDDING.value and call[3].get("llm_name") == "embd-model" for call in llm_calls) + + llm_calls.clear() + + async def _fake_keyword_extraction(_chat_mdl, question): + return f"-{question}-keywords" + + async def _fake_kg_retrieval(question, tenant_ids, kb_ids, _embd_mdl, _chat_mdl): + return { + "id": "kg-chunk", + "question": question, + "tenant_ids": tenant_ids, + "kb_ids": kb_ids, + "content_with_weight": 1, + "vector": [0.5], + } + + monkeypatch.setattr(module, "keyword_extraction", _fake_keyword_extraction) + monkeypatch.setattr(module.settings, "kg_retriever", SimpleNamespace(retrieval=_fake_kg_retrieval)) + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue( + { + "kb_id": "kb-1", + "question": "keyword-q", + "rerank_id": "manual-reranker", + "keyword": True, + "use_kg": True, + } + ), + ) + monkeypatch.setattr( + module.KnowledgebaseService, + "get_by_id", + lambda _kb_id: (True, SimpleNamespace(tenant_id="tenant-kb", embd_id="embd-model")), + ) + res = _run(handler()) + assert res["code"] == 0 + assert res["data"]["chunks"][0]["id"] == "kg-chunk" + assert all("vector" not in chunk for chunk in res["data"]["chunks"]) + assert any(call[1] == module.LLMType.RERANK.value for call in llm_calls) + + async def _raise_not_found(*_args, **_kwargs): + raise RuntimeError("x not_found y") + + monkeypatch.setattr(module.settings, "retriever", SimpleNamespace(retrieval=_raise_not_found)) + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue({"kb_id": "kb-1", "question": "q"}), + ) + res = _run(handler()) + assert res["message"] == "No chunk found! Check the chunk status please!" + + +@pytest.mark.p2 +def test_searchbots_related_questions_embedded_matrix_unit(monkeypatch): + module = _load_session_module(monkeypatch) + handler = inspect.unwrap(module.related_questions_embedded) + + monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer"})) + res = _run(handler()) + assert res["message"] == "Authorization is not valid!" + + monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer bad"})) + monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: []) + res = _run(handler()) + assert "API key is invalid" in res["message"] + + monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer ok"})) + monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="")]) + monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"question": "q"})) + res = _run(handler()) + assert res["message"] == "permission denined." + + captured = {} + + class _FakeChatBundle: + async def async_chat(self, prompt, messages, options): + captured["prompt"] = prompt + captured["messages"] = messages + captured["options"] = options + return "1. Alpha\n2. Beta\nignored" + + def _fake_bundle(*args, **_kwargs): + captured["bundle_args"] = args + return _FakeChatBundle() + + monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-1")]) + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue({"question": "solar", "search_id": "search-1"}), + ) + monkeypatch.setattr( + module.SearchService, + "get_detail", + lambda _search_id: {"search_config": {"chat_id": "chat-x", "llm_setting": {"temperature": 0.2}}}, + ) + monkeypatch.setattr(module, "LLMBundle", _fake_bundle) + res = _run(handler()) + assert res["code"] == 0 + assert res["data"] == ["Alpha", "Beta"] + assert captured["bundle_args"] == ("tenant-1", module.LLMType.CHAT, "chat-x") + assert captured["options"] == {"temperature": 0.2} + assert "Keywords: solar" in captured["messages"][0]["content"] + + +@pytest.mark.p2 +def test_searchbots_detail_share_embedded_matrix_unit(monkeypatch): + module = _load_session_module(monkeypatch) + handler = inspect.unwrap(module.detail_share_embedded) + + monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer"}, args={"search_id": "s-1"})) + res = _run(handler()) + assert res["message"] == "Authorization is not valid!" + + monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer bad"}, args={"search_id": "s-1"})) + monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: []) + res = _run(handler()) + assert "API key is invalid" in res["message"] + + monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer ok"}, args={"search_id": "s-1"})) + monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="")]) + res = _run(handler()) + assert res["message"] == "permission denined." + + monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-1")]) + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-a")]) + monkeypatch.setattr(module.SearchService, "query", lambda **_kwargs: []) + res = _run(handler()) + assert res["code"] == module.RetCode.OPERATING_ERROR + assert "Has no permission for this operation." in res["message"] + + monkeypatch.setattr(module.SearchService, "query", lambda **_kwargs: [SimpleNamespace(id="s-1")]) + monkeypatch.setattr(module.SearchService, "get_detail", lambda _sid: None) + res = _run(handler()) + assert res["message"] == "Can't find this Search App!" + + monkeypatch.setattr(module.SearchService, "get_detail", lambda _sid: {"id": "s-1", "name": "search-app"}) + res = _run(handler()) + assert res["code"] == 0 + assert res["data"]["id"] == "s-1" + + +@pytest.mark.p2 +def test_searchbots_mindmap_embedded_matrix_unit(monkeypatch): + module = _load_session_module(monkeypatch) + handler = inspect.unwrap(module.mindmap) + + monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer"})) + res = _run(handler()) + assert res["message"] == "Authorization is not valid!" + + monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer bad"})) + monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: []) + res = _run(handler()) + assert "API key is invalid" in res["message"] + + monkeypatch.setattr(module, "request", SimpleNamespace(headers={"Authorization": "Bearer ok"})) + monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-1")]) + monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"question": "q", "kb_ids": ["kb-1"]})) + + captured = {} + + async def _gen_ok(question, kb_ids, tenant_id, search_config): + captured["params"] = (question, kb_ids, tenant_id, search_config) + return {"nodes": [question]} + + monkeypatch.setattr(module, "gen_mindmap", _gen_ok) + res = _run(handler()) + assert res["code"] == 0 + assert res["data"] == {"nodes": ["q"]} + assert captured["params"] == ("q", ["kb-1"], "tenant-1", {}) + + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue({"question": "q2", "kb_ids": ["kb-1"], "search_id": "search-1"}), + ) + monkeypatch.setattr(module.SearchService, "get_detail", lambda _sid: {"search_config": {"mode": "graph"}}) + res = _run(handler()) + assert res["code"] == 0 + assert captured["params"] == ("q2", ["kb-1"], "tenant-1", {"mode": "graph"}) + + async def _gen_error(*_args, **_kwargs): + return {"error": "mindmap boom"} + + monkeypatch.setattr(module, "gen_mindmap", _gen_error) + res = _run(handler()) + assert "mindmap boom" in res["message"] + + +@pytest.mark.p2 +def test_sequence2txt_embedded_validation_and_stream_matrix_unit(monkeypatch): + module = _load_session_module(monkeypatch) + handler = inspect.unwrap(module.sequence2txt) + monkeypatch.setattr(module, "Response", _StubResponse) + monkeypatch.setattr(module.tempfile, "mkstemp", lambda suffix: (11, f"/tmp/audio{suffix}")) + monkeypatch.setattr(module.os, "close", lambda _fd: None) + + def _set_request(form, files): + monkeypatch.setattr( + module, + "request", + SimpleNamespace(form=_AwaitableValue(form), files=_AwaitableValue(files)), + ) + + _set_request({"stream": "false"}, {}) + res = _run(handler("tenant-1")) + assert "Missing 'file' in multipart form-data" in res["message"] + + _set_request({"stream": "false"}, {"file": _DummyUploadFile("bad.txt")}) + res = _run(handler("tenant-1")) + assert "Unsupported audio format: .txt" in res["message"] + + _set_request({"stream": "false"}, {"file": _DummyUploadFile("audio.wav")}) + monkeypatch.setattr(module.TenantService, "get_info_by", lambda _tid: []) + res = _run(handler("tenant-1")) + assert res["message"] == "Tenant not found!" + + _set_request({"stream": "false"}, {"file": _DummyUploadFile("audio.wav")}) + monkeypatch.setattr(module.TenantService, "get_info_by", lambda _tid: [{"tenant_id": "tenant-1", "asr_id": ""}]) + res = _run(handler("tenant-1")) + assert res["message"] == "No default ASR model is set" + + class _SyncASR: + def transcription(self, _path): + return "transcribed text" + + def stream_transcription(self, _path): + return [] + + _set_request({"stream": "false"}, {"file": _DummyUploadFile("audio.wav")}) + monkeypatch.setattr(module.TenantService, "get_info_by", lambda _tid: [{"tenant_id": "tenant-1", "asr_id": "asr-x"}]) + monkeypatch.setattr(module, "LLMBundle", lambda *_args, **_kwargs: _SyncASR()) + monkeypatch.setattr(module.os, "remove", lambda _path: (_ for _ in ()).throw(RuntimeError("cleanup fail"))) + res = _run(handler("tenant-1")) + 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"} + + _set_request({"stream": "true"}, {"file": _DummyUploadFile("audio.wav")}) + monkeypatch.setattr(module, "LLMBundle", lambda *_args, **_kwargs: _StreamASR()) + monkeypatch.setattr(module.os, "remove", lambda _path: None) + resp = _run(handler("tenant-1")) + assert isinstance(resp, _StubResponse) + assert resp.content_type == "text/event-stream" + chunks = _run(_collect_stream(resp.body)) + assert any('"event": "partial"' in chunk for chunk in chunks) + + class _ErrorASR: + def transcription(self, _path): + return "" + + def stream_transcription(self, _path): + raise RuntimeError("stream asr boom") + + _set_request({"stream": "true"}, {"file": _DummyUploadFile("audio.wav")}) + monkeypatch.setattr(module, "LLMBundle", lambda *_args, **_kwargs: _ErrorASR()) + monkeypatch.setattr(module.os, "remove", lambda _path: (_ for _ in ()).throw(RuntimeError("cleanup boom"))) + resp = _run(handler("tenant-1")) + chunks = _run(_collect_stream(resp.body)) + assert any("stream asr boom" in chunk for chunk in chunks) + + +@pytest.mark.p2 +def test_tts_embedded_stream_and_error_matrix_unit(monkeypatch): + module = _load_session_module(monkeypatch) + handler = inspect.unwrap(module.tts) + monkeypatch.setattr(module, "Response", _StubResponse) + monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"text": "A。B"})) + + monkeypatch.setattr(module.TenantService, "get_info_by", lambda _tid: []) + res = _run(handler("tenant-1")) + assert res["message"] == "Tenant not found!" + + monkeypatch.setattr(module.TenantService, "get_info_by", lambda _tid: [{"tenant_id": "tenant-1", "tts_id": ""}]) + res = _run(handler("tenant-1")) + 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.TenantService, "get_info_by", lambda _tid: [{"tenant_id": "tenant-1", "tts_id": "tts-x"}]) + monkeypatch.setattr(module, "LLMBundle", lambda *_args, **_kwargs: _TTSOk()) + resp = _run(handler("tenant-1")) + 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" + chunks = _run(_collect_stream(resp.body)) + assert any("chunk-A" in chunk for chunk in chunks) + assert any("chunk-B" in chunk for chunk in chunks) + + class _TTSErr: + def tts(self, _txt): + raise RuntimeError("tts boom") + + monkeypatch.setattr(module, "LLMBundle", lambda *_args, **_kwargs: _TTSErr()) + resp = _run(handler("tenant-1")) + chunks = _run(_collect_stream(resp.body)) + assert any('"code": 500' in chunk and "**ERROR**: tts boom" in chunk for chunk in chunks) + + +@pytest.mark.p2 +def test_build_reference_chunks_metadata_matrix_unit(monkeypatch): + module = _load_session_module(monkeypatch) + + monkeypatch.setattr(module, "chunks_format", lambda _reference: [{"dataset_id": "kb-1", "document_id": "doc-1"}]) + res = module._build_reference_chunks([], include_metadata=False) + assert res == [{"dataset_id": "kb-1", "document_id": "doc-1"}] + + monkeypatch.setattr(module, "chunks_format", lambda _reference: [{"dataset_id": "kb-1"}, {"document_id": "doc-2"}]) + res = module._build_reference_chunks([], include_metadata=True) + assert all("document_metadata" not in chunk for chunk in res) + + monkeypatch.setattr(module, "chunks_format", lambda _reference: [{"dataset_id": "kb-1", "document_id": "doc-1"}]) + monkeypatch.setattr(module.DocMetadataService, "get_metadata_for_documents", lambda _doc_ids, _kb_id: {"doc-1": {"author": "alice"}}) + res = module._build_reference_chunks([], include_metadata=True, metadata_fields=[1, None]) + assert "document_metadata" not in res[0] + + source_chunks = [ + {"dataset_id": "kb-1", "document_id": "doc-1"}, + {"dataset_id": "kb-2", "document_id": "doc-2"}, + {"dataset_id": "kb-1", "document_id": "doc-3"}, + {"dataset_id": "kb-1", "document_id": None}, + ] + monkeypatch.setattr(module, "chunks_format", lambda _reference: [dict(chunk) for chunk in source_chunks]) + + def _get_metadata(_doc_ids, kb_id): + if kb_id == "kb-1": + return {"doc-1": {"author": "alice", "year": 2024}} + if kb_id == "kb-2": + return {"doc-2": {"author": "bob", "tag": "rag"}} + return {} + + monkeypatch.setattr(module.DocMetadataService, "get_metadata_for_documents", _get_metadata) + res = module._build_reference_chunks([], include_metadata=True, metadata_fields=["author", "missing", 3]) + assert res[0]["document_metadata"] == {"author": "alice"} + assert res[1]["document_metadata"] == {"author": "bob"} + assert "document_metadata" not in res[2] + assert "document_metadata" not in res[3] diff --git a/test/testcases/test_sdk_api/test_agent_management/test_agent_crud_unit.py b/test/testcases/test_sdk_api/test_agent_management/test_agent_crud_unit.py index 14fc03acf8..a92b367046 100644 --- a/test/testcases/test_sdk_api/test_agent_management/test_agent_crud_unit.py +++ b/test/testcases/test_sdk_api/test_agent_management/test_agent_crud_unit.py @@ -17,6 +17,7 @@ import pytest from ragflow_sdk import RAGFlow from ragflow_sdk.modules.agent import Agent +from ragflow_sdk.modules.session import Session class _DummyResponse: @@ -27,6 +28,16 @@ class _DummyResponse: 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_list_agents_success_and_error(monkeypatch): client = RAGFlow("token", "http://localhost:9380") @@ -122,3 +133,84 @@ def test_delete_agent_success_and_error(monkeypatch): with pytest.raises(Exception) as exception_info: client.delete_agent("agent-1") assert "delete boom" in str(exception_info.value), str(exception_info.value) + + +@pytest.mark.p2 +def test_agent_and_dsl_default_initialization(): + client = RAGFlow("token", "http://localhost:9380") + + agent = Agent(client, {"id": "agent-1", "title": "Agent One"}) + assert agent.id == "agent-1" + assert agent.avatar is None + assert agent.canvas_type is None + assert agent.description is None + assert agent.dsl is None + + dsl = Agent.Dsl(client, {}) + assert dsl.answer == [] + assert "begin" in dsl.components + assert dsl.components["begin"]["obj"]["component_name"] == "Begin" + assert dsl.graph["nodes"][0]["id"] == "begin" + assert dsl.history == [] + assert dsl.messages == [] + assert dsl.path == [] + assert dsl.reference == [] + + +@pytest.mark.p2 +def test_agent_session_methods_success_and_error_paths(monkeypatch): + client = RAGFlow("token", "http://localhost:9380") + agent = Agent(client, {"id": "agent-1"}) + calls = {"post": [], "get": [], "rm": []} + + def _ok_post(path, json=None, stream=False, files=None): + calls["post"].append((path, json, stream, files)) + return _DummyResponse({"code": 0, "data": {"id": "session-1", "agent_id": "agent-1", "name": "one"}}) + + def _ok_get(path, params=None): + calls["get"].append((path, params)) + return _DummyResponse( + { + "code": 0, + "data": [ + {"id": "session-1", "agent_id": "agent-1", "name": "one"}, + {"id": "session-2", "agent_id": "agent-1", "name": "two"}, + ], + } + ) + + def _ok_rm(path, payload): + calls["rm"].append((path, payload)) + return _DummyResponse({"code": 0, "message": "ok"}) + + monkeypatch.setattr(agent, "post", _ok_post) + monkeypatch.setattr(agent, "get", _ok_get) + monkeypatch.setattr(agent, "rm", _ok_rm) + + session = agent.create_session(name="session-name") + assert isinstance(session, Session), str(session) + assert session.id == "session-1", str(session) + assert calls["post"][-1][0] == "/agents/agent-1/sessions" + assert calls["post"][-1][1] == {"name": "session-name"} + + sessions = agent.list_sessions(page=2, page_size=5, orderby="create_time", desc=False, id="session-1") + assert len(sessions) == 2, str(sessions) + assert all(isinstance(item, Session) for item in sessions), str(sessions) + assert calls["get"][-1][0] == "/agents/agent-1/sessions" + assert calls["get"][-1][1]["page"] == 2 + assert calls["get"][-1][1]["id"] == "session-1" + + agent.delete_sessions(ids=["session-1", "session-2"]) + assert calls["rm"][-1] == ("/agents/agent-1/sessions", {"ids": ["session-1", "session-2"]}) + + monkeypatch.setattr(agent, "post", lambda *_args, **_kwargs: _DummyResponse({"code": 1, "message": "create failed"})) + with pytest.raises(Exception, match="create failed"): + agent.create_session(name="bad") + + monkeypatch.setattr(agent, "get", lambda *_args, **_kwargs: _DummyResponse({"code": 2, "message": "list failed"})) + with pytest.raises(Exception, match="list failed"): + agent.list_sessions() + + monkeypatch.setattr(agent, "rm", lambda *_args, **_kwargs: _DummyResponse({"code": 3, "message": "delete failed"})) + with pytest.raises(Exception, match="delete failed"): + agent.delete_sessions(ids=["session-1"]) diff --git a/test/testcases/test_sdk_api/test_session_management/test_create_session_with_chat_assistant.py b/test/testcases/test_sdk_api/test_session_management/test_create_session_with_chat_assistant.py index e7bb41262c..74ef87afc6 100644 --- a/test/testcases/test_sdk_api/test_session_management/test_create_session_with_chat_assistant.py +++ b/test/testcases/test_sdk_api/test_session_management/test_create_session_with_chat_assistant.py @@ -17,6 +17,28 @@ from concurrent.futures import ThreadPoolExecutor, as_completed import pytest from configs import SESSION_WITH_CHAT_NAME_LIMIT +from ragflow_sdk import RAGFlow +from ragflow_sdk.modules.session import Session + + +class _DummyStreamResponse: + def __init__(self, lines): + self._lines = lines + + def iter_lines(self, decode_unicode=True): + del decode_unicode + for line in self._lines: + yield line + + +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None @pytest.mark.usefixtures("clear_session_with_chat_assistants") @@ -74,3 +96,72 @@ class TestSessionWithChatAssistantCreate: with pytest.raises(Exception) as exception_info: chat_assistant.create_session(name="valid_name") assert "You do not own the assistant" in str(exception_info.value) + + +@pytest.mark.p2 +def test_session_module_streaming_and_helper_paths_unit(monkeypatch): + client = RAGFlow("token", "http://localhost:9380") + chat_session = Session(client, {"id": "session-chat", "chat_id": "chat-1"}) + chat_done_session = Session(client, {"id": "session-chat-done", "chat_id": "chat-1"}) + agent_session = Session(client, {"id": "session-agent", "agent_id": "agent-1"}) + calls = [] + + chat_stream = _DummyStreamResponse( + [ + "", + "data: {bad json}", + 'data: {"event":"workflow_started","data":{"content":"skip"}}', + '{"data":{"answer":"chat-answer","reference":{"chunks":[{"id":"chunk-1"}]}}}', + 'data: {"data": true}', + "data: [DONE]", + ] + ) + agent_stream = _DummyStreamResponse( + [ + "data: {bad json}", + 'data: {"event":"message","data":{"content":"agent-answer"}}', + 'data: {"event":"message_end","data":{"content":"done"}}', + ] + ) + + def _chat_post(path, json=None, stream=False, files=None): + calls.append(("chat", path, json, stream, files)) + return chat_stream + + def _agent_post(path, json=None, stream=False, files=None): + calls.append(("agent", path, json, stream, files)) + return agent_stream + + monkeypatch.setattr(chat_session, "post", _chat_post) + monkeypatch.setattr( + chat_done_session, + "post", + lambda *_args, **_kwargs: _DummyStreamResponse( + ['{"data":{"answer":"chat-done","reference":{"chunks":[]}}}', "data: [DONE]"] + ), + ) + monkeypatch.setattr(agent_session, "post", _agent_post) + + chat_messages = list(chat_session.ask("hello chat", stream=True, temperature=0.2)) + assert len(chat_messages) == 1 + assert chat_messages[0].content == "chat-answer" + assert chat_messages[0].reference == [{"id": "chunk-1"}] + + chat_done_messages = list(chat_done_session.ask("hello done", stream=True)) + assert len(chat_done_messages) == 1 + assert chat_done_messages[0].content == "chat-done" + + agent_messages = list(agent_session.ask("hello agent", stream=True, top_p=0.8)) + assert len(agent_messages) == 1 + assert agent_messages[0].content == "agent-answer" + + assert calls[0][1] == "/chats/chat-1/completions" + assert calls[0][2]["question"] == "hello chat" + assert calls[0][2]["session_id"] == "session-chat" + assert calls[0][2]["temperature"] == 0.2 + assert calls[0][3] is True + assert calls[1][1] == "/agents/agent-1/completions" + assert calls[1][2]["question"] == "hello agent" + assert calls[1][2]["session_id"] == "session-agent" + assert calls[1][2]["top_p"] == 0.8 + assert calls[1][3] is True diff --git a/test/testcases/test_sdk_api/test_session_management/test_list_sessions_with_chat_assistant.py b/test/testcases/test_sdk_api/test_session_management/test_list_sessions_with_chat_assistant.py index df9c0e1c08..91cd330539 100644 --- a/test/testcases/test_sdk_api/test_session_management/test_list_sessions_with_chat_assistant.py +++ b/test/testcases/test_sdk_api/test_session_management/test_list_sessions_with_chat_assistant.py @@ -15,6 +15,18 @@ # import pytest from concurrent.futures import ThreadPoolExecutor, as_completed +from ragflow_sdk import RAGFlow +from ragflow_sdk.modules.session import Message, Session + + +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None class TestSessionsWithChatAssistantList: @@ -215,3 +227,54 @@ class TestSessionsWithChatAssistantList: with pytest.raises(Exception) as exception_info: chat_assistant.list_sessions() assert "You don't own the assistant" in str(exception_info.value) + + +@pytest.mark.p2 +def test_session_module_error_paths_unit(monkeypatch): + client = RAGFlow("token", "http://localhost:9380") + + unknown_session = Session(client, {"id": "session-unknown", "chat_id": "chat-1"}) + unknown_session._Session__session_type = "unknown" # noqa: SLF001 + with pytest.raises(Exception) as exception_info: + list(unknown_session.ask("hello", stream=False)) + assert "Unknown session type" in str(exception_info.value) + + bad_json_session = Session(client, {"id": "session-bad-json", "chat_id": "chat-1"}) + + class _BadJsonResponse: + def json(self): + raise ValueError("json decode failed") + + monkeypatch.setattr(bad_json_session, "post", lambda *_args, **_kwargs: _BadJsonResponse()) + with pytest.raises(Exception) as exception_info: + list(bad_json_session.ask("hello", stream=False)) + assert "Invalid response" in str(exception_info.value) + + ok_json_session = Session(client, {"id": "session-ok-json", "chat_id": "chat-1"}) + + class _OkJsonResponse: + def json(self): + return {"data": {"answer": "ok-answer", "reference": {"chunks": [{"id": "chunk-ok"}]}}} + + monkeypatch.setattr(ok_json_session, "post", lambda *_args, **_kwargs: _OkJsonResponse()) + ok_messages = list(ok_json_session.ask("hello", stream=False)) + assert len(ok_messages) == 1 + assert ok_messages[0].content == "ok-answer" + assert ok_messages[0].reference == [{"id": "chunk-ok"}] + + transport_session = Session(client, {"id": "session-transport", "chat_id": "chat-1"}) + monkeypatch.setattr( + transport_session, + "post", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("transport boom")), + ) + with pytest.raises(RuntimeError) as exception_info: + list(transport_session.ask("hello", stream=False)) + assert "transport boom" in str(exception_info.value) + + message = Message(client, {}) + assert message.content == "Hi! I am your assistant, can I help you?" + assert message.reference is None + assert message.role == "assistant" + assert message.prompt is None + assert message.id is None diff --git a/test/testcases/test_web_api/test_auth_app/test_oauth_client_unit.py b/test/testcases/test_web_api/test_auth_app/test_oauth_client_unit.py new file mode 100644 index 0000000000..90f089a908 --- /dev/null +++ b/test/testcases/test_web_api/test_auth_app/test_oauth_client_unit.py @@ -0,0 +1,197 @@ +# +# 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 +import urllib.parse +from pathlib import Path +from types import ModuleType + +import pytest + + +class _FakeResponse: + def __init__(self, payload=None, err=None): + self._payload = payload or {} + self._err = err + + def raise_for_status(self): + if self._err: + raise self._err + + def json(self): + return self._payload + + +def _base_config(scope="openid profile"): + return { + "client_id": "client-1", + "client_secret": "secret-1", + "authorization_url": "https://issuer.example/authorize", + "token_url": "https://issuer.example/token", + "userinfo_url": "https://issuer.example/userinfo", + "redirect_uri": "https://app.example/callback", + "scope": scope, + } + + +def _load_oauth_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) + + http_client_mod = ModuleType("common.http_client") + + async def _default_async_request(*_args, **_kwargs): + return _FakeResponse({}) + + def _default_sync_request(*_args, **_kwargs): + return _FakeResponse({}) + + http_client_mod.async_request = _default_async_request + http_client_mod.sync_request = _default_sync_request + monkeypatch.setitem(sys.modules, "common.http_client", http_client_mod) + + api_pkg = ModuleType("api") + api_pkg.__path__ = [str(repo_root / "api")] + apps_pkg = ModuleType("api.apps") + apps_pkg.__path__ = [str(repo_root / "api" / "apps")] + auth_pkg = ModuleType("api.apps.auth") + auth_pkg.__path__ = [str(repo_root / "api" / "apps" / "auth")] + + monkeypatch.setitem(sys.modules, "api", api_pkg) + monkeypatch.setitem(sys.modules, "api.apps", apps_pkg) + monkeypatch.setitem(sys.modules, "api.apps.auth", auth_pkg) + + sys.modules.pop("api.apps.auth.oauth", None) + oauth_path = repo_root / "api" / "apps" / "auth" / "oauth.py" + oauth_spec = importlib.util.spec_from_file_location("api.apps.auth.oauth", oauth_path) + oauth_module = importlib.util.module_from_spec(oauth_spec) + monkeypatch.setitem(sys.modules, "api.apps.auth.oauth", oauth_module) + oauth_spec.loader.exec_module(oauth_module) + return oauth_module + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + +@pytest.mark.p2 +def test_oauth_client_sync_matrix_unit(monkeypatch): + oauth_module = _load_oauth_module(monkeypatch) + client = oauth_module.OAuthClient(_base_config()) + + assert client.client_id == "client-1" + assert client.client_secret == "secret-1" + assert client.authorization_url.endswith("/authorize") + assert client.token_url.endswith("/token") + assert client.userinfo_url.endswith("/userinfo") + assert client.redirect_uri.endswith("/callback") + assert client.scope == "openid profile" + assert client.http_request_timeout == 7 + + info = oauth_module.UserInfo("u@example.com", "user1", "User One", "avatar-url") + assert info.to_dict() == { + "email": "u@example.com", + "username": "user1", + "nickname": "User One", + "avatar_url": "avatar-url", + } + + auth_url = client.get_authorization_url(state="s p/a?ce") + parsed = urllib.parse.urlparse(auth_url) + query = urllib.parse.parse_qs(parsed.query) + assert parsed.scheme == "https" + assert query["client_id"] == ["client-1"] + assert query["redirect_uri"] == ["https://app.example/callback"] + assert query["response_type"] == ["code"] + assert query["scope"] == ["openid profile"] + assert query["state"] == ["s p/a?ce"] + + no_scope_client = oauth_module.OAuthClient(_base_config(scope=None)) + no_scope_query = urllib.parse.parse_qs(urllib.parse.urlparse(no_scope_client.get_authorization_url()).query) + assert "scope" not in no_scope_query + + call_log = [] + + def _sync_ok(method, url, data=None, headers=None, timeout=None): + call_log.append((method, url, data, headers, timeout)) + if url.endswith("/token"): + return _FakeResponse({"access_token": "token-1"}) + return _FakeResponse({"email": "user@example.com", "picture": "id-picture"}) + + monkeypatch.setattr(oauth_module, "sync_request", _sync_ok) + token = client.exchange_code_for_token("code-1") + assert token["access_token"] == "token-1" + user_info = client.fetch_user_info("access-1") + assert isinstance(user_info, oauth_module.UserInfo) + assert user_info.to_dict() == { + "email": "user@example.com", + "username": "user", + "nickname": "user", + "avatar_url": "id-picture", + } + assert call_log[0][0] == "POST" + assert call_log[0][3]["Accept"] == "application/json" + assert call_log[1][0] == "GET" + assert call_log[1][3]["Authorization"] == "Bearer access-1" + + normalized = client.normalize_user_info( + {"email": "fallback@example.com", "username": "fallback-user", "nickname": "fallback-nick", "avatar_url": "direct-avatar"} + ) + assert normalized.to_dict()["avatar_url"] == "direct-avatar" + + monkeypatch.setattr(oauth_module, "sync_request", lambda *_args, **_kwargs: _FakeResponse(err=RuntimeError("status boom"))) + with pytest.raises(ValueError, match="Failed to exchange authorization code for token: status boom"): + client.exchange_code_for_token("code-2") + with pytest.raises(ValueError, match="Failed to fetch user info: status boom"): + client.fetch_user_info("access-2") + + +@pytest.mark.p2 +def test_oauth_client_async_matrix_unit(monkeypatch): + oauth_module = _load_oauth_module(monkeypatch) + client = oauth_module.OAuthClient(_base_config()) + + async def _async_ok(method, url, data=None, headers=None, **kwargs): + _ = (method, data, headers, kwargs.get("timeout")) + if url.endswith("/token"): + return _FakeResponse({"access_token": "token-async"}) + return _FakeResponse({"email": "async@example.com", "username": "async-user", "nickname": "Async User", "avatar_url": "async-avatar"}) + + monkeypatch.setattr(oauth_module, "async_request", _async_ok) + token = asyncio.run(client.async_exchange_code_for_token("code-a")) + assert token["access_token"] == "token-async" + info = asyncio.run(client.async_fetch_user_info("async-token")) + assert info.to_dict() == { + "email": "async@example.com", + "username": "async-user", + "nickname": "Async User", + "avatar_url": "async-avatar", + } + + async def _async_fail(*_args, **_kwargs): + return _FakeResponse(err=RuntimeError("async boom")) + + monkeypatch.setattr(oauth_module, "async_request", _async_fail) + with pytest.raises(ValueError, match="Failed to exchange authorization code for token: async boom"): + asyncio.run(client.async_exchange_code_for_token("code-b")) + with pytest.raises(ValueError, match="Failed to fetch user info: async boom"): + asyncio.run(client.async_fetch_user_info("async-token-2")) diff --git a/test/testcases/test_web_api/test_canvas_app/test_canvas_routes_unit.py b/test/testcases/test_web_api/test_canvas_app/test_canvas_routes_unit.py new file mode 100644 index 0000000000..35aa319419 --- /dev/null +++ b/test/testcases/test_web_api/test_canvas_app/test_canvas_routes_unit.py @@ -0,0 +1,1415 @@ +# +# 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 inspect +import sys +from copy import deepcopy +from functools import partial +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +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 _Args(dict): + def get(self, key, default=None, type=None): + value = super().get(key, default) + if value is None or type is None: + return value + try: + return type(value) + except (TypeError, ValueError): + return default + + +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, mimetype=None, content_type=None): + self.response = body + self.body = body + self.mimetype = mimetype + self.content_type = content_type + self.headers = _StubHeaders() + + +class _DummyRequest: + def __init__(self, *, headers=None, args=None, files=None, method="POST", content_length=0): + self.headers = headers or {} + self.args = args or _Args() + self.files = _AwaitableValue(files if files is not None else {}) + self.method = method + self.content_length = content_length + + +class _DummyRetCode: + SUCCESS = 0 + EXCEPTION_ERROR = 100 + ARGUMENT_ERROR = 101 + DATA_ERROR = 102 + OPERATING_ERROR = 103 + + +class _DummyCanvasCategory: + Agent = "agent" + DataFlow = "dataflow" + + +class _TaskField: + def __eq__(self, other): + return ("eq", other) + + +class _DummyTask: + doc_id = _TaskField() + + +class _FileMap(dict): + def getlist(self, key): + return list(self.get(key, [])) + + +def _run(coro): + return asyncio.run(coro) + + +async def _collect_stream(body): + items = [] + if hasattr(body, "__aiter__"): + async for item in body: + if isinstance(item, bytes): + item = item.decode("utf-8") + items.append(item) + else: + for item in body: + if isinstance(item, bytes): + item = item.decode("utf-8") + items.append(item) + return items + + +def _set_request_json(monkeypatch, module, payload): + async def _req(): + return deepcopy(payload) + + monkeypatch.setattr(module, "get_request_json", _req) + + +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + +def _load_canvas_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) + + settings_mod = ModuleType("common.settings") + settings_mod.docStoreConn = SimpleNamespace( + index_exist=lambda *_args, **_kwargs: False, + delete=lambda *_args, **_kwargs: True, + ) + common_pkg.settings = settings_mod + monkeypatch.setitem(sys.modules, "common.settings", settings_mod) + + constants_mod = ModuleType("common.constants") + constants_mod.RetCode = _DummyRetCode + monkeypatch.setitem(sys.modules, "common.constants", constants_mod) + + misc_utils_mod = ModuleType("common.misc_utils") + misc_utils_mod.get_uuid = lambda: "uuid-1" + + async def _thread_pool_exec(func, *args, **kwargs): + return func(*args, **kwargs) + + misc_utils_mod.thread_pool_exec = _thread_pool_exec + monkeypatch.setitem(sys.modules, "common.misc_utils", misc_utils_mod) + + api_pkg = ModuleType("api") + api_pkg.__path__ = [str(repo_root / "api")] + monkeypatch.setitem(sys.modules, "api", api_pkg) + + 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) + + db_pkg = ModuleType("api.db") + db_pkg.CanvasCategory = _DummyCanvasCategory + monkeypatch.setitem(sys.modules, "api.db", db_pkg) + + services_pkg = ModuleType("api.db.services") + services_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.db.services", services_pkg) + + canvas_service_mod = ModuleType("api.db.services.canvas_service") + + class _StubCanvasTemplateService: + @staticmethod + def get_all(): + return [] + + class _StubUserCanvasService: + @staticmethod + def accessible(*_args, **_kwargs): + return True + + @staticmethod + def delete_by_id(*_args, **_kwargs): + return True + + @staticmethod + def query(*_args, **_kwargs): + return [] + + @staticmethod + def save(**_kwargs): + return True + + @staticmethod + def update_by_id(*_args, **_kwargs): + return True + + @staticmethod + def get_by_canvas_id(_canvas_id): + return True, {"id": _canvas_id} + + @staticmethod + def get_by_id(_canvas_id): + return True, SimpleNamespace( + id=_canvas_id, + user_id="user-1", + dsl="{}", + canvas_category=_DummyCanvasCategory.Agent, + to_dict=lambda: {"id": _canvas_id}, + ) + + @staticmethod + def get_by_tenant_ids(*_args, **_kwargs): + return [], 0 + + class _StubAPI4ConversationService: + @staticmethod + def get_names(*_args, **_kwargs): + return [] + + @staticmethod + def get_list(*_args, **_kwargs): + return 0, [] + + @staticmethod + def save(**_kwargs): + return True + + @staticmethod + def get_by_id(_session_id): + return True, SimpleNamespace(to_dict=lambda: {"id": _session_id}) + + @staticmethod + def delete_by_id(*_args, **_kwargs): + return True + + async def _completion(*_args, **_kwargs): + if False: + yield {} + + canvas_service_mod.CanvasTemplateService = _StubCanvasTemplateService + canvas_service_mod.UserCanvasService = _StubUserCanvasService + canvas_service_mod.API4ConversationService = _StubAPI4ConversationService + canvas_service_mod.completion = _completion + monkeypatch.setitem(sys.modules, "api.db.services.canvas_service", canvas_service_mod) + + document_service_mod = ModuleType("api.db.services.document_service") + document_service_mod.DocumentService = SimpleNamespace( + clear_chunk_num_when_rerun=lambda *_args, **_kwargs: True, + update_by_id=lambda *_args, **_kwargs: True, + ) + monkeypatch.setitem(sys.modules, "api.db.services.document_service", document_service_mod) + + file_service_mod = ModuleType("api.db.services.file_service") + file_service_mod.FileService = SimpleNamespace( + upload_info=lambda *_args, **_kwargs: {"ok": True}, + get_blob=lambda *_args, **_kwargs: b"", + ) + monkeypatch.setitem(sys.modules, "api.db.services.file_service", file_service_mod) + + pipeline_log_service_mod = ModuleType("api.db.services.pipeline_operation_log_service") + pipeline_log_service_mod.PipelineOperationLogService = SimpleNamespace( + get_documents_info=lambda *_args, **_kwargs: [], + update_by_id=lambda *_args, **_kwargs: True, + ) + monkeypatch.setitem(sys.modules, "api.db.services.pipeline_operation_log_service", pipeline_log_service_mod) + + task_service_mod = ModuleType("api.db.services.task_service") + task_service_mod.queue_dataflow = lambda *_args, **_kwargs: (True, "") + task_service_mod.CANVAS_DEBUG_DOC_ID = "debug-doc" + task_service_mod.TaskService = SimpleNamespace(filter_delete=lambda *_args, **_kwargs: True) + monkeypatch.setitem(sys.modules, "api.db.services.task_service", task_service_mod) + + user_service_mod = ModuleType("api.db.services.user_service") + user_service_mod.TenantService = SimpleNamespace(get_joined_tenants_by_user_id=lambda *_args, **_kwargs: []) + monkeypatch.setitem(sys.modules, "api.db.services.user_service", user_service_mod) + + canvas_version_mod = ModuleType("api.db.services.user_canvas_version") + canvas_version_mod.UserCanvasVersionService = SimpleNamespace( + insert=lambda **_kwargs: True, + delete_all_versions=lambda *_args, **_kwargs: True, + list_by_canvas_id=lambda *_args, **_kwargs: [], + get_by_id=lambda *_args, **_kwargs: (True, None), + ) + monkeypatch.setitem(sys.modules, "api.db.services.user_canvas_version", canvas_version_mod) + + db_models_mod = ModuleType("api.db.db_models") + + class _StubAPIToken: + @staticmethod + def query(**_kwargs): + return [] + + db_models_mod.APIToken = _StubAPIToken + db_models_mod.Task = _DummyTask + monkeypatch.setitem(sys.modules, "api.db.db_models", db_models_mod) + + api_utils_mod = ModuleType("api.utils.api_utils") + + def _get_json_result(code=_DummyRetCode.SUCCESS, message="success", data=None): + return {"code": code, "message": message, "data": data} + + def _get_data_error_result(code=_DummyRetCode.DATA_ERROR, message="Sorry! Data missing!"): + return {"code": code, "message": message} + + def _server_error_response(exc): + return {"code": _DummyRetCode.EXCEPTION_ERROR, "message": repr(exc), "data": None} + + async def _get_request_json(): + return {} + + def _validate_request(*_args, **_kwargs): + def _decorator(func): + return func + + return _decorator + + api_utils_mod.get_json_result = _get_json_result + api_utils_mod.server_error_response = _server_error_response + api_utils_mod.validate_request = _validate_request + api_utils_mod.get_data_error_result = _get_data_error_result + api_utils_mod.get_request_json = _get_request_json + monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod) + + rag_pkg = ModuleType("rag") + rag_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "rag", rag_pkg) + + rag_flow_pkg = ModuleType("rag.flow") + rag_flow_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "rag.flow", rag_flow_pkg) + + pipeline_mod = ModuleType("rag.flow.pipeline") + + class _StubPipeline: + def __init__(self, *_args, **_kwargs): + pass + + pipeline_mod.Pipeline = _StubPipeline + monkeypatch.setitem(sys.modules, "rag.flow.pipeline", pipeline_mod) + + rag_nlp_mod = ModuleType("rag.nlp") + rag_nlp_mod.search = SimpleNamespace(index_name=lambda tenant_id: f"idx-{tenant_id}") + monkeypatch.setitem(sys.modules, "rag.nlp", rag_nlp_mod) + + rag_utils_pkg = ModuleType("rag.utils") + rag_utils_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "rag.utils", rag_utils_pkg) + + redis_mod = ModuleType("rag.utils.redis_conn") + redis_mod.REDIS_CONN = SimpleNamespace( + set=lambda *_args, **_kwargs: True, + get=lambda *_args, **_kwargs: None, + ) + monkeypatch.setitem(sys.modules, "rag.utils.redis_conn", redis_mod) + + agent_pkg = ModuleType("agent") + agent_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "agent", agent_pkg) + + agent_component_mod = ModuleType("agent.component") + + class _StubLLM: + pass + + agent_component_mod.LLM = _StubLLM + monkeypatch.setitem(sys.modules, "agent.component", agent_component_mod) + + agent_canvas_mod = ModuleType("agent.canvas") + + class _StubCanvas: + def __init__(self, dsl, _user_id, _agent_id=None, canvas_id=None): + self.dsl = dsl + self.id = canvas_id + + async def run(self, **_kwargs): + if False: + yield {} + + def cancel_task(self): + return None + + def reset(self): + return None + + def get_component_input_form(self, _component_id): + return {} + + def get_component(self, _component_id): + return {"obj": SimpleNamespace(reset=lambda: None, invoke=lambda **_kwargs: None, output=lambda: {})} + + def __str__(self): + return "{}" + + agent_canvas_mod.Canvas = _StubCanvas + monkeypatch.setitem(sys.modules, "agent.canvas", agent_canvas_mod) + + quart_mod = ModuleType("quart") + quart_mod.request = _DummyRequest() + quart_mod.Response = _StubResponse + + async def _make_response(blob): + return {"blob": blob} + + quart_mod.make_response = _make_response + monkeypatch.setitem(sys.modules, "quart", quart_mod) + + module_path = repo_root / "api" / "apps" / "canvas_app.py" + spec = importlib.util.spec_from_file_location("test_canvas_routes_unit_module", module_path) + module = importlib.util.module_from_spec(spec) + module.manager = _DummyManager() + monkeypatch.setitem(sys.modules, "test_canvas_routes_unit_module", module) + spec.loader.exec_module(module) + return module + + +@pytest.mark.p2 +def test_templates_rm_save_get_matrix_unit(monkeypatch): + module = _load_canvas_module(monkeypatch) + + class _Template: + def __init__(self, template_id): + self.template_id = template_id + + def to_dict(self): + return {"id": self.template_id} + + monkeypatch.setattr(module.CanvasTemplateService, "get_all", lambda: [_Template("tpl-1")]) + res = module.templates() + assert res["code"] == module.RetCode.SUCCESS + assert res["data"] == [{"id": "tpl-1"}] + + _set_request_json(monkeypatch, module, {"canvas_ids": ["c1", "c2"]}) + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: False) + res = _run(inspect.unwrap(module.rm)()) + assert res["code"] == module.RetCode.OPERATING_ERROR + assert "Only owner of canvas authorized" in res["message"] + + deleted = [] + _set_request_json(monkeypatch, module, {"canvas_ids": ["c1", "c2"]}) + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.UserCanvasService, "delete_by_id", lambda canvas_id: deleted.append(canvas_id)) + res = _run(inspect.unwrap(module.rm)()) + assert res["data"] is True + assert deleted == ["c1", "c2"] + + _set_request_json(monkeypatch, module, {"title": " Demo ", "dsl": {"n": 1}}) + monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: [object()]) + res = _run(inspect.unwrap(module.save)()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "already exists" in res["message"] + + _set_request_json(monkeypatch, module, {"title": "Demo", "dsl": {"n": 1}}) + monkeypatch.setattr(module, "get_uuid", lambda: "canvas-new") + monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: []) + monkeypatch.setattr(module.UserCanvasService, "save", lambda **_kwargs: False) + res = _run(inspect.unwrap(module.save)()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "Fail to save canvas." in res["message"] + + created = {"save": [], "versions": []} + _set_request_json(monkeypatch, module, {"title": "Demo", "dsl": {"n": 1}}) + monkeypatch.setattr(module, "get_uuid", lambda: "canvas-new") + monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: []) + monkeypatch.setattr(module.UserCanvasService, "save", lambda **kwargs: created["save"].append(kwargs) or True) + monkeypatch.setattr(module.UserCanvasVersionService, "insert", lambda **kwargs: created["versions"].append(("insert", kwargs))) + monkeypatch.setattr( + module.UserCanvasVersionService, + "delete_all_versions", + lambda canvas_id: created["versions"].append(("delete", canvas_id)), + ) + res = _run(inspect.unwrap(module.save)()) + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["id"] == "canvas-new" + assert created["save"] + assert any(item[0] == "insert" for item in created["versions"]) + assert any(item[0] == "delete" for item in created["versions"]) + + _set_request_json(monkeypatch, module, {"id": "canvas-1", "title": "Renamed", "dsl": "{\"m\": 1}"}) + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: False) + res = _run(inspect.unwrap(module.save)()) + assert res["code"] == module.RetCode.OPERATING_ERROR + + updates = [] + versions = [] + _set_request_json(monkeypatch, module, {"id": "canvas-1", "title": "Renamed", "dsl": "{\"m\": 1}"}) + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.UserCanvasService, "update_by_id", lambda canvas_id, payload: updates.append((canvas_id, payload))) + monkeypatch.setattr(module.UserCanvasVersionService, "insert", lambda **kwargs: versions.append(("insert", kwargs))) + monkeypatch.setattr(module.UserCanvasVersionService, "delete_all_versions", lambda canvas_id: versions.append(("delete", canvas_id))) + res = _run(inspect.unwrap(module.save)()) + assert res["code"] == module.RetCode.SUCCESS + assert updates and updates[0][0] == "canvas-1" + assert any(item[0] == "insert" for item in versions) + assert any(item[0] == "delete" for item in versions) + + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: False) + res = module.get("canvas-1") + assert res["code"] == module.RetCode.DATA_ERROR + assert res["message"] == "canvas not found." + + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.UserCanvasService, "get_by_canvas_id", lambda _canvas_id: (True, {"id": "canvas-1"})) + res = module.get("canvas-1") + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["id"] == "canvas-1" + + +@pytest.mark.p2 +def test_getsse_auth_token_and_ownership_matrix_unit(monkeypatch): + module = _load_canvas_module(monkeypatch) + + monkeypatch.setattr(module, "request", _DummyRequest(headers={"Authorization": "Bearer"})) + res = module.getsse("canvas-1") + assert res["message"] == "Authorization is not valid!" + + monkeypatch.setattr(module, "request", _DummyRequest(headers={"Authorization": "Bearer invalid"})) + monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: []) + res = module.getsse("canvas-1") + assert "API key is invalid" in res["message"] + + monkeypatch.setattr(module, "request", _DummyRequest(headers={"Authorization": "Bearer ok"})) + monkeypatch.setattr(module.APIToken, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-1")]) + monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: []) + res = module.getsse("canvas-1") + assert res["code"] == module.RetCode.OPERATING_ERROR + + monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: [object()]) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (False, None)) + res = module.getsse("canvas-1") + assert res["message"] == "canvas not found." + + bad_owner = SimpleNamespace(user_id="tenant-2", to_dict=lambda: {"id": "canvas-1"}) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (True, bad_owner)) + res = module.getsse("canvas-1") + assert res["message"] == "canvas not found." + + good_owner = SimpleNamespace(user_id="tenant-1", to_dict=lambda: {"id": "canvas-1"}) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (True, good_owner)) + res = module.getsse("canvas-1") + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["id"] == "canvas-1" + + +@pytest.mark.p2 +def test_run_dataflow_and_canvas_sse_matrix_unit(monkeypatch): + module = _load_canvas_module(monkeypatch) + + async def _thread_pool_exec(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(module, "thread_pool_exec", _thread_pool_exec) + + _set_request_json(monkeypatch, module, {"id": "c1"}) + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: False) + res = _run(inspect.unwrap(module.run)()) + assert res["code"] == module.RetCode.OPERATING_ERROR + + _set_request_json(monkeypatch, module, {"id": "c1"}) + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (False, None)) + res = _run(inspect.unwrap(module.run)()) + assert res["message"] == "canvas not found." + + class _CanvasRecord: + def __init__(self, *, canvas_id, dsl, canvas_category): + self.id = canvas_id + self.dsl = dsl + self.canvas_category = canvas_category + + def to_dict(self): + return {"id": self.id, "dsl": self.dsl} + + pipeline_calls = [] + monkeypatch.setattr(module, "Pipeline", lambda *args, **kwargs: pipeline_calls.append((args, kwargs))) + monkeypatch.setattr(module, "get_uuid", lambda: "task-1") + + _set_request_json(monkeypatch, module, {"id": "df-1", "files": ["f1"], "user_id": "exp-1"}) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (True, _CanvasRecord(canvas_id="df-1", dsl={"n": 1}, canvas_category=module.CanvasCategory.DataFlow))) + monkeypatch.setattr(module, "queue_dataflow", lambda *_args, **_kwargs: (False, "queue failed")) + res = _run(inspect.unwrap(module.run)()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "queue failed" in res["message"] + assert pipeline_calls + + _set_request_json(monkeypatch, module, {"id": "df-1", "files": ["f1"], "user_id": "exp-1"}) + monkeypatch.setattr(module, "queue_dataflow", lambda *_args, **_kwargs: (True, "")) + res = _run(inspect.unwrap(module.run)()) + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["message_id"] == "task-1" + + _set_request_json(monkeypatch, module, {"id": "ag-1", "query": "q", "files": [], "inputs": {}}) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (True, _CanvasRecord(canvas_id="ag-1", dsl={"x": 1}, canvas_category=module.CanvasCategory.Agent))) + monkeypatch.setattr(module, "Canvas", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("canvas init failed"))) + res = _run(inspect.unwrap(module.run)()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "canvas init failed" in res["message"] + + updates = [] + + class _CanvasSSESuccess: + def __init__(self, *_args, **_kwargs): + self.cancelled = False + + async def run(self, **_kwargs): + yield {"answer": "stream-ok"} + + def cancel_task(self): + self.cancelled = True + + def __str__(self): + return '{"updated": true}' + + _set_request_json(monkeypatch, module, {"id": "ag-2", "query": "q", "files": [], "inputs": {}, "user_id": "exp-2"}) + monkeypatch.setattr(module, "Canvas", _CanvasSSESuccess) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (True, _CanvasRecord(canvas_id="ag-2", dsl="{}", canvas_category=module.CanvasCategory.Agent))) + monkeypatch.setattr(module.UserCanvasService, "update_by_id", lambda canvas_id, payload: updates.append((canvas_id, payload))) + resp = _run(inspect.unwrap(module.run)()) + assert isinstance(resp, _StubResponse) + assert resp.headers.get("Content-Type") == "text/event-stream; charset=utf-8" + chunks = _run(_collect_stream(resp.response)) + assert any('"answer": "stream-ok"' in chunk for chunk in chunks) + assert updates and updates[0][0] == "ag-2" + + class _CanvasSSEError: + last_instance = None + + def __init__(self, *_args, **_kwargs): + self.cancelled = False + _CanvasSSEError.last_instance = self + + async def run(self, **_kwargs): + yield {"answer": "start"} + raise RuntimeError("stream boom") + + def cancel_task(self): + self.cancelled = True + + def __str__(self): + return "{}" + + _set_request_json(monkeypatch, module, {"id": "ag-3", "query": "q", "files": [], "inputs": {}, "user_id": "exp-3"}) + monkeypatch.setattr(module, "Canvas", _CanvasSSEError) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (True, _CanvasRecord(canvas_id="ag-3", dsl="{}", canvas_category=module.CanvasCategory.Agent))) + resp = _run(inspect.unwrap(module.run)()) + chunks = _run(_collect_stream(resp.response)) + assert any('"code": 500' in chunk and "stream boom" in chunk for chunk in chunks) + assert _CanvasSSEError.last_instance.cancelled is True + + +@pytest.mark.p2 +def test_exp_agent_completion_trace_and_filtering_unit(monkeypatch): + module = _load_canvas_module(monkeypatch) + _set_request_json(monkeypatch, module, {"return_trace": True}) + + async def _agent_completion(*_args, **_kwargs): + yield "data:not-json" + yield 'data:{"event":"node_finished","data":{"component_id":"cmp-1","step":"done"}}' + yield 'data:{"event":"heartbeat","data":{"t":1}}' + yield 'data:{"event":"message","data":{"content":"hello"}}' + yield 'data:{"event":"message_end","data":{"content":"bye"}}' + + monkeypatch.setattr(module, "agent_completion", _agent_completion) + resp = _run(inspect.unwrap(module.exp_agent_completion)("canvas-1")) + assert isinstance(resp, _StubResponse) + assert resp.headers.get("Content-Type") == "text/event-stream; charset=utf-8" + + chunks = _run(_collect_stream(resp.response)) + assert any('"event": "node_finished"' in chunk and '"trace"' in chunk for chunk in chunks) + assert not any('"event":"heartbeat"' in chunk or '"event": "heartbeat"' in chunk for chunk in chunks) + assert any('"event":"message"' in chunk or '"event": "message"' in chunk for chunk in chunks) + assert chunks[-1] == "data:[DONE]\n\n" + + +@pytest.mark.p2 +def test_rerun_and_cancel_matrix_unit(monkeypatch): + module = _load_canvas_module(monkeypatch) + _set_request_json(monkeypatch, module, {"id": "flow-1", "dsl": {"n": 1}, "component_id": "cmp-1"}) + + monkeypatch.setattr(module.PipelineOperationLogService, "get_documents_info", lambda _id: []) + res = _run(inspect.unwrap(module.rerun)()) + assert res["message"] == "Document not found." + + processing_doc = {"id": "doc-1", "name": "Doc-1", "kb_id": "kb-1", "progress": 0.5} + monkeypatch.setattr(module.PipelineOperationLogService, "get_documents_info", lambda _id: [dict(processing_doc)]) + res = _run(inspect.unwrap(module.rerun)()) + assert "is processing" in res["message"] + + class _DocStore: + def __init__(self): + self.deleted = [] + + def index_exist(self, *_args, **_kwargs): + return True + + def delete(self, *args, **_kwargs): + self.deleted.append(args) + return True + + doc_store = _DocStore() + monkeypatch.setattr(module.settings, "docStoreConn", doc_store) + + doc = { + "id": "doc-1", + "name": "Doc-1", + "kb_id": "kb-1", + "progress": 1.0, + "progress_msg": "old", + "chunk_num": 8, + "token_num": 12, + } + updates = {"doc": [], "pipeline": [], "tasks": [], "queue": []} + monkeypatch.setattr(module.PipelineOperationLogService, "get_documents_info", lambda _id: [dict(doc)]) + monkeypatch.setattr(module.DocumentService, "clear_chunk_num_when_rerun", lambda doc_id: updates["doc"].append(("clear", doc_id))) + monkeypatch.setattr(module.DocumentService, "update_by_id", lambda doc_id, payload: updates["doc"].append(("update", doc_id, payload))) + monkeypatch.setattr(module.TaskService, "filter_delete", lambda expr: updates["tasks"].append(expr)) + monkeypatch.setattr(module.PipelineOperationLogService, "update_by_id", lambda flow_id, payload: updates["pipeline"].append((flow_id, payload))) + monkeypatch.setattr( + module, + "queue_dataflow", + lambda **kwargs: updates["queue"].append(kwargs) or (True, ""), + ) + monkeypatch.setattr(module, "get_uuid", lambda: "task-rerun") + _set_request_json(monkeypatch, module, {"id": "flow-1", "dsl": {"n": 1}, "component_id": "cmp-1"}) + res = _run(inspect.unwrap(module.rerun)()) + assert res["code"] == module.RetCode.SUCCESS + assert doc_store.deleted + assert any(item[0] == "clear" and item[1] == "doc-1" for item in updates["doc"]) + assert updates["pipeline"] and updates["pipeline"][0][1]["dsl"]["path"] == ["cmp-1"] + assert updates["queue"] and updates["queue"][0]["rerun"] is True + + redis_calls = [] + monkeypatch.setattr(module.REDIS_CONN, "set", lambda key, value: redis_calls.append((key, value))) + res = module.cancel("task-9") + assert res["code"] == module.RetCode.SUCCESS + assert redis_calls == [("task-9-cancel", "x")] + + monkeypatch.setattr(module.REDIS_CONN, "set", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("redis fail"))) + res = module.cancel("task-9") + assert res["code"] == module.RetCode.SUCCESS + + +@pytest.mark.p2 +def test_reset_upload_input_form_debug_matrix_unit(monkeypatch): + module = _load_canvas_module(monkeypatch) + + _set_request_json(monkeypatch, module, {"id": "canvas-1"}) + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: False) + res = _run(inspect.unwrap(module.reset)()) + assert res["code"] == module.RetCode.OPERATING_ERROR + + _set_request_json(monkeypatch, module, {"id": "canvas-1"}) + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (False, None)) + res = _run(inspect.unwrap(module.reset)()) + assert res["message"] == "canvas not found." + + class _ResetCanvas: + def __init__(self, *_args, **_kwargs): + self.reset_called = False + + def reset(self): + self.reset_called = True + + def __str__(self): + return '{"v": 2}' + + updates = [] + _set_request_json(monkeypatch, module, {"id": "canvas-1"}) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (True, SimpleNamespace(id="canvas-1", dsl={"v": 1}))) + monkeypatch.setattr(module.UserCanvasService, "update_by_id", lambda canvas_id, payload: updates.append((canvas_id, payload))) + monkeypatch.setattr(module, "Canvas", _ResetCanvas) + res = _run(inspect.unwrap(module.reset)()) + assert res["code"] == module.RetCode.SUCCESS + assert res["data"] == {"v": 2} + assert updates == [("canvas-1", {"dsl": {"v": 2}})] + + _set_request_json(monkeypatch, module, {"id": "canvas-1"}) + monkeypatch.setattr(module, "Canvas", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("reset boom"))) + res = _run(inspect.unwrap(module.reset)()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "reset boom" in res["message"] + + monkeypatch.setattr(module.UserCanvasService, "get_by_canvas_id", lambda _canvas_id: (False, None)) + monkeypatch.setattr(module, "request", _DummyRequest(args=_Args({"url": "http://example.com"}), files=_FileMap())) + res = _run(module.upload("canvas-1")) + assert res["message"] == "canvas not found." + + monkeypatch.setattr(module.UserCanvasService, "get_by_canvas_id", lambda _canvas_id: (True, {"user_id": "tenant-1"})) + monkeypatch.setattr( + module, + "request", + _DummyRequest( + args=_Args({"url": "http://example.com"}), + files=_FileMap({"file": ["file-1"]}), + ), + ) + monkeypatch.setattr(module.FileService, "upload_info", lambda user_id, file_obj, url=None: {"uid": user_id, "file": file_obj, "url": url}) + res = _run(module.upload("canvas-1")) + assert res["data"]["url"] == "http://example.com" + + monkeypatch.setattr( + module, + "request", + _DummyRequest( + args=_Args({"url": "http://example.com"}), + files=_FileMap({"file": ["f1", "f2"]}), + ), + ) + monkeypatch.setattr(module.FileService, "upload_info", lambda user_id, file_obj, url=None: {"uid": user_id, "file": file_obj, "url": url}) + res = _run(module.upload("canvas-1")) + assert len(res["data"]) == 2 + + monkeypatch.setattr(module.FileService, "upload_info", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("upload boom"))) + res = _run(module.upload("canvas-1")) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "upload boom" in res["message"] + + monkeypatch.setattr(module, "request", _DummyRequest(args=_Args({"id": "canvas-1", "component_id": "begin"}))) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (False, None)) + res = module.input_form() + assert res["message"] == "canvas not found." + + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (True, SimpleNamespace(id="canvas-1", dsl={"n": 1}))) + monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: []) + res = module.input_form() + assert res["code"] == module.RetCode.OPERATING_ERROR + + class _InputCanvas: + def __init__(self, *_args, **_kwargs): + pass + + def get_component_input_form(self, component_id): + return {"component_id": component_id} + + monkeypatch.setattr(module.UserCanvasService, "query", lambda **_kwargs: [object()]) + monkeypatch.setattr(module, "Canvas", _InputCanvas) + res = module.input_form() + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["component_id"] == "begin" + + monkeypatch.setattr(module, "Canvas", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("input boom"))) + res = module.input_form() + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "input boom" in res["message"] + + _set_request_json( + monkeypatch, + module, + {"id": "canvas-1", "component_id": "llm-node", "params": {"p": {"value": "v"}}}, + ) + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: False) + res = _run(inspect.unwrap(module.debug)()) + assert res["code"] == module.RetCode.OPERATING_ERROR + + class _DebugComponent(module.LLM): + def __init__(self): + self.reset_called = False + self.debug_inputs = None + self.invoked = None + + def reset(self): + self.reset_called = True + + def set_debug_inputs(self, params): + self.debug_inputs = params + + def invoke(self, **kwargs): + self.invoked = kwargs + + def output(self): + async def _gen(): + yield "A" + yield "B" + + return {"stream": partial(_gen)} + + class _DebugCanvas: + last_component = None + + def __init__(self, *_args, **_kwargs): + self.message_id = "" + self._component = _DebugComponent() + _DebugCanvas.last_component = self._component + + def reset(self): + return None + + def get_component(self, _component_id): + return {"obj": self._component} + + _set_request_json( + monkeypatch, + module, + {"id": "canvas-1", "component_id": "llm-node", "params": {"p": {"value": "v"}}}, + ) + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (True, SimpleNamespace(id="canvas-1", dsl={"n": 1}))) + monkeypatch.setattr(module, "get_uuid", lambda: "msg-1") + monkeypatch.setattr(module, "Canvas", _DebugCanvas) + res = _run(inspect.unwrap(module.debug)()) + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["stream"] == "AB" + assert _DebugCanvas.last_component.reset_called is True + assert _DebugCanvas.last_component.debug_inputs == {"p": {"value": "v"}} + assert _DebugCanvas.last_component.invoked == {"p": "v"} + + +@pytest.mark.p2 +def test_debug_sync_iter_and_exception_matrix_unit(monkeypatch): + module = _load_canvas_module(monkeypatch) + + class _SyncDebugComponent(module.LLM): + def __init__(self): + self.invoked = {} + + def reset(self): + return None + + def set_debug_inputs(self, _params): + return None + + def invoke(self, **kwargs): + self.invoked = kwargs + + def output(self): + def _gen(): + yield "S" + yield "Y" + yield "N" + yield "C" + + return {"stream": partial(_gen)} + + class _SyncDebugCanvas: + def __init__(self, *_args, **_kwargs): + self.message_id = "" + self.component = _SyncDebugComponent() + + def reset(self): + return None + + def get_component(self, _component_id): + return {"obj": self.component} + + _set_request_json( + monkeypatch, + module, + {"id": "canvas-1", "component_id": "sync-node", "params": {"p": {"value": "v"}}}, + ) + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (True, SimpleNamespace(id="canvas-1", dsl={"n": 1}))) + monkeypatch.setattr(module, "Canvas", _SyncDebugCanvas) + res = _run(inspect.unwrap(module.debug)()) + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["stream"] == "SYNC" + + monkeypatch.setattr(module, "Canvas", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("debug boom"))) + res = _run(inspect.unwrap(module.debug)()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "debug boom" in res["message"] + + +@pytest.mark.p2 +def test_test_db_connect_dialect_matrix_unit(monkeypatch): + module = _load_canvas_module(monkeypatch) + + class _FakeDB: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + self.connected = 0 + self.closed = 0 + + def connect(self): + self.connected += 1 + + def close(self): + self.closed += 1 + + mysql_objs = [] + postgres_objs = [] + + def _mysql_ctor(*args, **kwargs): + obj = _FakeDB(*args, **kwargs) + mysql_objs.append(obj) + return obj + + def _postgres_ctor(*args, **kwargs): + obj = _FakeDB(*args, **kwargs) + postgres_objs.append(obj) + return obj + + monkeypatch.setattr(module, "MySQLDatabase", _mysql_ctor) + monkeypatch.setattr(module, "PostgresqlDatabase", _postgres_ctor) + + def _run_case(payload): + _set_request_json(monkeypatch, module, payload) + return _run(inspect.unwrap(module.test_db_connect)()) + + req_base = { + "database": "db", + "username": "user", + "host": "host", + "port": 3306, + "password": "pwd", + } + + res = _run_case({**req_base, "db_type": "mysql"}) + assert res["code"] == module.RetCode.SUCCESS + assert mysql_objs[-1].connected == 1 + assert mysql_objs[-1].closed == 1 + + res = _run_case({**req_base, "db_type": "mariadb"}) + assert res["code"] == module.RetCode.SUCCESS + assert mysql_objs[-1].connected == 1 + + res = _run_case({**req_base, "db_type": "oceanbase"}) + assert res["code"] == module.RetCode.SUCCESS + assert mysql_objs[-1].kwargs["charset"] == "utf8mb4" + + res = _run_case({**req_base, "db_type": "postgres"}) + assert res["code"] == module.RetCode.SUCCESS + assert postgres_objs[-1].closed == 1 + + mssql_calls = {} + + class _MssqlCursor: + def execute(self, sql): + mssql_calls["sql"] = sql + + def close(self): + mssql_calls["cursor_closed"] = True + + class _MssqlConn: + def cursor(self): + mssql_calls["cursor_opened"] = True + return _MssqlCursor() + + def close(self): + mssql_calls["conn_closed"] = True + + pyodbc_mod = ModuleType("pyodbc") + + def _pyodbc_connect(conn_str): + mssql_calls["conn_str"] = conn_str + return _MssqlConn() + + pyodbc_mod.connect = _pyodbc_connect + monkeypatch.setitem(sys.modules, "pyodbc", pyodbc_mod) + res = _run_case({**req_base, "db_type": "mssql"}) + assert res["code"] == module.RetCode.SUCCESS + assert "DRIVER={ODBC Driver 17 for SQL Server}" in mssql_calls["conn_str"] + assert mssql_calls["sql"] == "SELECT 1" + + ibm_calls = {} + ibm_db_mod = ModuleType("ibm_db") + + def _ibm_connect(conn_str, *_args): + ibm_calls["conn_str"] = conn_str + return "ibm-conn" + + def _ibm_exec_immediate(conn, sql): + ibm_calls["exec"] = (conn, sql) + return "ibm-stmt" + + ibm_db_mod.connect = _ibm_connect + ibm_db_mod.exec_immediate = _ibm_exec_immediate + ibm_db_mod.fetch_assoc = lambda stmt: ibm_calls.update({"fetch": stmt}) or {"one": 1} + ibm_db_mod.close = lambda conn: ibm_calls.update({"close": conn}) + monkeypatch.setitem(sys.modules, "ibm_db", ibm_db_mod) + res = _run_case({**req_base, "db_type": "IBM DB2"}) + assert res["code"] == module.RetCode.SUCCESS + assert ibm_calls["exec"] == ("ibm-conn", "SELECT 1 FROM sysibm.sysdummy1") + + monkeypatch.setitem(sys.modules, "trino", None) + res = _run_case({**req_base, "db_type": "trino", "database": "catalog.schema"}) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "Missing dependency 'trino'" in res["message"] + + trino_calls = {"connect": [], "auth": []} + + class _TrinoCursor: + def execute(self, sql): + trino_calls["sql"] = sql + + def fetchall(self): + trino_calls["fetched"] = True + return [(1,)] + + def close(self): + trino_calls["cursor_closed"] = True + + class _TrinoConn: + def cursor(self): + return _TrinoCursor() + + def close(self): + trino_calls["conn_closed"] = True + + trino_mod = ModuleType("trino") + trino_mod.BasicAuthentication = lambda user, password: trino_calls["auth"].append((user, password)) or ("auth", user) + trino_mod.dbapi = SimpleNamespace(connect=lambda **kwargs: trino_calls["connect"].append(kwargs) or _TrinoConn()) + monkeypatch.setitem(sys.modules, "trino", trino_mod) + + res = _run_case({**req_base, "db_type": "trino", "database": ""}) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "catalog.schema" in res["message"] + + monkeypatch.setenv("TRINO_USE_TLS", "1") + res = _run_case({**req_base, "db_type": "trino", "database": "cat.schema"}) + assert res["code"] == module.RetCode.SUCCESS + assert trino_calls["connect"][-1]["catalog"] == "cat" + assert trino_calls["connect"][-1]["schema"] == "schema" + assert trino_calls["auth"][-1] == ("user", "pwd") + + res = _run_case({**req_base, "db_type": "trino", "database": "cat/schema"}) + assert res["code"] == module.RetCode.SUCCESS + assert trino_calls["connect"][-1]["catalog"] == "cat" + assert trino_calls["connect"][-1]["schema"] == "schema" + + res = _run_case({**req_base, "db_type": "trino", "database": "catalog"}) + assert res["code"] == module.RetCode.SUCCESS + assert trino_calls["connect"][-1]["catalog"] == "catalog" + assert trino_calls["connect"][-1]["schema"] == "default" + + res = _run_case({**req_base, "db_type": "unknown"}) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "Unsupported database type." in res["message"] + + class _BoomDB(_FakeDB): + def connect(self): + raise RuntimeError("connect boom") + + monkeypatch.setattr(module, "MySQLDatabase", lambda *_args, **_kwargs: _BoomDB()) + res = _run_case({**req_base, "db_type": "mysql"}) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "connect boom" in res["message"] + + +@pytest.mark.p2 +def test_canvas_history_list_and_setting_matrix_unit(monkeypatch): + module = _load_canvas_module(monkeypatch) + + class _Version: + def __init__(self, version_id, update_time): + self.version_id = version_id + self.update_time = update_time + + def to_dict(self): + return {"id": self.version_id, "update_time": self.update_time} + + monkeypatch.setattr( + module.UserCanvasVersionService, + "list_by_canvas_id", + lambda _canvas_id: [_Version("v1", 1), _Version("v2", 5)], + ) + res = module.getlistversion("canvas-1") + assert [item["id"] for item in res["data"]] == ["v2", "v1"] + + monkeypatch.setattr( + module.UserCanvasVersionService, + "list_by_canvas_id", + lambda _canvas_id: (_ for _ in ()).throw(RuntimeError("history boom")), + ) + res = module.getlistversion("canvas-1") + assert "Error getting history files: history boom" in res["message"] + + monkeypatch.setattr( + module.UserCanvasVersionService, + "get_by_id", + lambda _version_id: (True, _Version("v3", 3)), + ) + res = module.getversion("v3") + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["id"] == "v3" + + monkeypatch.setattr( + module.UserCanvasVersionService, + "get_by_id", + lambda _version_id: (_ for _ in ()).throw(RuntimeError("version boom")), + ) + res = module.getversion("v3") + assert "Error getting history file: version boom" in res["data"] + + list_calls = [] + + def _get_by_tenant_ids(tenants, user_id, page_number, page_size, orderby, desc, keywords, canvas_category): + list_calls.append((tenants, user_id, page_number, page_size, orderby, desc, keywords, canvas_category)) + return [{"id": "canvas-1"}], 1 + + monkeypatch.setattr(module.UserCanvasService, "get_by_tenant_ids", _get_by_tenant_ids) + monkeypatch.setattr( + module.TenantService, + "get_joined_tenants_by_user_id", + lambda _user_id: [{"tenant_id": "t1"}, {"tenant_id": "t2"}], + ) + + monkeypatch.setattr( + module, + "request", + _DummyRequest( + args=_Args( + { + "keywords": "kw", + "page": "2", + "page_size": "3", + "orderby": "update_time", + "canvas_category": "agent", + "desc": "false", + } + ) + ), + ) + res = module.list_canvas() + assert res["code"] == module.RetCode.SUCCESS + assert list_calls[-1][0] == ["t1", "t2", "user-1"] + assert list_calls[-1][2:6] == (2, 3, "update_time", False) + + monkeypatch.setattr(module, "request", _DummyRequest(args=_Args({"owner_ids": "u1,u2", "desc": "true"}))) + res = module.list_canvas() + assert res["code"] == module.RetCode.SUCCESS + assert list_calls[-1][0] == ["u1", "u2"] + assert list_calls[-1][2:4] == (0, 0) + assert list_calls[-1][5] is True + + _set_request_json(monkeypatch, module, {"id": "canvas-1", "title": "T", "permission": "private"}) + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: False) + res = _run(inspect.unwrap(module.setting)()) + assert res["code"] == module.RetCode.OPERATING_ERROR + + _set_request_json(monkeypatch, module, {"id": "canvas-1", "title": "T", "permission": "private"}) + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (False, None)) + res = _run(inspect.unwrap(module.setting)()) + assert res["message"] == "canvas not found." + + updates = [] + _set_request_json( + monkeypatch, + module, + { + "id": "canvas-1", + "title": "New title", + "permission": "private", + "description": "new desc", + "avatar": "avatar.png", + }, + ) + monkeypatch.setattr( + module.UserCanvasService, + "get_by_id", + lambda _canvas_id: (True, SimpleNamespace(to_dict=lambda: {"id": "canvas-1", "title": "Old"})), + ) + monkeypatch.setattr(module.UserCanvasService, "update_by_id", lambda canvas_id, payload: updates.append((canvas_id, payload)) or 2) + res = _run(inspect.unwrap(module.setting)()) + assert res["code"] == module.RetCode.SUCCESS + assert res["data"] == 2 + assert updates[-1][0] == "canvas-1" + assert updates[-1][1]["title"] == "New title" + assert updates[-1][1]["description"] == "new desc" + assert updates[-1][1]["permission"] == "private" + assert updates[-1][1]["avatar"] == "avatar.png" + + +@pytest.mark.p2 +def test_trace_and_sessions_matrix_unit(monkeypatch): + module = _load_canvas_module(monkeypatch) + + monkeypatch.setattr(module, "request", _DummyRequest(args=_Args({"canvas_id": "c1", "message_id": "m1"}))) + monkeypatch.setattr(module.REDIS_CONN, "get", lambda _key: None) + res = module.trace() + assert res["code"] == module.RetCode.SUCCESS + assert res["data"] == {} + + monkeypatch.setattr(module.REDIS_CONN, "get", lambda _key: '{"event":"ok"}') + res = module.trace() + assert res["code"] == module.RetCode.SUCCESS + assert res["data"] == {"event": "ok"} + + monkeypatch.setattr(module.REDIS_CONN, "get", lambda _key: (_ for _ in ()).throw(RuntimeError("trace boom"))) + res = module.trace() + assert res is None + + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: False) + monkeypatch.setattr(module, "request", _DummyRequest(args=_Args({}))) + res = module.sessions("canvas-1") + assert res["code"] == module.RetCode.OPERATING_ERROR + + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module, "request", _DummyRequest(args=_Args({"desc": "false", "exp_user_id": "exp-1"}))) + monkeypatch.setattr(module.API4ConversationService, "get_names", lambda _canvas_id, _exp_user_id: [{"id": "s1"}, {"id": "s2"}]) + res = module.sessions("canvas-1") + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["total"] == 2 + + list_calls = [] + + def _get_list(*args, **kwargs): + list_calls.append((args, kwargs)) + return 7, [{"id": "s3"}] + + monkeypatch.setattr(module.API4ConversationService, "get_list", _get_list) + monkeypatch.setattr( + module, + "request", + _DummyRequest(args=_Args({"page": "3", "page_size": "9", "orderby": "update_time", "dsl": "false"})), + ) + res = module.sessions("canvas-1") + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["total"] == 7 + assert list_calls[-1][0][4] == "update_time" + assert list_calls[-1][0][5] is True + assert list_calls[-1][0][8] is False + + monkeypatch.setattr(module, "get_json_result", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("result boom"))) + res = module.sessions("canvas-1") + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "result boom" in res["message"] + + +@pytest.mark.p2 +def test_session_crud_prompts_and_download_matrix_unit(monkeypatch): + module = _load_canvas_module(monkeypatch) + + class _SessionCanvas: + def __init__(self, *_args, **_kwargs): + self.reset_called = False + + def reset(self): + self.reset_called = True + + _set_request_json(monkeypatch, module, {"name": "Sess1"}) + monkeypatch.setattr(module.UserCanvasService, "get_by_id", lambda _canvas_id: (True, SimpleNamespace(id="canvas-1", dsl={"n": 1}))) + monkeypatch.setattr(module, "Canvas", _SessionCanvas) + monkeypatch.setattr(module, "get_uuid", lambda: "sess-1") + saved = [] + monkeypatch.setattr(module.API4ConversationService, "save", lambda **kwargs: saved.append(kwargs)) + res = _run(inspect.unwrap(module.set_session)("canvas-1")) + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["id"] == "sess-1" + assert isinstance(res["data"]["dsl"], str) + assert saved and saved[-1]["id"] == "sess-1" + + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: False) + res = module.get_session("canvas-1", "sess-1") + assert res["code"] == module.RetCode.OPERATING_ERROR + + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.API4ConversationService, "get_by_id", lambda _session_id: (True, SimpleNamespace(to_dict=lambda: {"id": _session_id}))) + res = module.get_session("canvas-1", "sess-1") + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["id"] == "sess-1" + + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: False) + res = module.del_session("canvas-1", "sess-1") + assert res["code"] == module.RetCode.OPERATING_ERROR + + monkeypatch.setattr(module.UserCanvasService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.API4ConversationService, "delete_by_id", lambda _session_id: _session_id == "sess-1") + res = module.del_session("canvas-1", "sess-1") + assert res["code"] == module.RetCode.SUCCESS + assert res["data"] is True + + rag_prompts_pkg = ModuleType("rag.prompts") + rag_prompts_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "rag.prompts", rag_prompts_pkg) + rag_generator_mod = ModuleType("rag.prompts.generator") + rag_generator_mod.ANALYZE_TASK_SYSTEM = "SYS" + rag_generator_mod.ANALYZE_TASK_USER = "USER" + rag_generator_mod.NEXT_STEP = "NEXT" + rag_generator_mod.REFLECT = "REFLECT" + rag_generator_mod.CITATION_PROMPT_TEMPLATE = "CITE" + monkeypatch.setitem(sys.modules, "rag.prompts.generator", rag_generator_mod) + + res = module.prompts() + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["task_analysis"] == "SYS\n\nUSER" + assert res["data"]["plan_generation"] == "NEXT" + assert res["data"]["reflection"] == "REFLECT" + assert res["data"]["citation_guidelines"] == "CITE" + + monkeypatch.setattr(module, "request", _DummyRequest(args=_Args({"id": "f1", "created_by": "u1"}))) + monkeypatch.setattr(module.FileService, "get_blob", lambda _created_by, _id: b"blob-data") + res = _run(module.download()) + assert res == {"blob": b"blob-data"} diff --git a/test/testcases/test_web_api/test_chunk_app/test_chunk_routes_unit.py b/test/testcases/test_web_api/test_chunk_app/test_chunk_routes_unit.py index 1361f9df56..fbc447db42 100644 --- a/test/testcases/test_web_api/test_chunk_app/test_chunk_routes_unit.py +++ b/test/testcases/test_web_api/test_chunk_app/test_chunk_routes_unit.py @@ -17,6 +17,7 @@ import asyncio import base64 import importlib.util +import json import sys from pathlib import Path from types import ModuleType, SimpleNamespace @@ -71,6 +72,7 @@ class _DummyRetCode: SUCCESS = 0 DATA_ERROR = 102 EXCEPTION_ERROR = 100 + OPERATING_ERROR = 103 class _DummyParserType: @@ -204,6 +206,7 @@ def _load_chunk_module(monkeypatch): class _DummyLLMType: EMBEDDING = SimpleNamespace(value="embedding") CHAT = SimpleNamespace(value="chat") + RERANK = SimpleNamespace(value="rerank") constants_mod.RetCode = _DummyRetCode constants_mod.LLMType = _DummyLLMType @@ -365,6 +368,11 @@ def _set_request_json(monkeypatch, module, payload): monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue(payload)) +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + @pytest.mark.p2 def test_list_chunk_exception_branches_unit(monkeypatch): module = _load_chunk_module(monkeypatch) @@ -663,7 +671,180 @@ def test_create_chunk_guards_pagerank_and_success_unit(monkeypatch): ) res = _run(module.create()) assert res["code"] == 0, res + assert res["data"]["chunk_id"], res assert module.settings.docStoreConn.inserted, "insert should be called" inserted = module.settings.docStoreConn.inserted[-1] assert "pagerank_flt" in inserted assert module.DocumentService.increment_calls, "increment_chunk_num should be called" + + async def _raise_thread_pool(_func): + raise RuntimeError("create tp boom") + + monkeypatch.setattr(module, "thread_pool_exec", _raise_thread_pool) + _set_request_json(monkeypatch, module, {"doc_id": "doc-1", "content_with_weight": "chunk"}) + res = _run(module.create()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR, res + assert "create tp boom" in res["message"], res + + +@pytest.mark.p2 +def test_retrieval_test_branch_matrix_unit(monkeypatch): + module = _load_chunk_module(monkeypatch) + module.request = SimpleNamespace(headers={"X-Request-ID": "req-r"}, args={}) + + applied_filters = [] + llm_calls = [] + cross_calls = [] + keyword_calls = [] + + async def _apply_filter(meta_data_filter, metas, question, chat_mdl, local_doc_ids): + applied_filters.append( + { + "meta_data_filter": meta_data_filter, + "metas": metas, + "question": question, + "chat_mdl": chat_mdl, + "local_doc_ids": list(local_doc_ids), + } + ) + return ["doc-filtered"] + + async def _cross_languages(_tenant_id, _dialog, question, langs): + cross_calls.append((question, tuple(langs))) + return f"{question}-xl" + + async def _keyword_extraction(_chat_mdl, question): + keyword_calls.append(question) + return "-kw" + + class _Retriever: + def __init__(self, mode="ok"): + self.mode = mode + self.retrieval_questions = [] + + async def retrieval(self, question, *_args, **_kwargs): + if self.mode == "not_found": + raise Exception("boom not_found boom") + if self.mode == "explode": + raise RuntimeError("retrieval boom") + self.retrieval_questions.append(question) + return {"chunks": [{"id": "c1", "vector": [0.1], "content_with_weight": "chunk-content"}]} + + def retrieval_by_children(self, chunks, _tenant_ids): + return list(chunks) + + class _KgRetriever: + async def retrieval(self, *_args, **_kwargs): + return {"id": "kg-1", "content_with_weight": "kg-content"} + + class _NoContentKgRetriever: + async def retrieval(self, *_args, **_kwargs): + return {"id": "kg-2", "content_with_weight": ""} + + monkeypatch.setattr(module, "LLMBundle", lambda *args, **kwargs: llm_calls.append((args, kwargs)) or SimpleNamespace()) + monkeypatch.setattr(module.DocMetadataService, "get_flatted_meta_by_kbs", lambda _kb_ids: [{"meta": "v"}], raising=False) + monkeypatch.setattr(module, "apply_meta_data_filter", _apply_filter) + monkeypatch.setattr(module.SearchService, "get_detail", lambda _sid: {"search_config": {"meta_data_filter": {"method": "auto"}, "chat_id": "chat-1"}}, raising=False) + monkeypatch.setattr(module, "cross_languages", _cross_languages) + monkeypatch.setattr(module, "keyword_extraction", _keyword_extraction) + monkeypatch.setattr(module, "label_question", lambda *_args, **_kwargs: ["lbl"]) + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [_DummyTenant("tenant-1")]) + + monkeypatch.setattr(module.KnowledgebaseService, "query", lambda **_kwargs: False, raising=False) + _set_request_json(monkeypatch, module, {"kb_id": "kb-1", "question": "q", "search_id": "search-1"}) + res = _run(module.retrieval_test()) + assert res["code"] == module.RetCode.OPERATING_ERROR, res + assert "Only owner of dataset authorized for this operation." in res["message"], res + assert applied_filters and applied_filters[-1]["meta_data_filter"]["method"] == "auto" + assert llm_calls, "search_id metadata auto branch should instantiate chat model" + + _set_request_json(monkeypatch, module, {"kb_id": [], "question": "q"}) + res = _run(module.retrieval_test()) + assert res["code"] == module.RetCode.DATA_ERROR, res + assert "Please specify dataset firstly." in res["message"], res + + monkeypatch.setattr(module.KnowledgebaseService, "query", lambda **_kwargs: True, raising=False) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (False, None), raising=False) + _set_request_json( + monkeypatch, + module, + {"kb_id": ["kb-1"], "question": "q", "meta_data_filter": {"method": "semi_auto"}}, + ) + res = _run(module.retrieval_test()) + assert res["code"] == module.RetCode.DATA_ERROR, res + assert "Knowledgebase not found!" in res["message"], res + + retriever = _Retriever(mode="ok") + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (True, SimpleNamespace(tenant_id="tenant-kb", embd_id="embd-1")), raising=False) + monkeypatch.setattr(module.settings, "retriever", retriever) + monkeypatch.setattr(module.settings, "kg_retriever", _KgRetriever(), raising=False) + _set_request_json( + monkeypatch, + module, + { + "kb_id": ["kb-1"], + "question": "q", + "cross_languages": ["fr"], + "rerank_id": "rerank-1", + "keyword": True, + "use_kg": True, + }, + ) + res = _run(module.retrieval_test()) + assert res["code"] == 0, res + assert cross_calls[-1] == ("q", ("fr",)) + assert keyword_calls[-1] == "q-xl" + assert retriever.retrieval_questions[-1] == "q-xl-kw" + assert res["data"]["chunks"][0]["id"] == "kg-1", res + assert all("vector" not in chunk for chunk in res["data"]["chunks"]) + + monkeypatch.setattr(module.settings, "kg_retriever", _NoContentKgRetriever(), raising=False) + _set_request_json(monkeypatch, module, {"kb_id": ["kb-1"], "question": "q", "use_kg": True}) + res = _run(module.retrieval_test()) + assert res["code"] == 0, res + assert res["data"]["chunks"][0]["id"] == "c1", res + + monkeypatch.setattr(module.settings, "retriever", _Retriever(mode="not_found")) + _set_request_json(monkeypatch, module, {"kb_id": ["kb-1"], "question": "q"}) + res = _run(module.retrieval_test()) + assert res["code"] == module.RetCode.DATA_ERROR, res + assert "No chunk found! Check the chunk status please!" in res["message"], res + + monkeypatch.setattr(module.settings, "retriever", _Retriever(mode="explode")) + _set_request_json(monkeypatch, module, {"kb_id": ["kb-1"], "question": "q"}) + res = _run(module.retrieval_test()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR, res + assert "retrieval boom" in res["message"], res + + +@pytest.mark.p2 +def test_knowledge_graph_repeat_deal_matrix_unit(monkeypatch): + module = _load_chunk_module(monkeypatch) + module.request = SimpleNamespace(args={"doc_id": "doc-1"}, headers={}) + + payload = { + "id": "root", + "children": [ + {"id": "dup"}, + {"id": "dup", "children": [{"id": "dup"}]}, + ], + } + + class _SRes: + ids = ["bad-json", "mind-map"] + field = { + "bad-json": {"knowledge_graph_kwd": "graph", "content_with_weight": "{bad json"}, + "mind-map": {"knowledge_graph_kwd": "mind_map", "content_with_weight": json.dumps(payload)}, + } + + async def _search(*_args, **_kwargs): + return _SRes() + + monkeypatch.setattr(module.settings.retriever, "search", _search) + res = _run(module.knowledge_graph()) + assert res["code"] == 0, res + assert res["data"]["graph"] == {}, res + mind_map = res["data"]["mind_map"] + assert mind_map["children"][0]["id"] == "dup", res + assert mind_map["children"][1]["id"] == "dup(1)", res + assert mind_map["children"][1]["children"][0]["id"] == "dup(2)", res diff --git a/test/testcases/test_web_api/test_connector_app/test_connector_routes_unit.py b/test/testcases/test_web_api/test_connector_app/test_connector_routes_unit.py new file mode 100644 index 0000000000..40500e7b0c --- /dev/null +++ b/test/testcases/test_web_api/test_connector_app/test_connector_routes_unit.py @@ -0,0 +1,711 @@ +# +# 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 json +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +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 _Args(dict): + def get(self, key, default=None, type=None): + value = super().get(key, default) + if type is None: + return value + try: + return type(value) + except (TypeError, ValueError): + return default + + def to_dict(self, flat=True): + return dict(self) + + +class _FakeResponse: + def __init__(self, body, status_code): + self.body = body + self.status_code = status_code + self.headers = {} + + +class _FakeConnectorRecord: + def __init__(self, payload): + self._payload = payload + + def to_dict(self): + return dict(self._payload) + + +class _FakeCredentials: + def __init__(self, raw='{"refresh_token":"rt","access_token":"at"}'): + self._raw = raw + + def to_json(self): + return self._raw + + +class _FakeFlow: + def __init__(self, client_config, scopes): + self.client_config = client_config + self.scopes = scopes + self.redirect_uri = None + self.credentials = _FakeCredentials() + self.auth_kwargs = None + self.token_code = None + + def authorization_url(self, **kwargs): + self.auth_kwargs = dict(kwargs) + return f"https://oauth.example/{kwargs['state']}", kwargs["state"] + + def fetch_token(self, code): + self.token_code = code + + +class _FakeBoxToken: + def __init__(self, access_token, refresh_token): + self.access_token = access_token + self.refresh_token = refresh_token + + +class _FakeBoxOAuth: + def __init__(self, config): + self.config = config + self.exchange_code = None + + def get_authorize_url(self, options): + return f"https://box.example/auth?state={options.state}&redirect={options.redirect_uri}" + + def get_tokens_authorization_code_grant(self, code): + self.exchange_code = code + + def retrieve_token(self): + return _FakeBoxToken("box-access", "box-refresh") + + +class _FakeRedis: + def __init__(self): + self.store = {} + self.set_calls = [] + self.deleted = [] + + def get(self, key): + return self.store.get(key) + + def set_obj(self, key, obj, ttl): + self.set_calls.append((key, obj, ttl)) + self.store[key] = json.dumps(obj) + + def delete(self, key): + self.deleted.append(key) + self.store.pop(key, None) + + +def _run(coro): + return asyncio.run(coro) + + +def _set_request(module, *, args=None, json_body=None): + module.request = SimpleNamespace( + args=_Args(args or {}), + json=_AwaitableValue({} if json_body is None else json_body), + ) + + +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + +def _load_connector_app(monkeypatch): + repo_root = Path(__file__).resolve().parents[4] + + api_pkg = ModuleType("api") + api_pkg.__path__ = [str(repo_root / "api")] + monkeypatch.setitem(sys.modules, "api", api_pkg) + + apps_mod = ModuleType("api.apps") + apps_mod.__path__ = [str(repo_root / "api" / "apps")] + apps_mod.current_user = SimpleNamespace(id="tenant-1") + apps_mod.login_required = lambda fn: fn + monkeypatch.setitem(sys.modules, "api.apps", apps_mod) + + db_mod = ModuleType("api.db") + db_mod.InputType = SimpleNamespace(POLL="POLL") + monkeypatch.setitem(sys.modules, "api.db", db_mod) + + services_pkg = ModuleType("api.db.services") + services_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.db.services", services_pkg) + + connector_service_mod = ModuleType("api.db.services.connector_service") + + class _StubConnectorService: + @staticmethod + def update_by_id(*_args, **_kwargs): + return True + + @staticmethod + def save(**_kwargs): + return True + + @staticmethod + def get_by_id(_connector_id): + return True, _FakeConnectorRecord({"id": _connector_id}) + + @staticmethod + def list(_tenant_id): + return [] + + @staticmethod + def resume(*_args, **_kwargs): + return True + + @staticmethod + def rebuild(*_args, **_kwargs): + return None + + @staticmethod + def delete_by_id(*_args, **_kwargs): + return True + + class _StubSyncLogsService: + @staticmethod + def list_sync_tasks(*_args, **_kwargs): + return [], 0 + + connector_service_mod.ConnectorService = _StubConnectorService + connector_service_mod.SyncLogsService = _StubSyncLogsService + monkeypatch.setitem(sys.modules, "api.db.services.connector_service", connector_service_mod) + + api_utils_mod = ModuleType("api.utils.api_utils") + + async def _get_request_json(): + return {} + + api_utils_mod.get_request_json = _get_request_json + api_utils_mod.get_json_result = lambda data=None, message="", code=0: { + "code": code, + "message": message, + "data": data, + } + api_utils_mod.get_data_error_result = lambda message="", code=400, data=None: { + "code": code, + "message": message, + "data": data, + } + api_utils_mod.validate_request = lambda *_args, **_kwargs: (lambda fn: fn) + monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod) + + constants_mod = ModuleType("common.constants") + constants_mod.RetCode = SimpleNamespace( + ARGUMENT_ERROR=101, + SERVER_ERROR=500, + RUNNING=102, + PERMISSION_ERROR=403, + ) + constants_mod.TaskStatus = SimpleNamespace(SCHEDULE="schedule", CANCEL="cancel") + monkeypatch.setitem(sys.modules, "common.constants", constants_mod) + + config_mod = ModuleType("common.data_source.config") + config_mod.GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI = "https://example.com/drive" + config_mod.GMAIL_WEB_OAUTH_REDIRECT_URI = "https://example.com/gmail" + config_mod.BOX_WEB_OAUTH_REDIRECT_URI = "https://example.com/box" + config_mod.DocumentSource = SimpleNamespace(GMAIL="gmail", GOOGLE_DRIVE="google-drive") + monkeypatch.setitem(sys.modules, "common.data_source.config", config_mod) + + google_constants_mod = ModuleType("common.data_source.google_util.constant") + google_constants_mod.WEB_OAUTH_POPUP_TEMPLATE = ( + "{title}" + "

{heading}

{message}

" + ) + google_constants_mod.GOOGLE_SCOPES = { + config_mod.DocumentSource.GMAIL: ["scope-gmail"], + config_mod.DocumentSource.GOOGLE_DRIVE: ["scope-drive"], + } + monkeypatch.setitem(sys.modules, "common.data_source.google_util.constant", google_constants_mod) + + misc_mod = ModuleType("common.misc_utils") + misc_mod.get_uuid = lambda: "uuid-from-helper" + monkeypatch.setitem(sys.modules, "common.misc_utils", misc_mod) + + rag_pkg = ModuleType("rag") + rag_pkg.__path__ = [str(repo_root / "rag")] + monkeypatch.setitem(sys.modules, "rag", rag_pkg) + + rag_utils_pkg = ModuleType("rag.utils") + rag_utils_pkg.__path__ = [str(repo_root / "rag" / "utils")] + monkeypatch.setitem(sys.modules, "rag.utils", rag_utils_pkg) + + redis_mod = ModuleType("rag.utils.redis_conn") + redis_mod.REDIS_CONN = _FakeRedis() + monkeypatch.setitem(sys.modules, "rag.utils.redis_conn", redis_mod) + + quart_mod = ModuleType("quart") + quart_mod.request = SimpleNamespace(args=_Args(), json=_AwaitableValue({})) + + async def _make_response(body, status_code): + return _FakeResponse(body, status_code) + + quart_mod.make_response = _make_response + monkeypatch.setitem(sys.modules, "quart", quart_mod) + + google_pkg = ModuleType("google_auth_oauthlib") + google_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "google_auth_oauthlib", google_pkg) + + google_flow_mod = ModuleType("google_auth_oauthlib.flow") + + class _StubFlow: + @classmethod + def from_client_config(cls, client_config, scopes): + return _FakeFlow(client_config, scopes) + + google_flow_mod.Flow = _StubFlow + monkeypatch.setitem(sys.modules, "google_auth_oauthlib.flow", google_flow_mod) + + box_mod = ModuleType("box_sdk_gen") + + class _OAuthConfig: + def __init__(self, client_id, client_secret): + self.client_id = client_id + self.client_secret = client_secret + + class _GetAuthorizeUrlOptions: + def __init__(self, redirect_uri, state): + self.redirect_uri = redirect_uri + self.state = state + + box_mod.BoxOAuth = _FakeBoxOAuth + box_mod.OAuthConfig = _OAuthConfig + box_mod.GetAuthorizeUrlOptions = _GetAuthorizeUrlOptions + monkeypatch.setitem(sys.modules, "box_sdk_gen", box_mod) + + module_path = repo_root / "api" / "apps" / "connector_app.py" + spec = importlib.util.spec_from_file_location("test_connector_routes_unit", module_path) + module = importlib.util.module_from_spec(spec) + module.manager = _DummyManager() + spec.loader.exec_module(module) + return module + + +@pytest.mark.p2 +def test_connector_basic_routes_and_task_controls(monkeypatch): + module = _load_connector_app(monkeypatch) + + async def _no_sleep(_secs): + return None + + monkeypatch.setattr(module.asyncio, "sleep", _no_sleep) + + records = {"conn-1": _FakeConnectorRecord({"id": "conn-1", "source": "drive"})} + update_calls = [] + save_calls = [] + resume_calls = [] + delete_calls = [] + + monkeypatch.setattr(module.ConnectorService, "update_by_id", lambda cid, payload: update_calls.append((cid, payload))) + + def _save(**payload): + save_calls.append(payload) + records[payload["id"]] = _FakeConnectorRecord(payload) + + monkeypatch.setattr(module.ConnectorService, "save", _save) + monkeypatch.setattr(module.ConnectorService, "get_by_id", lambda cid: (True, records[cid])) + monkeypatch.setattr(module.ConnectorService, "list", lambda tenant_id: [{"id": "listed", "tenant": tenant_id}]) + monkeypatch.setattr(module.SyncLogsService, "list_sync_tasks", lambda cid, page, page_size: ([{"id": "log-1"}], 9)) + monkeypatch.setattr(module.ConnectorService, "resume", lambda cid, status: resume_calls.append((cid, status))) + monkeypatch.setattr(module.ConnectorService, "delete_by_id", lambda cid: delete_calls.append(cid)) + monkeypatch.setattr(module, "get_uuid", lambda: "generated-id") + + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue({"id": "conn-1", "refresh_freq": 7, "config": {"x": 1}}), + ) + res = _run(module.set_connector()) + assert update_calls == [("conn-1", {"refresh_freq": 7, "config": {"x": 1}})] + assert res["data"]["id"] == "conn-1" + + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue({"name": "new", "source": "gmail", "config": {"y": 2}}), + ) + res = _run(module.set_connector()) + assert save_calls[-1]["id"] == "generated-id" + assert save_calls[-1]["tenant_id"] == "tenant-1" + assert save_calls[-1]["input_type"] == module.InputType.POLL + assert res["data"]["id"] == "generated-id" + + list_res = module.list_connector() + assert list_res["data"] == [{"id": "listed", "tenant": "tenant-1"}] + + monkeypatch.setattr(module.ConnectorService, "get_by_id", lambda _cid: (False, None)) + missing_res = module.get_connector("missing") + assert missing_res["message"] == "Can't find this Connector!" + + monkeypatch.setattr(module.ConnectorService, "get_by_id", lambda cid: (True, _FakeConnectorRecord({"id": cid}))) + found_res = module.get_connector("conn-2") + assert found_res["data"]["id"] == "conn-2" + + _set_request(module, args={"page": "2", "page_size": "7"}) + logs_res = module.list_logs("conn-log") + assert logs_res["data"] == {"total": 9, "logs": [{"id": "log-1"}]} + + monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"resume": True})) + assert _run(module.resume("conn-r1"))["data"] is True + + monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"resume": False})) + assert _run(module.resume("conn-r2"))["data"] is True + assert ("conn-r1", module.TaskStatus.SCHEDULE) in resume_calls + assert ("conn-r2", module.TaskStatus.CANCEL) in resume_calls + + monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"kb_id": "kb-1"})) + monkeypatch.setattr(module.ConnectorService, "rebuild", lambda *_args: "rebuild-failed") + failed_rebuild = _run(module.rebuild("conn-rb")) + assert failed_rebuild["code"] == module.RetCode.SERVER_ERROR + assert failed_rebuild["data"] is False + + monkeypatch.setattr(module.ConnectorService, "rebuild", lambda *_args: None) + ok_rebuild = _run(module.rebuild("conn-rb")) + assert ok_rebuild["data"] is True + + rm_res = module.rm_connector("conn-rm") + assert rm_res["data"] is True + assert ("conn-rm", module.TaskStatus.CANCEL) in resume_calls + assert delete_calls == ["conn-rm"] + + +@pytest.mark.p2 +def test_connector_oauth_helper_functions(monkeypatch): + module = _load_connector_app(monkeypatch) + + assert module._web_state_cache_key("flow-a", "gmail") == "gmail_web_flow_state:flow-a" + assert module._web_result_cache_key("flow-b", "google-drive") == "google-drive_web_flow_result:flow-b" + + creds_dict = {"web": {"client_id": "id"}} + assert module._load_credentials(creds_dict) == creds_dict + assert module._load_credentials(json.dumps(creds_dict)) == creds_dict + + with pytest.raises(ValueError, match="Invalid Google credentials JSON"): + module._load_credentials("{not-json") + + assert module._get_web_client_config(creds_dict) == {"web": {"client_id": "id"}} + with pytest.raises(ValueError, match="must include a 'web'"): + module._get_web_client_config({"installed": {"client_id": "id"}}) + + popup_ok = _run(module._render_web_oauth_popup("flow-1", True, "done", "gmail")) + assert popup_ok.status_code == 200 + assert popup_ok.headers["Content-Type"] == "text/html; charset=utf-8" + assert "Authorization complete" in popup_ok.body + assert "ragflow-gmail-oauth" in popup_ok.body + + popup_error = _run(module._render_web_oauth_popup("flow-2", False, "", "google-drive")) + assert popup_error.status_code == 200 + assert "Authorization failed" in popup_error.body + assert "<denied>" in popup_error.body + + +@pytest.mark.p2 +def test_start_google_web_oauth_matrix(monkeypatch): + module = _load_connector_app(monkeypatch) + + redis = _FakeRedis() + monkeypatch.setattr(module, "REDIS_CONN", redis) + monkeypatch.setattr(module.time, "time", lambda: 1700000000) + + flow_calls = [] + + def _from_client_config(client_config, scopes): + flow = _FakeFlow(client_config, scopes) + flow_calls.append(flow) + return flow + + monkeypatch.setattr(module.Flow, "from_client_config", staticmethod(_from_client_config)) + + _set_request(module, args={"type": "invalid"}) + monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"credentials": "{}"})) + invalid_type = _run(module.start_google_web_oauth()) + assert invalid_type["code"] == module.RetCode.ARGUMENT_ERROR + + monkeypatch.setattr(module, "GMAIL_WEB_OAUTH_REDIRECT_URI", "") + _set_request(module, args={"type": "gmail"}) + missing_redirect = _run(module.start_google_web_oauth()) + assert missing_redirect["code"] == module.RetCode.SERVER_ERROR + + monkeypatch.setattr(module, "GMAIL_WEB_OAUTH_REDIRECT_URI", "https://example.com/gmail") + monkeypatch.setattr(module, "GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI", "https://example.com/drive") + + _set_request(module, args={"type": "google-drive"}) + monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"credentials": "{invalid-json"})) + invalid_credentials = _run(module.start_google_web_oauth()) + assert invalid_credentials["code"] == module.RetCode.ARGUMENT_ERROR + + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue({"credentials": json.dumps({"web": {"client_id": "id"}, "refresh_token": "rt"})}), + ) + has_refresh_token = _run(module.start_google_web_oauth()) + assert has_refresh_token["code"] == module.RetCode.ARGUMENT_ERROR + + monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"credentials": json.dumps({"installed": {"x": 1}})})) + missing_web = _run(module.start_google_web_oauth()) + assert missing_web["code"] == module.RetCode.ARGUMENT_ERROR + + ids = iter(["flow-gmail", "flow-drive"]) + monkeypatch.setattr(module.uuid, "uuid4", lambda: next(ids)) + + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue({"credentials": json.dumps({"web": {"client_id": "id", "client_secret": "secret"}})}), + ) + + _set_request(module, args={"type": "gmail"}) + gmail_ok = _run(module.start_google_web_oauth()) + assert gmail_ok["code"] == 0 + assert gmail_ok["data"]["flow_id"] == "flow-gmail" + assert gmail_ok["data"]["authorization_url"].endswith("flow-gmail") + + _set_request(module, args={}) + drive_ok = _run(module.start_google_web_oauth()) + assert drive_ok["code"] == 0 + assert drive_ok["data"]["flow_id"] == "flow-drive" + assert drive_ok["data"]["authorization_url"].endswith("flow-drive") + + assert any(call.scopes == module.GOOGLE_SCOPES[module.DocumentSource.GMAIL] for call in flow_calls) + assert any(call.scopes == module.GOOGLE_SCOPES[module.DocumentSource.GOOGLE_DRIVE] for call in flow_calls) + assert "gmail_web_flow_state:flow-gmail" in redis.store + assert "google-drive_web_flow_state:flow-drive" in redis.store + + +@pytest.mark.p2 +def test_google_web_oauth_callbacks_matrix(monkeypatch): + module = _load_connector_app(monkeypatch) + + flow_calls = [] + + def _from_client_config(client_config, scopes): + flow = _FakeFlow(client_config, scopes) + flow_calls.append(flow) + return flow + + monkeypatch.setattr(module.Flow, "from_client_config", staticmethod(_from_client_config)) + + callback_specs = [ + ( + module.google_gmail_web_oauth_callback, + "gmail", + module.GMAIL_WEB_OAUTH_REDIRECT_URI, + module.GOOGLE_SCOPES[module.DocumentSource.GMAIL], + ), + ( + module.google_drive_web_oauth_callback, + "google-drive", + module.GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI, + module.GOOGLE_SCOPES[module.DocumentSource.GOOGLE_DRIVE], + ), + ] + + for callback, source, expected_redirect, expected_scopes in callback_specs: + redis = _FakeRedis() + monkeypatch.setattr(module, "REDIS_CONN", redis) + + _set_request(module, args={}) + missing_state = _run(callback()) + assert "Missing OAuth state parameter." in missing_state.body + + _set_request(module, args={"state": "sid"}) + expired_state = _run(callback()) + assert "Authorization session expired" in expired_state.body + + redis.store[module._web_state_cache_key("sid", source)] = json.dumps({"user_id": "tenant-1"}) + _set_request(module, args={"state": "sid"}) + invalid_state = _run(callback()) + assert "Authorization session was invalid" in invalid_state.body + assert module._web_state_cache_key("sid", source) in redis.deleted + + redis.store[module._web_state_cache_key("sid", source)] = json.dumps({ + "user_id": "tenant-1", + "client_config": {"web": {"client_id": "cid"}}, + }) + _set_request(module, args={"state": "sid", "error": "denied", "error_description": "permission denied"}) + oauth_error = _run(callback()) + assert "permission denied" in oauth_error.body + + redis.store[module._web_state_cache_key("sid", source)] = json.dumps({ + "user_id": "tenant-1", + "client_config": {"web": {"client_id": "cid"}}, + }) + _set_request(module, args={"state": "sid"}) + missing_code = _run(callback()) + assert "Missing authorization code" in missing_code.body + + redis.store[module._web_state_cache_key("sid", source)] = json.dumps({ + "user_id": "tenant-1", + "client_config": {"web": {"client_id": "cid"}}, + }) + _set_request(module, args={"state": "sid", "code": "code-123"}) + success = _run(callback()) + assert "Authorization completed successfully." in success.body + + result_key = module._web_result_cache_key("sid", source) + assert result_key in redis.store + assert module._web_state_cache_key("sid", source) in redis.deleted + + assert flow_calls[-1].redirect_uri == expected_redirect + assert flow_calls[-1].scopes == expected_scopes + assert flow_calls[-1].token_code == "code-123" + + +@pytest.mark.p2 +def test_poll_google_web_result_matrix(monkeypatch): + module = _load_connector_app(monkeypatch) + redis = _FakeRedis() + monkeypatch.setattr(module, "REDIS_CONN", redis) + + _set_request(module, args={"type": "invalid"}, json_body={"flow_id": "flow-1"}) + invalid_type = _run(module.poll_google_web_result()) + assert invalid_type["code"] == module.RetCode.ARGUMENT_ERROR + + _set_request(module, args={"type": "gmail"}, json_body={"flow_id": "flow-1"}) + pending = _run(module.poll_google_web_result()) + assert pending["code"] == module.RetCode.RUNNING + + redis.store[module._web_result_cache_key("flow-1", "gmail")] = json.dumps( + {"user_id": "another-user", "credentials": "token-x"} + ) + _set_request(module, args={"type": "gmail"}, json_body={"flow_id": "flow-1"}) + permission_error = _run(module.poll_google_web_result()) + assert permission_error["code"] == module.RetCode.PERMISSION_ERROR + + redis.store[module._web_result_cache_key("flow-1", "gmail")] = json.dumps( + {"user_id": "tenant-1", "credentials": "token-ok"} + ) + _set_request(module, args={"type": "gmail"}, json_body={"flow_id": "flow-1"}) + success = _run(module.poll_google_web_result()) + assert success["code"] == 0 + assert success["data"] == {"credentials": "token-ok"} + assert module._web_result_cache_key("flow-1", "gmail") in redis.deleted + + +@pytest.mark.p2 +def test_box_oauth_start_callback_and_poll_matrix(monkeypatch): + module = _load_connector_app(monkeypatch) + redis = _FakeRedis() + monkeypatch.setattr(module, "REDIS_CONN", redis) + + created_auth = [] + + class _TrackingBoxOAuth(_FakeBoxOAuth): + def __init__(self, config): + super().__init__(config) + created_auth.append(self) + + monkeypatch.setattr(module, "BoxOAuth", _TrackingBoxOAuth) + monkeypatch.setattr(module.uuid, "uuid4", lambda: "flow-box") + monkeypatch.setattr(module.time, "time", lambda: 1800000000) + + monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({})) + missing_params = _run(module.start_box_web_oauth()) + assert missing_params["code"] == module.RetCode.ARGUMENT_ERROR + + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue({"client_id": "cid", "client_secret": "sec", "redirect_uri": "https://box.local/callback"}), + ) + start_ok = _run(module.start_box_web_oauth()) + assert start_ok["code"] == 0 + assert start_ok["data"]["flow_id"] == "flow-box" + assert "authorization_url" in start_ok["data"] + assert module._web_state_cache_key("flow-box", "box") in redis.store + + _set_request(module, args={}) + missing_state = _run(module.box_web_oauth_callback()) + assert "Missing OAuth parameters." in missing_state.body + + _set_request(module, args={"state": "flow-box"}) + missing_code = _run(module.box_web_oauth_callback()) + assert "Missing authorization code from Box." in missing_code.body + + redis.store[module._web_state_cache_key("flow-null", "box")] = "null" + _set_request(module, args={"state": "flow-null", "code": "abc"}) + invalid_session = _run(module.box_web_oauth_callback()) + assert invalid_session["code"] == module.RetCode.ARGUMENT_ERROR + + redis.store[module._web_state_cache_key("flow-box", "box")] = json.dumps( + {"user_id": "tenant-1", "client_id": "cid", "client_secret": "sec"} + ) + _set_request(module, args={"state": "flow-box", "code": "abc", "error": "access_denied", "error_description": "denied"}) + callback_error = _run(module.box_web_oauth_callback()) + assert "denied" in callback_error.body + + redis.store[module._web_state_cache_key("flow-ok", "box")] = json.dumps( + {"user_id": "tenant-1", "client_id": "cid", "client_secret": "sec"} + ) + _set_request(module, args={"state": "flow-ok", "code": "code-ok"}) + callback_success = _run(module.box_web_oauth_callback()) + assert "Authorization completed successfully." in callback_success.body + assert created_auth[-1].exchange_code == "code-ok" + assert module._web_result_cache_key("flow-ok", "box") in redis.store + assert module._web_state_cache_key("flow-ok", "box") in redis.deleted + + monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue({"flow_id": "flow-ok"})) + redis.store.pop(module._web_result_cache_key("flow-ok", "box"), None) + pending = _run(module.poll_box_web_result()) + assert pending["code"] == module.RetCode.RUNNING + + redis.store[module._web_result_cache_key("flow-ok", "box")] = json.dumps({"user_id": "another-user"}) + permission_error = _run(module.poll_box_web_result()) + assert permission_error["code"] == module.RetCode.PERMISSION_ERROR + + redis.store[module._web_result_cache_key("flow-ok", "box")] = json.dumps( + {"user_id": "tenant-1", "access_token": "at", "refresh_token": "rt"} + ) + poll_success = _run(module.poll_box_web_result()) + assert poll_success["code"] == 0 + assert poll_success["data"]["credentials"]["access_token"] == "at" + assert module._web_result_cache_key("flow-ok", "box") in redis.deleted diff --git a/test/testcases/test_web_api/test_conversation_app/test_conversation_routes_unit.py b/test/testcases/test_web_api/test_conversation_app/test_conversation_routes_unit.py index 294647541e..06caa19a4a 100644 --- a/test/testcases/test_web_api/test_conversation_app/test_conversation_routes_unit.py +++ b/test/testcases/test_web_api/test_conversation_app/test_conversation_routes_unit.py @@ -578,7 +578,189 @@ def test_sequence2txt_validation_and_transcription_paths(monkeypatch): @pytest.mark.p2 def test_tts_request_parse_entry(monkeypatch): module = _load_conversation_module(monkeypatch) - _set_request_json(monkeypatch, module, {"text": "hello"}) + _set_request_json(monkeypatch, module, {"text": "A。B"}) monkeypatch.setattr(module.TenantService, "get_info_by", lambda _uid: []) res = _run(module.tts()) assert res["message"] == "Tenant not found!" + + monkeypatch.setattr(module.TenantService, "get_info_by", lambda _uid: [{"tenant_id": "tenant-1", "tts_id": ""}]) + 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.TenantService, "get_info_by", lambda _uid: [{"tenant_id": "tenant-1", "tts_id": "tts-x"}]) + 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, llm_type, chat_id): + llm_calls["bundle"] = (tenant_id, llm_type, chat_id) + return _FakeChat() + + monkeypatch.setattr(module, "LLMBundle", _fake_bundle) + monkeypatch.setattr(module, "load_prompt", lambda name: f"prompt-{name}") + _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["bundle"][2] == "chat-x" + assert llm_calls["options"] == {"temperature": 0.2} + assert llm_calls["prompt"] == "prompt-related_question" + assert "Keywords: solar" in llm_calls["messages"][0]["content"] diff --git a/test/testcases/test_web_api/test_dataset_management/test_dataset_sdk_routes_unit.py b/test/testcases/test_web_api/test_dataset_management/test_dataset_sdk_routes_unit.py new file mode 100644 index 0000000000..bc81ac1254 --- /dev/null +++ b/test/testcases/test_web_api/test_dataset_management/test_dataset_sdk_routes_unit.py @@ -0,0 +1,778 @@ +# +# 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 functools +import importlib.util +import inspect +import json +import os +import sys +from copy import deepcopy +from enum import Enum +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +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 _DummyArgs(dict): + def get(self, key, default=None, type=None): + value = super().get(key, default) + if value is None or type is None: + return value + try: + return type(value) + except (TypeError, ValueError): + return default + + +class _Field: + def __init__(self, name): + self.name = name + + def __eq__(self, other): + return (self.name, "==", other) + + +class _KB: + def __init__( + self, + *, + kb_id="kb-1", + name="old", + tenant_id="tenant-1", + parser_id="naive", + parser_config=None, + embd_id="embd-1", + chunk_num=0, + pagerank=0, + graphrag_task_id="", + raptor_task_id="", + ): + self.id = kb_id + self.name = name + self.tenant_id = tenant_id + self.parser_id = parser_id + self.parser_config = parser_config or {} + self.embd_id = embd_id + self.chunk_num = chunk_num + self.pagerank = pagerank + self.graphrag_task_id = graphrag_task_id + self.raptor_task_id = raptor_task_id + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "tenant_id": self.tenant_id, + "parser_id": self.parser_id, + "parser_config": deepcopy(self.parser_config), + "embd_id": self.embd_id, + "pagerank": self.pagerank, + } + + +def _run(coro): + return asyncio.run(coro) + + +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + +def _set_request_args(monkeypatch, module, args): + monkeypatch.setattr(module, "request", SimpleNamespace(args=_DummyArgs(args))) + + +def _patch_json_parser(monkeypatch, module, payload_state, err_state=None): + async def _parse_json(*_args, **_kwargs): + return deepcopy(payload_state), err_state + + monkeypatch.setattr(module, "validate_and_parse_json_request", _parse_json) + + +def _load_dataset_module(monkeypatch): + repo_root = Path(__file__).resolve().parents[4] + + quart_mod = ModuleType("quart") + quart_mod.Request = type("Request", (), {}) + quart_mod.request = SimpleNamespace(args=_DummyArgs()) + monkeypatch.setitem(sys.modules, "quart", quart_mod) + + api_pkg = ModuleType("api") + api_pkg.__path__ = [str(repo_root / "api")] + monkeypatch.setitem(sys.modules, "api", api_pkg) + + utils_pkg = ModuleType("api.utils") + utils_pkg.__path__ = [str(repo_root / "api" / "utils")] + monkeypatch.setitem(sys.modules, "api.utils", utils_pkg) + api_pkg.utils = utils_pkg + + apps_pkg = ModuleType("api.apps") + apps_pkg.__path__ = [str(repo_root / "api" / "apps")] + monkeypatch.setitem(sys.modules, "api.apps", apps_pkg) + api_pkg.apps = apps_pkg + + sdk_pkg = ModuleType("api.apps.sdk") + sdk_pkg.__path__ = [str(repo_root / "api" / "apps" / "sdk")] + monkeypatch.setitem(sys.modules, "api.apps.sdk", sdk_pkg) + apps_pkg.sdk = sdk_pkg + + db_pkg = ModuleType("api.db") + db_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.db", db_pkg) + api_pkg.db = db_pkg + + db_models_mod = ModuleType("api.db.db_models") + db_models_mod.File = SimpleNamespace( + source_type=_Field("source_type"), + id=_Field("id"), + type=_Field("type"), + name=_Field("name"), + ) + monkeypatch.setitem(sys.modules, "api.db.db_models", db_models_mod) + + services_pkg = ModuleType("api.db.services") + services_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.db.services", services_pkg) + + document_service_mod = ModuleType("api.db.services.document_service") + + class _StubDocumentService: + @staticmethod + def query(**_kwargs): + return [] + + @staticmethod + def remove_document(*_args, **_kwargs): + return True + + @staticmethod + def get_by_kb_id(**_kwargs): + return [], 0 + + document_service_mod.DocumentService = _StubDocumentService + document_service_mod.queue_raptor_o_graphrag_tasks = lambda **_kwargs: "task-queued" + monkeypatch.setitem(sys.modules, "api.db.services.document_service", document_service_mod) + services_pkg.document_service = document_service_mod + + file2document_service_mod = ModuleType("api.db.services.file2document_service") + + class _StubFile2DocumentService: + @staticmethod + def get_by_document_id(_doc_id): + return [SimpleNamespace(file_id="file-1")] + + @staticmethod + def delete_by_document_id(_doc_id): + return None + + file2document_service_mod.File2DocumentService = _StubFile2DocumentService + monkeypatch.setitem(sys.modules, "api.db.services.file2document_service", file2document_service_mod) + services_pkg.file2document_service = file2document_service_mod + + file_service_mod = ModuleType("api.db.services.file_service") + + class _StubFileService: + @staticmethod + def filter_delete(_filters): + return None + + file_service_mod.FileService = _StubFileService + monkeypatch.setitem(sys.modules, "api.db.services.file_service", file_service_mod) + services_pkg.file_service = file_service_mod + + knowledgebase_service_mod = ModuleType("api.db.services.knowledgebase_service") + + class _StubKnowledgebaseService: + @staticmethod + def create_with_name(**_kwargs): + return True, {"id": "kb-1"} + + @staticmethod + def save(**_kwargs): + return True + + @staticmethod + def get_by_id(_kb_id): + return True, _KB() + + @staticmethod + def query(**_kwargs): + return [] + + @staticmethod + def get_or_none(**_kwargs): + return _KB() + + @staticmethod + def delete_by_id(_kb_id): + return True + + @staticmethod + def update_by_id(_kb_id, _payload): + return True + + @staticmethod + def get_kb_by_id(_kb_id, _tenant_id): + return [SimpleNamespace(id=_kb_id)] + + @staticmethod + def get_kb_by_name(_name, _tenant_id): + return [SimpleNamespace(name=_name)] + + @staticmethod + def get_list(*_args, **_kwargs): + return [], 0 + + @staticmethod + def accessible(_dataset_id, _tenant_id): + return True + + knowledgebase_service_mod.KnowledgebaseService = _StubKnowledgebaseService + monkeypatch.setitem(sys.modules, "api.db.services.knowledgebase_service", knowledgebase_service_mod) + services_pkg.knowledgebase_service = knowledgebase_service_mod + + task_service_mod = ModuleType("api.db.services.task_service") + + class _StubTaskService: + @staticmethod + def get_by_id(_task_id): + return False, None + + task_service_mod.GRAPH_RAPTOR_FAKE_DOC_ID = "fake-doc" + task_service_mod.TaskService = _StubTaskService + monkeypatch.setitem(sys.modules, "api.db.services.task_service", task_service_mod) + services_pkg.task_service = task_service_mod + + user_service_mod = ModuleType("api.db.services.user_service") + + class _StubTenantService: + @staticmethod + def get_by_id(_tenant_id): + return True, SimpleNamespace(embd_id="embd-default") + + @staticmethod + def get_joined_tenants_by_user_id(_tenant_id): + return [{"tenant_id": "tenant-1"}] + + user_service_mod.TenantService = _StubTenantService + monkeypatch.setitem(sys.modules, "api.db.services.user_service", user_service_mod) + services_pkg.user_service = user_service_mod + + constants_mod = ModuleType("common.constants") + + class _RetCode: + SUCCESS = 0 + ARGUMENT_ERROR = 101 + DATA_ERROR = 102 + AUTHENTICATION_ERROR = 108 + + class _FileSource: + KNOWLEDGEBASE = "knowledgebase" + + class _StatusEnum(Enum): + VALID = "valid" + + constants_mod.RetCode = _RetCode + constants_mod.FileSource = _FileSource + constants_mod.StatusEnum = _StatusEnum + constants_mod.PAGERANK_FLD = "pagerank" + monkeypatch.setitem(sys.modules, "common.constants", constants_mod) + + common_pkg = ModuleType("common") + common_pkg.__path__ = [str(repo_root / "common")] + common_pkg.settings = SimpleNamespace( + docStoreConn=SimpleNamespace( + delete_idx=lambda *_args, **_kwargs: None, + delete=lambda *_args, **_kwargs: None, + update=lambda *_args, **_kwargs: None, + index_exist=lambda *_args, **_kwargs: False, + ), + retriever=SimpleNamespace(search=lambda *_args, **_kwargs: _AwaitableValue(SimpleNamespace(ids=[], field={}))), + ) + monkeypatch.setitem(sys.modules, "common", common_pkg) + + api_utils_mod = ModuleType("api.utils.api_utils") + + def _deep_merge(base, updates): + merged = deepcopy(base) + for key, value in updates.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + def _get_result(*, data=None, message="", code=_RetCode.SUCCESS, total=None): + payload = {"code": code, "data": data, "message": message} + if total is not None: + payload["total"] = total + return payload + + def _get_error_argument_result(message=""): + return _get_result(code=_RetCode.ARGUMENT_ERROR, message=message) + + def _get_error_data_result(message=""): + return _get_result(code=_RetCode.DATA_ERROR, message=message) + + def _get_error_permission_result(message=""): + return _get_result(code=_RetCode.AUTHENTICATION_ERROR, message=message) + + def _token_required(func): + @functools.wraps(func) + async def _async_wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + @functools.wraps(func) + def _sync_wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return _async_wrapper if asyncio.iscoroutinefunction(func) else _sync_wrapper + + api_utils_mod.deep_merge = _deep_merge + api_utils_mod.get_error_argument_result = _get_error_argument_result + api_utils_mod.get_error_data_result = _get_error_data_result + api_utils_mod.get_error_permission_result = _get_error_permission_result + api_utils_mod.get_parser_config = lambda _chunk_method, _unused: {"auto": True} + api_utils_mod.get_result = _get_result + api_utils_mod.remap_dictionary_keys = lambda data: data + api_utils_mod.token_required = _token_required + api_utils_mod.verify_embedding_availability = lambda _embd_id, _tenant_id: (True, None) + monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod) + + async def _parse_json(*_args, **_kwargs): + return {}, None + + def _parse_args(*_args, **_kwargs): + return {"name": "", "page": 1, "page_size": 30, "orderby": "create_time", "desc": True}, None + + validation_spec = importlib.util.spec_from_file_location( + "api.utils.validation_utils", repo_root / "api" / "utils" / "validation_utils.py" + ) + validation_mod = importlib.util.module_from_spec(validation_spec) + monkeypatch.setitem(sys.modules, "api.utils.validation_utils", validation_mod) + validation_spec.loader.exec_module(validation_mod) + validation_mod.validate_and_parse_json_request = _parse_json + validation_mod.validate_and_parse_request_args = _parse_args + + rag_pkg = ModuleType("rag") + rag_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "rag", rag_pkg) + + rag_nlp_pkg = ModuleType("rag.nlp") + rag_nlp_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "rag.nlp", rag_nlp_pkg) + + search_mod = ModuleType("rag.nlp.search") + search_mod.index_name = lambda _tenant_id: "idx" + monkeypatch.setitem(sys.modules, "rag.nlp.search", search_mod) + rag_nlp_pkg.search = search_mod + + module_name = "test_dataset_sdk_routes_unit_module" + module_path = repo_root / "api" / "apps" / "sdk" / "dataset.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 + + +@pytest.mark.p2 +def test_create_route_error_matrix_unit(monkeypatch): + module = _load_dataset_module(monkeypatch) + req_state = {"name": "kb"} + _patch_json_parser(monkeypatch, module, req_state) + + monkeypatch.setattr(module.KnowledgebaseService, "create_with_name", lambda **_kwargs: (False, {"code": 777, "message": "early"})) + res = _run(inspect.unwrap(module.create)("tenant-1")) + assert res["code"] == 777, res + + monkeypatch.setattr(module.KnowledgebaseService, "create_with_name", lambda **_kwargs: (True, {"id": "kb-1"})) + monkeypatch.setattr(module.TenantService, "get_by_id", lambda _tenant_id: (False, None)) + res = _run(inspect.unwrap(module.create)("tenant-1")) + assert res["message"] == "Tenant not found", res + + monkeypatch.setattr(module.TenantService, "get_by_id", lambda _tenant_id: (True, SimpleNamespace(embd_id="embd-1"))) + monkeypatch.setattr(module.KnowledgebaseService, "save", lambda **_kwargs: False) + res = _run(inspect.unwrap(module.create)("tenant-1")) + assert res["code"] == module.RetCode.DATA_ERROR, res + + monkeypatch.setattr(module.KnowledgebaseService, "save", lambda **_kwargs: True) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (False, None)) + res = _run(inspect.unwrap(module.create)("tenant-1")) + assert "Dataset created failed" in res["message"], res + + monkeypatch.setattr(module.KnowledgebaseService, "save", lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("save boom"))) + res = _run(inspect.unwrap(module.create)("tenant-1")) + assert res["message"] == "Database operation failed", res + + +@pytest.mark.p2 +def test_delete_route_error_summary_matrix_unit(monkeypatch): + module = _load_dataset_module(monkeypatch) + req_state = {"ids": ["kb-1"]} + _patch_json_parser(monkeypatch, module, req_state) + + kb = _KB(kb_id="kb-1", name="kb-1", tenant_id="tenant-1") + monkeypatch.setattr(module.KnowledgebaseService, "get_or_none", lambda **_kwargs: kb) + monkeypatch.setattr(module.DocumentService, "query", lambda **_kwargs: [SimpleNamespace(id="doc-1")]) + monkeypatch.setattr(module.DocumentService, "remove_document", lambda *_args, **_kwargs: False) + monkeypatch.setattr(module.settings, "docStoreConn", SimpleNamespace(delete_idx=lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("drop failed")))) + monkeypatch.setattr(module.KnowledgebaseService, "delete_by_id", lambda _kb_id: False) + res = _run(inspect.unwrap(module.delete)("tenant-1")) + assert res["code"] == module.RetCode.DATA_ERROR, res + assert "Successfully deleted 0 datasets" in res["message"], res + + monkeypatch.setattr(module.settings, "docStoreConn", SimpleNamespace(delete_idx=lambda *_args, **_kwargs: None)) + monkeypatch.setattr(module.KnowledgebaseService, "delete_by_id", lambda _kb_id: True) + res = _run(inspect.unwrap(module.delete)("tenant-1")) + assert res["code"] == module.RetCode.SUCCESS, res + assert res["data"]["success_count"] == 1, res + assert res["data"]["errors"], res + + req_state["ids"] = None + monkeypatch.setattr( + module.KnowledgebaseService, + "query", + lambda **_kwargs: (_ for _ in ()).throw(module.OperationalError("db down")), + ) + res = _run(inspect.unwrap(module.delete)("tenant-1")) + assert res["code"] == module.RetCode.DATA_ERROR, res + assert res["message"] == "Database operation failed", res + + +@pytest.mark.p2 +def test_update_route_branch_matrix_unit(monkeypatch): + module = _load_dataset_module(monkeypatch) + req_state = {"name": "new"} + _patch_json_parser(monkeypatch, module, req_state) + + monkeypatch.setattr(module.KnowledgebaseService, "get_or_none", lambda **_kwargs: None) + res = _run(inspect.unwrap(module.update)("tenant-1", "kb-1")) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + + kb = _KB(kb_id="kb-1", name="old", chunk_num=0) + + def _get_or_none_duplicate(**kwargs): + if kwargs.get("id"): + return kb + if kwargs.get("name"): + return SimpleNamespace(id="dup") + return None + + monkeypatch.setattr(module.KnowledgebaseService, "get_or_none", _get_or_none_duplicate) + req_state.clear() + req_state.update({"name": "new"}) + res = _run(inspect.unwrap(module.update)("tenant-1", "kb-1")) + assert "already exists" in res["message"], res + + kb_chunked = _KB(kb_id="kb-1", name="old", chunk_num=2, embd_id="embd-1") + monkeypatch.setattr(module.KnowledgebaseService, "get_or_none", lambda **kwargs: kb_chunked if kwargs.get("id") else None) + req_state.clear() + req_state.update({"embd_id": "embd-2"}) + res = _run(inspect.unwrap(module.update)("tenant-1", "kb-1")) + assert "chunk_num" in res["message"], res + + kb_rank = _KB(kb_id="kb-1", name="old", pagerank=0) + monkeypatch.setattr(module.KnowledgebaseService, "get_or_none", lambda **kwargs: kb_rank if kwargs.get("id") else None) + req_state.clear() + req_state.update({"pagerank": 3}) + os.environ["DOC_ENGINE"] = "infinity" + res = _run(inspect.unwrap(module.update)("tenant-1", "kb-1")) + assert "doc_engine" in res["message"], res + os.environ.pop("DOC_ENGINE", None) + + update_calls = [] + monkeypatch.setattr(module.settings, "docStoreConn", SimpleNamespace(update=lambda *args, **_kwargs: update_calls.append(args))) + monkeypatch.setattr(module.KnowledgebaseService, "update_by_id", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (True, _KB(kb_id="kb-1", pagerank=3))) + + req_state.clear() + req_state.update({"pagerank": 3}) + res = _run(inspect.unwrap(module.update)("tenant-1", "kb-1")) + assert res["code"] == module.RetCode.SUCCESS, res + assert update_calls and update_calls[-1][0] == {"kb_id": "kb-1"}, update_calls + + update_calls.clear() + monkeypatch.setattr(module.KnowledgebaseService, "get_or_none", lambda **kwargs: _KB(kb_id="kb-1", pagerank=3) if kwargs.get("id") else None) + req_state.clear() + req_state.update({"pagerank": 0}) + res = _run(inspect.unwrap(module.update)("tenant-1", "kb-1")) + assert res["code"] == module.RetCode.SUCCESS, res + assert update_calls and update_calls[-1][0] == {"exists": module.PAGERANK_FLD}, update_calls + + monkeypatch.setattr(module.KnowledgebaseService, "update_by_id", lambda *_args, **_kwargs: False) + req_state.clear() + req_state.update({"description": "changed"}) + res = _run(inspect.unwrap(module.update)("tenant-1", "kb-1")) + assert "Update dataset error" in res["message"], res + + monkeypatch.setattr(module.KnowledgebaseService, "update_by_id", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (False, None)) + res = _run(inspect.unwrap(module.update)("tenant-1", "kb-1")) + assert "Dataset created failed" in res["message"], res + + monkeypatch.setattr( + module.KnowledgebaseService, + "get_or_none", + lambda **_kwargs: (_ for _ in ()).throw(module.OperationalError("update down")), + ) + res = _run(inspect.unwrap(module.update)("tenant-1", "kb-1")) + assert res["message"] == "Database operation failed", res + + +@pytest.mark.p2 +def test_list_knowledge_graph_delete_kg_matrix_unit(monkeypatch): + module = _load_dataset_module(monkeypatch) + + _set_request_args(monkeypatch, module, {"id": "", "name": "", "page": 1, "page_size": 30, "orderby": "create_time", "desc": True}) + monkeypatch.setattr( + module, + "validate_and_parse_request_args", + lambda *_args, **_kwargs: ({"name": "", "page": 1, "page_size": 30, "orderby": "create_time", "desc": True}, None), + ) + monkeypatch.setattr( + module.KnowledgebaseService, + "get_list", + lambda *_args, **_kwargs: (_ for _ in ()).throw(module.OperationalError("list down")), + ) + res = module.list_datasets("tenant-1") + assert res["code"] == module.RetCode.DATA_ERROR, res + assert res["message"] == "Database operation failed", res + + monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda *_args, **_kwargs: False) + res = _run(inspect.unwrap(module.knowledge_graph)("tenant-1", "kb-1")) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + + monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (True, _KB(tenant_id="tenant-1"))) + monkeypatch.setattr(module.search, "index_name", lambda _tenant_id: "idx") + monkeypatch.setattr(module.settings, "docStoreConn", SimpleNamespace(index_exist=lambda *_args, **_kwargs: False)) + res = _run(inspect.unwrap(module.knowledge_graph)("tenant-1", "kb-1")) + assert res["data"] == {"graph": {}, "mind_map": {}}, res + + monkeypatch.setattr(module.settings, "docStoreConn", SimpleNamespace(index_exist=lambda *_args, **_kwargs: True)) + + class _EmptyRetriever: + async def search(self, *_args, **_kwargs): + return SimpleNamespace(ids=[], field={}) + + monkeypatch.setattr(module.settings, "retriever", _EmptyRetriever()) + res = _run(inspect.unwrap(module.knowledge_graph)("tenant-1", "kb-1")) + assert res["data"] == {"graph": {}, "mind_map": {}}, res + + class _BadRetriever: + async def search(self, *_args, **_kwargs): + return SimpleNamespace(ids=["bad"], field={"bad": {"knowledge_graph_kwd": "graph", "content_with_weight": "{bad"}}) + + monkeypatch.setattr(module.settings, "retriever", _BadRetriever()) + res = _run(inspect.unwrap(module.knowledge_graph)("tenant-1", "kb-1")) + assert res["code"] == module.RetCode.SUCCESS, res + assert res["data"]["graph"] == {}, res + + payload = { + "nodes": [{"id": "n2", "pagerank": 2}, {"id": "n1", "pagerank": 5}], + "edges": [ + {"source": "n1", "target": "n2", "weight": 2}, + {"source": "n1", "target": "n1", "weight": 10}, + {"source": "n1", "target": "n3", "weight": 9}, + ], + } + + class _GoodRetriever: + async def search(self, *_args, **_kwargs): + return SimpleNamespace(ids=["good"], field={"good": {"knowledge_graph_kwd": "graph", "content_with_weight": json.dumps(payload)}}) + + monkeypatch.setattr(module.settings, "retriever", _GoodRetriever()) + res = _run(inspect.unwrap(module.knowledge_graph)("tenant-1", "kb-1")) + assert res["code"] == module.RetCode.SUCCESS, res + assert len(res["data"]["graph"]["nodes"]) == 2, res + assert len(res["data"]["graph"]["edges"]) == 1, res + + monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda *_args, **_kwargs: False) + res = inspect.unwrap(module.delete_knowledge_graph)("tenant-1", "kb-1") + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + + +@pytest.mark.p2 +def test_run_trace_graphrag_matrix_unit(monkeypatch): + module = _load_dataset_module(monkeypatch) + + warnings = [] + monkeypatch.setattr(module.logging, "warning", lambda msg, *_args, **_kwargs: warnings.append(msg)) + + res = inspect.unwrap(module.run_graphrag)("tenant-1", "") + assert 'Dataset ID' in res["message"], res + + monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda *_args, **_kwargs: False) + res = inspect.unwrap(module.run_graphrag)("tenant-1", "kb-1") + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + + monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (False, None)) + res = inspect.unwrap(module.run_graphrag)("tenant-1", "kb-1") + assert "Invalid Dataset ID" in res["message"], res + + stale_kb = _KB(kb_id="kb-1", graphrag_task_id="task-old") + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (True, stale_kb)) + monkeypatch.setattr(module.TaskService, "get_by_id", lambda _task_id: (False, None)) + monkeypatch.setattr(module.DocumentService, "get_by_kb_id", lambda **_kwargs: ([{"id": "doc-1"}], 1)) + monkeypatch.setattr(module, "queue_raptor_o_graphrag_tasks", lambda **_kwargs: "task-new") + monkeypatch.setattr(module.KnowledgebaseService, "update_by_id", lambda *_args, **_kwargs: True) + res = inspect.unwrap(module.run_graphrag)("tenant-1", "kb-1") + assert res["code"] == module.RetCode.SUCCESS, res + assert any("GraphRAG" in msg for msg in warnings), warnings + + monkeypatch.setattr(module.TaskService, "get_by_id", lambda _task_id: (True, SimpleNamespace(progress=0))) + res = inspect.unwrap(module.run_graphrag)("tenant-1", "kb-1") + assert "already running" in res["message"], res + + warnings.clear() + queue_calls = {} + no_task_kb = _KB(kb_id="kb-1", graphrag_task_id="") + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (True, no_task_kb)) + monkeypatch.setattr(module.TaskService, "get_by_id", lambda _task_id: (False, None)) + monkeypatch.setattr(module.DocumentService, "get_by_kb_id", lambda **_kwargs: ([{"id": "doc-1"}, {"id": "doc-2"}], 2)) + + def _queue(**kwargs): + queue_calls.update(kwargs) + return "queued-id" + + monkeypatch.setattr(module, "queue_raptor_o_graphrag_tasks", _queue) + monkeypatch.setattr(module.KnowledgebaseService, "update_by_id", lambda *_args, **_kwargs: False) + res = inspect.unwrap(module.run_graphrag)("tenant-1", "kb-1") + assert res["code"] == module.RetCode.SUCCESS, res + assert res["data"]["graphrag_task_id"] == "queued-id", res + assert queue_calls["doc_ids"] == ["doc-1", "doc-2"], queue_calls + assert any("Cannot save graphrag_task_id" in msg for msg in warnings), warnings + + res = inspect.unwrap(module.trace_graphrag)("tenant-1", "") + assert 'Dataset ID' in res["message"], res + + monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda *_args, **_kwargs: False) + res = inspect.unwrap(module.trace_graphrag)("tenant-1", "kb-1") + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + + monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (False, None)) + res = inspect.unwrap(module.trace_graphrag)("tenant-1", "kb-1") + assert "Invalid Dataset ID" in res["message"], res + + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (True, _KB(kb_id="kb-1", graphrag_task_id="task-1"))) + monkeypatch.setattr(module.TaskService, "get_by_id", lambda _task_id: (False, None)) + res = inspect.unwrap(module.trace_graphrag)("tenant-1", "kb-1") + assert res["code"] == module.RetCode.SUCCESS, res + assert res["data"] == {}, res + + monkeypatch.setattr(module.TaskService, "get_by_id", lambda _task_id: (True, SimpleNamespace(to_dict=lambda: {"id": _task_id, "progress": 1}))) + res = inspect.unwrap(module.trace_graphrag)("tenant-1", "kb-1") + assert res["code"] == module.RetCode.SUCCESS, res + assert res["data"]["id"] == "task-1", res + + +@pytest.mark.p2 +def test_run_trace_raptor_matrix_unit(monkeypatch): + module = _load_dataset_module(monkeypatch) + + warnings = [] + monkeypatch.setattr(module.logging, "warning", lambda msg, *_args, **_kwargs: warnings.append(msg)) + + res = inspect.unwrap(module.run_raptor)("tenant-1", "") + assert 'Dataset ID' in res["message"], res + + monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda *_args, **_kwargs: False) + res = inspect.unwrap(module.run_raptor)("tenant-1", "kb-1") + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + + monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (False, None)) + res = inspect.unwrap(module.run_raptor)("tenant-1", "kb-1") + assert "Invalid Dataset ID" in res["message"], res + + stale_kb = _KB(kb_id="kb-1", raptor_task_id="task-old") + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (True, stale_kb)) + monkeypatch.setattr(module.TaskService, "get_by_id", lambda _task_id: (False, None)) + monkeypatch.setattr(module.DocumentService, "get_by_kb_id", lambda **_kwargs: ([{"id": "doc-1"}], 1)) + monkeypatch.setattr(module, "queue_raptor_o_graphrag_tasks", lambda **_kwargs: "task-new") + monkeypatch.setattr(module.KnowledgebaseService, "update_by_id", lambda *_args, **_kwargs: True) + res = inspect.unwrap(module.run_raptor)("tenant-1", "kb-1") + assert res["code"] == module.RetCode.SUCCESS, res + assert any("RAPTOR" in msg for msg in warnings), warnings + + monkeypatch.setattr(module.TaskService, "get_by_id", lambda _task_id: (True, SimpleNamespace(progress=0))) + res = inspect.unwrap(module.run_raptor)("tenant-1", "kb-1") + assert "already running" in res["message"], res + + warnings.clear() + no_task_kb = _KB(kb_id="kb-1", raptor_task_id="") + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (True, no_task_kb)) + monkeypatch.setattr(module.DocumentService, "get_by_kb_id", lambda **_kwargs: ([{"id": "doc-1"}], 1)) + monkeypatch.setattr(module, "queue_raptor_o_graphrag_tasks", lambda **_kwargs: "queued-raptor") + monkeypatch.setattr(module.KnowledgebaseService, "update_by_id", lambda *_args, **_kwargs: False) + res = inspect.unwrap(module.run_raptor)("tenant-1", "kb-1") + assert res["code"] == module.RetCode.SUCCESS, res + assert res["data"]["raptor_task_id"] == "queued-raptor", res + assert any("Cannot save raptor_task_id" in msg for msg in warnings), warnings + + res = inspect.unwrap(module.trace_raptor)("tenant-1", "") + assert 'Dataset ID' in res["message"], res + + monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda *_args, **_kwargs: False) + res = inspect.unwrap(module.trace_raptor)("tenant-1", "kb-1") + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + + monkeypatch.setattr(module.KnowledgebaseService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (False, None)) + res = inspect.unwrap(module.trace_raptor)("tenant-1", "kb-1") + assert "Invalid Dataset ID" in res["message"], res + + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (True, _KB(kb_id="kb-1", raptor_task_id="task-1"))) + monkeypatch.setattr(module.TaskService, "get_by_id", lambda _task_id: (False, None)) + res = inspect.unwrap(module.trace_raptor)("tenant-1", "kb-1") + assert "RAPTOR Task Not Found" in res["message"], res + + monkeypatch.setattr(module.TaskService, "get_by_id", lambda _task_id: (True, SimpleNamespace(to_dict=lambda: {"id": _task_id, "progress": -1}))) + res = inspect.unwrap(module.trace_raptor)("tenant-1", "kb-1") + assert res["code"] == module.RetCode.SUCCESS, res + assert res["data"]["id"] == "task-1", res diff --git a/test/testcases/test_web_api/test_dialog_app/test_dialog_routes_unit.py b/test/testcases/test_web_api/test_dialog_app/test_dialog_routes_unit.py new file mode 100644 index 0000000000..565c516d9e --- /dev/null +++ b/test/testcases/test_web_api/test_dialog_app/test_dialog_routes_unit.py @@ -0,0 +1,549 @@ +# +# 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 inspect +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from functools import wraps + +import pytest + + +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 _Args(dict): + def get(self, key, default=None): + return super().get(key, default) + + +def _run(coro): + return asyncio.run(coro) + + +def _set_request_json(monkeypatch, module, payload): + monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue(payload)) + + +def _set_request_args(monkeypatch, module, args): + monkeypatch.setattr(module, "request", SimpleNamespace(args=_Args(args))) + + +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + +def _load_dialog_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) + + quart_mod = ModuleType("quart") + quart_mod.request = SimpleNamespace(args=_Args()) + monkeypatch.setitem(sys.modules, "quart", quart_mod) + + api_pkg = ModuleType("api") + api_pkg.__path__ = [str(repo_root / "api")] + monkeypatch.setitem(sys.modules, "api", api_pkg) + + apps_mod = ModuleType("api.apps") + apps_mod.__path__ = [str(repo_root / "api" / "apps")] + apps_mod.current_user = SimpleNamespace(id="tenant-1") + apps_mod.login_required = lambda func: func + monkeypatch.setitem(sys.modules, "api.apps", apps_mod) + api_pkg.apps = apps_mod + + db_pkg = ModuleType("api.db") + db_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.db", db_pkg) + api_pkg.db = db_pkg + + services_pkg = ModuleType("api.db.services") + services_pkg.__path__ = [] + services_pkg.duplicate_name = lambda _checker, **kwargs: kwargs.get("name", "") + monkeypatch.setitem(sys.modules, "api.db.services", services_pkg) + + dialog_service_mod = ModuleType("api.db.services.dialog_service") + + class _DialogService: + model = SimpleNamespace(create_time="create_time") + + @staticmethod + def query(**_kwargs): + return [] + + @staticmethod + def save(**_kwargs): + return True + + @staticmethod + def update_by_id(*_args, **_kwargs): + return True + + @staticmethod + def get_by_id(_id): + return True, SimpleNamespace(to_dict=lambda: {"id": _id, "kb_ids": []}) + + @staticmethod + def get_by_tenant_ids(*_args, **_kwargs): + return [], 0 + + @staticmethod + def update_many_by_id(_payload): + return True + + dialog_service_mod.DialogService = _DialogService + monkeypatch.setitem(sys.modules, "api.db.services.dialog_service", dialog_service_mod) + + tenant_llm_service_mod = ModuleType("api.db.services.tenant_llm_service") + + class _TenantLLMService: + @staticmethod + def split_model_name_and_factory(embd_id): + return embd_id.split("@") + + tenant_llm_service_mod.TenantLLMService = _TenantLLMService + monkeypatch.setitem(sys.modules, "api.db.services.tenant_llm_service", tenant_llm_service_mod) + + knowledgebase_service_mod = ModuleType("api.db.services.knowledgebase_service") + + class _KnowledgebaseService: + @staticmethod + def get_by_ids(_ids): + return [] + + @staticmethod + def get_by_id(_id): + return False, None + + @staticmethod + def query(**_kwargs): + return [] + + knowledgebase_service_mod.KnowledgebaseService = _KnowledgebaseService + monkeypatch.setitem(sys.modules, "api.db.services.knowledgebase_service", knowledgebase_service_mod) + + user_service_mod = ModuleType("api.db.services.user_service") + + class _TenantService: + @staticmethod + def get_by_id(_id): + return True, SimpleNamespace(llm_id="llm-default") + + class _UserTenantService: + @staticmethod + def query(**_kwargs): + return [SimpleNamespace(tenant_id="tenant-1")] + + user_service_mod.TenantService = _TenantService + user_service_mod.UserTenantService = _UserTenantService + monkeypatch.setitem(sys.modules, "api.db.services.user_service", user_service_mod) + + api_utils_mod = ModuleType("api.utils.api_utils") + from common.constants import RetCode + + async def _default_request_json(): + return {} + + def _get_data_error_result(code=RetCode.DATA_ERROR, message="Sorry! Data missing!"): + return {"code": code, "message": message} + + def _get_json_result(code=RetCode.SUCCESS, message="success", data=None): + return {"code": code, "message": message, "data": data} + + def _server_error_response(error): + return {"code": RetCode.EXCEPTION_ERROR, "message": repr(error)} + + def _validate_request(*_args, **_kwargs): + def _decorator(func): + if inspect.iscoroutinefunction(func): + @wraps(func) + async def _wrapped(*func_args, **func_kwargs): + return await func(*func_args, **func_kwargs) + + return _wrapped + + @wraps(func) + def _wrapped(*func_args, **func_kwargs): + return func(*func_args, **func_kwargs) + + return _wrapped + + return _decorator + + api_utils_mod.get_request_json = _default_request_json + api_utils_mod.get_data_error_result = _get_data_error_result + api_utils_mod.get_json_result = _get_json_result + api_utils_mod.server_error_response = _server_error_response + api_utils_mod.validate_request = _validate_request + monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod) + + module_name = "test_dialog_routes_unit_module" + module_path = repo_root / "api" / "apps" / "dialog_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 + + +@pytest.mark.p2 +def test_set_dialog_branch_matrix_unit(monkeypatch): + module = _load_dialog_module(monkeypatch) + handler = inspect.unwrap(module.set_dialog) + + _set_request_json(monkeypatch, module, {"name": 1, "prompt_config": {"system": "", "parameters": []}}) + res = _run(handler()) + assert res["message"] == "Dialog name must be string." + + _set_request_json(monkeypatch, module, {"name": " ", "prompt_config": {"system": "", "parameters": []}}) + res = _run(handler()) + assert res["message"] == "Dialog name can't be empty." + + _set_request_json(monkeypatch, module, {"name": "a" * 256, "prompt_config": {"system": "", "parameters": []}}) + res = _run(handler()) + assert res["message"] == "Dialog name length is 256 which is larger than 255" + + captured = {} + + def _dup_name(checker, **kwargs): + assert checker(name=kwargs["name"]) is True + return kwargs["name"] + " (1)" + + monkeypatch.setattr(module, "duplicate_name", _dup_name) + monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: [SimpleNamespace(name="new dialog")]) + monkeypatch.setattr(module.TenantService, "get_by_id", lambda _id: (True, SimpleNamespace(llm_id="llm-x"))) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_ids", lambda _ids: [SimpleNamespace(embd_id="embd-a@builtin")]) + monkeypatch.setattr(module.TenantLLMService, "split_model_name_and_factory", lambda embd_id: embd_id.split("@")) + monkeypatch.setattr(module.DialogService, "save", lambda **kwargs: captured.update(kwargs) or False) + _set_request_json( + monkeypatch, + module, + { + "name": "New Dialog", + "kb_ids": ["kb-1"], + "prompt_config": {"system": "Use {knowledge}", "parameters": []}, + }, + ) + res = _run(handler()) + assert res["message"] == "Fail to new a dialog!" + assert captured["name"] == "New Dialog (1)" + assert captured["prompt_config"]["parameters"] == [{"key": "knowledge", "optional": False}] + + _set_request_json( + monkeypatch, + module, + { + "dialog_id": "dialog-1", + "name": "Update", + "kb_ids": [], + "prompt_config": { + "system": "Use {knowledge}", + "parameters": [{"key": "knowledge", "optional": True}], + }, + }, + ) + res = _run(handler()) + assert "Please remove `{knowledge}` in system prompt" in res["message"] + + _set_request_json( + monkeypatch, + module, + {"name": "demo", "prompt_config": {"system": "hello", "parameters": [{"key": "must", "optional": False}]}}, + ) + res = _run(handler()) + assert "Parameter 'must' is not used" in res["message"] + + monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: []) + monkeypatch.setattr(module.TenantService, "get_by_id", lambda _id: (False, None)) + _set_request_json(monkeypatch, module, {"name": "demo", "prompt_config": {"system": "hello", "parameters": []}}) + res = _run(handler()) + assert res["message"] == "Tenant not found!" + + monkeypatch.setattr(module.TenantService, "get_by_id", lambda _id: (True, SimpleNamespace(llm_id="llm-x"))) + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue( + { + "name": "demo", + "kb_ids": ["kb-1", "kb-2"], + "prompt_config": {"system": "hello", "parameters": []}, + } + ), + ) + monkeypatch.setattr( + module.KnowledgebaseService, + "get_by_ids", + lambda _ids: [SimpleNamespace(embd_id="embd-a@f1"), SimpleNamespace(embd_id="embd-b@f2")], + ) + monkeypatch.setattr(module.TenantLLMService, "split_model_name_and_factory", lambda embd_id: embd_id.split("@")) + res = _run(handler()) + assert "Datasets use different embedding models" in res["message"] + + monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: []) + monkeypatch.setattr( + module, + "get_request_json", + lambda: _AwaitableValue( + { + "name": "optional-param-dialog", + "prompt_config": {"system": "hello", "parameters": [{"key": "ignored", "optional": True}]}, + } + ), + ) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_ids", lambda _ids: []) + monkeypatch.setattr(module.DialogService, "save", lambda **_kwargs: False) + res = _run(handler()) + assert res["message"] == "Fail to new a dialog!" + + monkeypatch.setattr(module.KnowledgebaseService, "get_by_ids", lambda _ids: []) + monkeypatch.setattr(module.DialogService, "update_by_id", lambda *_args, **_kwargs: False) + _set_request_json( + monkeypatch, + module, + { + "dialog_id": "dialog-1", + "kb_names": ["legacy"], + "name": "rename", + "prompt_config": {"system": "hello", "parameters": []}, + }, + ) + res = _run(handler()) + assert res["message"] == "Dialog not found!" + + monkeypatch.setattr(module.DialogService, "update_by_id", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.DialogService, "get_by_id", lambda _id: (False, None)) + _set_request_json( + monkeypatch, + module, + { + "dialog_id": "dialog-1", + "name": "rename", + "prompt_config": {"system": "hello", "parameters": []}, + }, + ) + res = _run(handler()) + assert res["message"] == "Fail to update a dialog!" + + monkeypatch.setattr(module.DialogService, "get_by_id", lambda _id: (True, SimpleNamespace(to_dict=lambda: {"id": _id, "kb_ids": ["kb-1"]}))) + monkeypatch.setattr( + module.KnowledgebaseService, + "get_by_id", + lambda _id: (True, SimpleNamespace(status=module.StatusEnum.VALID.value, name="KB One")), + ) + _set_request_json( + monkeypatch, + module, + { + "dialog_id": "dialog-1", + "kb_names": ["legacy"], + "name": "new-name", + "prompt_config": {"system": "hello", "parameters": []}, + }, + ) + res = _run(handler()) + assert res["code"] == 0 + assert res["data"]["name"] == "new-name" + assert res["data"]["kb_names"] == ["KB One"] + + def _raise_tenant(_id): + raise RuntimeError("set boom") + + monkeypatch.setattr(module.TenantService, "get_by_id", _raise_tenant) + _set_request_json(monkeypatch, module, {"name": "demo", "prompt_config": {"system": "hello", "parameters": []}}) + res = _run(handler()) + assert "set boom" in res["message"] + + +@pytest.mark.p2 +def test_get_get_kb_names_and_list_dialogs_exception_matrix_unit(monkeypatch): + module = _load_dialog_module(monkeypatch) + get_handler = inspect.unwrap(module.get) + + monkeypatch.setattr( + module.DialogService, + "get_by_id", + lambda _id: (True, SimpleNamespace(to_dict=lambda: {"id": _id, "kb_ids": ["kb-1", "kb-2"]})), + ) + monkeypatch.setattr( + module.KnowledgebaseService, + "get_by_id", + lambda kid: ( + (True, SimpleNamespace(status=module.StatusEnum.VALID.value, name="KB-1")) + if kid == "kb-1" + else (False, None) + ), + ) + _set_request_args(monkeypatch, module, {"dialog_id": "dialog-1"}) + res = get_handler() + assert res["code"] == 0 + assert res["data"]["kb_ids"] == ["kb-1"] + assert res["data"]["kb_names"] == ["KB-1"] + + monkeypatch.setattr(module.DialogService, "get_by_id", lambda _id: (False, None)) + _set_request_args(monkeypatch, module, {"dialog_id": "dialog-missing"}) + res = get_handler() + assert res["message"] == "Dialog not found!" + + def _raise_get(_id): + raise RuntimeError("get boom") + + monkeypatch.setattr(module.DialogService, "get_by_id", _raise_get) + _set_request_args(monkeypatch, module, {"dialog_id": "dialog-1"}) + res = get_handler() + assert "get boom" in res["message"] + + monkeypatch.setattr( + module.KnowledgebaseService, + "get_by_id", + lambda kid: ( + (True, SimpleNamespace(status=module.StatusEnum.VALID.value, name=f"KB-{kid}")) + if kid.startswith("ok") + else (True, SimpleNamespace(status=module.StatusEnum.INVALID.value, name=f"BAD-{kid}")) + ), + ) + ids, names = module.get_kb_names(["ok-1", "bad-1", "ok-2"]) + assert ids == ["ok-1", "ok-2"] + assert names == ["KB-ok-1", "KB-ok-2"] + + def _raise_list(**_kwargs): + raise RuntimeError("list boom") + + monkeypatch.setattr(module.DialogService, "query", _raise_list) + res = module.list_dialogs() + assert "list boom" in res["message"] + + +@pytest.mark.p2 +def test_list_dialogs_next_owner_desc_and_pagination_matrix_unit(monkeypatch): + module = _load_dialog_module(monkeypatch) + handler = inspect.unwrap(module.list_dialogs_next) + + calls = [] + + def _get_by_tenant_ids(tenants, user_id, page_number, items_per_page, orderby, desc, keywords, parser_id): + calls.append( + { + "tenants": tenants, + "user_id": user_id, + "page_number": page_number, + "items_per_page": items_per_page, + "orderby": orderby, + "desc": desc, + "keywords": keywords, + "parser_id": parser_id, + } + ) + if tenants: + return ( + [ + {"id": "dialog-1", "tenant_id": "tenant-a"}, + {"id": "dialog-2", "tenant_id": "tenant-x"}, + {"id": "dialog-3", "tenant_id": "tenant-b"}, + ], + 3, + ) + return ([{"id": "dialog-0", "tenant_id": "tenant-1"}], 1) + + monkeypatch.setattr(module.DialogService, "get_by_tenant_ids", _get_by_tenant_ids) + + _set_request_args( + monkeypatch, + module, + { + "keywords": "k", + "page": "1", + "page_size": "2", + "parser_id": "parser-x", + "orderby": "create_time", + "desc": "false", + }, + ) + _set_request_json(monkeypatch, module, {"owner_ids": []}) + res = _run(handler()) + assert res["code"] == 0 + assert res["data"]["total"] == 1 + assert calls[-1]["tenants"] == [] + assert calls[-1]["desc"] is False + + _set_request_args(monkeypatch, module, {"page": "2", "page_size": "1"}) + _set_request_json(monkeypatch, module, {"owner_ids": ["tenant-a", "tenant-b"]}) + res = _run(handler()) + assert res["code"] == 0 + assert res["data"]["total"] == 2 + assert res["data"]["dialogs"] == [{"id": "dialog-3", "tenant_id": "tenant-b"}] + assert calls[-1]["page_number"] == 0 + assert calls[-1]["items_per_page"] == 0 + assert calls[-1]["desc"] is True + + def _raise_next(*_args, **_kwargs): + raise RuntimeError("next boom") + + monkeypatch.setattr(module.DialogService, "get_by_tenant_ids", _raise_next) + _set_request_args(monkeypatch, module, {"page": "1", "page_size": "1"}) + _set_request_json(monkeypatch, module, {"owner_ids": []}) + res = _run(handler()) + assert "next boom" in res["message"] + + +@pytest.mark.p2 +def test_rm_permission_and_exception_matrix_unit(monkeypatch): + module = _load_dialog_module(monkeypatch) + handler = inspect.unwrap(module.rm) + + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-a")]) + monkeypatch.setattr(module.DialogService, "query", lambda **_kwargs: []) + _set_request_json(monkeypatch, module, {"dialog_ids": ["dialog-1"]}) + res = _run(handler()) + assert res["code"] == module.RetCode.OPERATING_ERROR + assert "Only owner of dialog authorized for this operation." in res["message"] + + def _raise_query(**_kwargs): + raise RuntimeError("rm boom") + + monkeypatch.setattr(module.DialogService, "query", _raise_query) + _set_request_json(monkeypatch, module, {"dialog_ids": ["dialog-1"]}) + res = _run(handler()) + assert "rm boom" in res["message"] diff --git a/test/testcases/test_web_api/test_document_app/test_document_metadata.py b/test/testcases/test_web_api/test_document_app/test_document_metadata.py index 6808993e89..6d2c788cec 100644 --- a/test/testcases/test_web_api/test_document_app/test_document_metadata.py +++ b/test/testcases/test_web_api/test_document_app/test_document_metadata.py @@ -250,6 +250,31 @@ def _run(coro): return asyncio.run(coro) +class _DummyArgs: + def __init__(self, args=None): + self._args = args or {} + + def get(self, key, default=None): + return self._args.get(key, default) + + def getlist(self, key): + value = self._args.get(key, []) + if isinstance(value, list): + return value + return [value] + + +class _DummyRequest: + def __init__(self, args=None): + self.args = _DummyArgs(args) + + +class _DummyResponse: + def __init__(self, data=None): + self.data = data + self.headers = {} + + @pytest.mark.p2 class TestDocumentMetadataUnit: def _allow_kb(self, module, monkeypatch, kb_id="kb1", tenant_id="tenant1"): @@ -411,3 +436,546 @@ class TestDocumentMetadataUnit: res = _run(module.metadata_update.__wrapped__()) assert res["code"] == 0 assert res["data"]["matched_docs"] == 1 + + def test_metadata_update_invalid_delete_item_unit(self, document_app_module, monkeypatch): + module = document_app_module + + async def fake_request_json(): + return {"kb_id": "kb1", "doc_ids": ["doc1"], "updates": [], "deletes": [{}]} + + monkeypatch.setattr(module, "get_request_json", fake_request_json) + res = _run(module.metadata_update.__wrapped__()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert "Each delete requires key." in res["message"] + + def test_update_metadata_setting_authorization_and_refetch_not_found_unit(self, document_app_module, monkeypatch): + module = document_app_module + + async def fake_request_json(): + return {"doc_id": "doc1", "metadata": {"author": "alice"}} + + monkeypatch.setattr(module, "get_request_json", fake_request_json) + monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: False) + res = _run(module.update_metadata_setting.__wrapped__()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + assert "No authorization." in res["message"] + + doc = SimpleNamespace(id="doc1", to_dict=lambda: {"id": "doc1", "parser_config": {}}) + state = {"count": 0} + + def fake_get_by_id(_doc_id): + state["count"] += 1 + if state["count"] == 1: + return True, doc + return False, None + + monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.DocumentService, "get_by_id", fake_get_by_id) + monkeypatch.setattr(module.DocumentService, "update_parser_config", lambda *_args, **_kwargs: True) + res = _run(module.update_metadata_setting.__wrapped__()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "Document not found!" in res["message"] + + def test_thumbnails_missing_ids_rewrite_and_exception_unit(self, document_app_module, monkeypatch): + module = document_app_module + monkeypatch.setattr(module, "request", _DummyRequest(args={})) + res = module.thumbnails() + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert 'Lack of "Document ID"' in res["message"] + + monkeypatch.setattr(module, "request", _DummyRequest(args={"doc_ids": ["doc1", "doc2"]})) + monkeypatch.setattr( + module.DocumentService, + "get_thumbnails", + lambda _doc_ids: [ + {"id": "doc1", "kb_id": "kb1", "thumbnail": "thumb.jpg"}, + {"id": "doc2", "kb_id": "kb1", "thumbnail": f"{module.IMG_BASE64_PREFIX}blob"}, + ], + ) + res = module.thumbnails() + assert res["code"] == 0 + assert res["data"]["doc1"] == "/v1/document/image/kb1-thumb.jpg" + assert res["data"]["doc2"] == f"{module.IMG_BASE64_PREFIX}blob" + + def raise_error(*_args, **_kwargs): + raise RuntimeError("thumb boom") + + monkeypatch.setattr(module.DocumentService, "get_thumbnails", raise_error) + monkeypatch.setattr(module, "server_error_response", lambda e: {"code": 500, "message": str(e)}) + res = module.thumbnails() + assert res["code"] == 500 + assert "thumb boom" in res["message"] + + def test_change_status_partial_failure_matrix_unit(self, document_app_module, monkeypatch): + module = document_app_module + calls = {"docstore_update": []} + doc_ids = ["unauth", "missing_doc", "missing_kb", "update_fail", "docstore_3022", "docstore_generic", "outer_exc"] + + async def fake_request_json(): + return {"doc_ids": doc_ids, "status": "1"} + + def fake_accessible(doc_id, _uid): + return doc_id != "unauth" + + def fake_get_by_id(doc_id): + if doc_id == "missing_doc": + return False, None + if doc_id == "outer_exc": + raise RuntimeError("explode") + kb_id = "kb_missing" if doc_id == "missing_kb" else "kb1" + chunk_num = 1 if doc_id in {"docstore_3022", "docstore_generic"} else 0 + doc = SimpleNamespace(id=doc_id, kb_id=kb_id, status="0", chunk_num=chunk_num) + return True, doc + + def fake_get_kb(kb_id): + if kb_id == "kb_missing": + return False, None + return True, SimpleNamespace(tenant_id="tenant1") + + def fake_update_by_id(doc_id, _payload): + return doc_id != "update_fail" + + class _DocStore: + def update(self, where, _payload, _index_name, _kb_id): + calls["docstore_update"].append(where["doc_id"]) + if where["doc_id"] == "docstore_3022": + raise RuntimeError("3022 table missing") + if where["doc_id"] == "docstore_generic": + raise RuntimeError("doc store down") + return True + + monkeypatch.setattr(module, "get_request_json", fake_request_json) + monkeypatch.setattr(module.DocumentService, "accessible", fake_accessible) + monkeypatch.setattr(module.DocumentService, "get_by_id", fake_get_by_id) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda kb_id: fake_get_kb(kb_id)) + monkeypatch.setattr(module.DocumentService, "update_by_id", fake_update_by_id) + monkeypatch.setattr(module.settings, "docStoreConn", _DocStore()) + monkeypatch.setattr(module.search, "index_name", lambda tenant_id: f"idx_{tenant_id}") + + res = _run(module.change_status.__wrapped__()) + assert res["code"] == module.RetCode.SERVER_ERROR + assert res["message"] == "Partial failure" + assert res["data"]["unauth"]["error"] == "No authorization." + assert res["data"]["missing_doc"]["error"] == "No authorization." + assert res["data"]["missing_kb"]["error"] == "Can't find this dataset!" + assert res["data"]["update_fail"]["error"] == "Database error (Document update)!" + assert res["data"]["docstore_3022"]["error"] == "Document store table missing." + assert "Document store update failed:" in res["data"]["docstore_generic"]["error"] + assert "Internal server error: explode" == res["data"]["outer_exc"]["error"] + assert calls["docstore_update"] == ["docstore_3022", "docstore_generic"] + + def test_change_status_invalid_status_unit(self, document_app_module, monkeypatch): + module = document_app_module + + async def fake_request_json(): + return {"doc_ids": ["doc1"], "status": "2"} + + monkeypatch.setattr(module, "get_request_json", fake_request_json) + res = _run(module.change_status.__wrapped__()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert '"Status" must be either 0 or 1!' in res["message"] + + def test_change_status_all_success_unit(self, document_app_module, monkeypatch): + module = document_app_module + + async def fake_request_json(): + return {"doc_ids": ["doc1"], "status": "1"} + + monkeypatch.setattr(module, "get_request_json", fake_request_json) + monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (True, SimpleNamespace(id="doc1", kb_id="kb1", status="0", chunk_num=0))) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (True, SimpleNamespace(tenant_id="tenant1"))) + monkeypatch.setattr(module.DocumentService, "update_by_id", lambda *_args, **_kwargs: True) + res = _run(module.change_status.__wrapped__()) + assert res["code"] == 0 + assert res["data"]["doc1"]["status"] == "1" + + def test_rename_branch_matrix_and_exception_unit(self, document_app_module, monkeypatch): + module = document_app_module + file_updates = [] + es_updates = [] + + async def fake_thread_pool_exec(func, *_args, **_kwargs): + return func() + + monkeypatch.setattr(module, "thread_pool_exec", fake_thread_pool_exec) + monkeypatch.setattr(module.DocumentService, "get_tenant_id", lambda _doc_id: "tenant1") + monkeypatch.setattr(module.rag_tokenizer, "tokenize", lambda _name: ["token"]) + monkeypatch.setattr(module.rag_tokenizer, "fine_grained_tokenize", lambda _tokens: ["fine"]) + monkeypatch.setattr(module, "server_error_response", lambda e: {"code": 500, "message": str(e)}) + + class _DocStore: + def index_exist(self, _index_name, _kb_id): + return True + + def update(self, where, payload, _index_name, _kb_id): + es_updates.append((where, payload)) + + monkeypatch.setattr(module.settings, "docStoreConn", _DocStore()) + monkeypatch.setattr(module.search, "index_name", lambda tenant_id: f"idx_{tenant_id}") + + def set_req(name): + async def fake_request_json(): + return {"doc_id": "doc1", "name": name} + + monkeypatch.setattr(module, "get_request_json", fake_request_json) + + set_req("renamed.txt") + monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: False) + res = _run(module.rename.__wrapped__()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + + monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (False, None)) + res = _run(module.rename.__wrapped__()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "Document not found!" in res["message"] + + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (True, SimpleNamespace(id="doc1", name="origin.txt", kb_id="kb1"))) + set_req("renamed.pdf") + res = _run(module.rename.__wrapped__()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert "extension" in res["message"] + + too_long = "a" * (module.FILE_NAME_LEN_LIMIT + 1) + ".txt" + set_req(too_long) + res = _run(module.rename.__wrapped__()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert "bytes or less" in res["message"] + + set_req("dup.txt") + monkeypatch.setattr(module.DocumentService, "query", lambda **_kwargs: [SimpleNamespace(name="dup.txt")]) + res = _run(module.rename.__wrapped__()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "Duplicated document name" in res["message"] + + set_req("ok.txt") + monkeypatch.setattr(module.DocumentService, "query", lambda **_kwargs: []) + monkeypatch.setattr(module.DocumentService, "update_by_id", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.File2DocumentService, "get_by_document_id", lambda _doc_id: [SimpleNamespace(file_id="file1")]) + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (True, SimpleNamespace(id="file1"))) + monkeypatch.setattr(module.FileService, "update_by_id", lambda file_id, payload: file_updates.append((file_id, payload))) + res = _run(module.rename.__wrapped__()) + assert res["code"] == 0 + assert file_updates == [("file1", {"name": "ok.txt"})] + assert es_updates[0][0] == {"doc_id": "doc1"} + assert es_updates[0][1]["docnm_kwd"] == "ok.txt" + assert es_updates[0][1]["title_tks"] == ["token"] + assert es_updates[0][1]["title_sm_tks"] == ["fine"] + + def raise_db_error(*_args, **_kwargs): + raise RuntimeError("rename boom") + + monkeypatch.setattr(module.DocumentService, "update_by_id", raise_db_error) + res = _run(module.rename.__wrapped__()) + assert res["code"] == 500 + assert "rename boom" in res["message"] + + def test_get_route_not_found_success_and_exception_unit(self, document_app_module, monkeypatch): + module = document_app_module + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (False, None)) + res = _run(module.get("doc1")) + assert res["code"] == module.RetCode.DATA_ERROR + assert "Document not found!" in res["message"] + + async def fake_thread_pool_exec(*_args, **_kwargs): + return b"blob-data" + + async def fake_make_response(data): + return _DummyResponse(data) + + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (True, SimpleNamespace(name="image.abc", type=module.FileType.VISUAL.value))) + monkeypatch.setattr(module.File2DocumentService, "get_storage_address", lambda **_kwargs: ("bucket", "name")) + monkeypatch.setattr(module.settings, "STORAGE_IMPL", SimpleNamespace(get=lambda *_args, **_kwargs: b"blob-data")) + monkeypatch.setattr(module, "thread_pool_exec", fake_thread_pool_exec) + monkeypatch.setattr(module, "make_response", fake_make_response) + monkeypatch.setattr( + module, + "apply_safe_file_response_headers", + lambda response, content_type, extension: response.headers.update({"content_type": content_type, "extension": extension}), + ) + res = _run(module.get("doc1")) + assert isinstance(res, _DummyResponse) + assert res.data == b"blob-data" + assert res.headers["content_type"] == "image/abc" + assert res.headers["extension"] == "abc" + + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (_ for _ in ()).throw(RuntimeError("get boom"))) + monkeypatch.setattr(module, "server_error_response", lambda e: {"code": 500, "message": str(e)}) + res = _run(module.get("doc1")) + assert res["code"] == 500 + assert "get boom" in res["message"] + + def test_download_attachment_success_and_exception_unit(self, document_app_module, monkeypatch): + module = document_app_module + monkeypatch.setattr(module, "request", _DummyRequest(args={"ext": "abc"})) + + async def fake_thread_pool_exec(*_args, **_kwargs): + return b"attachment" + + async def fake_make_response(data): + return _DummyResponse(data) + + monkeypatch.setattr(module, "thread_pool_exec", fake_thread_pool_exec) + monkeypatch.setattr(module, "make_response", fake_make_response) + monkeypatch.setattr(module.settings, "STORAGE_IMPL", SimpleNamespace(get=lambda *_args, **_kwargs: b"attachment")) + monkeypatch.setattr( + module, + "apply_safe_file_response_headers", + lambda response, content_type, extension: response.headers.update({"content_type": content_type, "extension": extension}), + ) + res = _run(module.download_attachment("att1")) + assert isinstance(res, _DummyResponse) + assert res.data == b"attachment" + assert res.headers["content_type"] == "application/abc" + assert res.headers["extension"] == "abc" + + async def raise_error(*_args, **_kwargs): + raise RuntimeError("download boom") + + monkeypatch.setattr(module, "thread_pool_exec", raise_error) + monkeypatch.setattr(module, "server_error_response", lambda e: {"code": 500, "message": str(e)}) + res = _run(module.download_attachment("att1")) + assert res["code"] == 500 + assert "download boom" in res["message"] + + def test_change_parser_guards_and_reset_update_failure_unit(self, document_app_module, monkeypatch): + module = document_app_module + + monkeypatch.setattr(module, "server_error_response", lambda e: {"code": 500, "message": str(e)}) + + async def req_auth_fail(): + return {"doc_id": "doc1", "parser_id": "naive", "pipeline_id": "pipe2"} + + monkeypatch.setattr(module, "get_request_json", req_auth_fail) + monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: False) + res = _run(module.change_parser.__wrapped__()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + + monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (False, None)) + res = _run(module.change_parser.__wrapped__()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "Document not found!" in res["message"] + + async def req_same_pipeline(): + return {"doc_id": "doc1", "parser_id": "naive", "pipeline_id": "pipe1"} + + doc_same = SimpleNamespace( + id="doc1", + pipeline_id="pipe1", + parser_id="naive", + parser_config={"k": "v"}, + token_num=0, + chunk_num=0, + process_duration=0, + kb_id="kb1", + type="doc", + name="doc.txt", + ) + monkeypatch.setattr(module, "get_request_json", req_same_pipeline) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (True, doc_same)) + res = _run(module.change_parser.__wrapped__()) + assert res["code"] == 0 + + calls = [] + + async def req_pipeline_change(): + return {"doc_id": "doc1", "parser_id": "naive", "pipeline_id": "pipe2"} + + doc = SimpleNamespace( + id="doc1", + pipeline_id="pipe1", + parser_id="naive", + parser_config={}, + token_num=0, + chunk_num=0, + process_duration=0, + kb_id="kb1", + type="doc", + name="doc.txt", + ) + + def fake_update_by_id(doc_id, payload): + calls.append((doc_id, payload)) + return True + + monkeypatch.setattr(module, "get_request_json", req_pipeline_change) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (True, doc)) + monkeypatch.setattr(module.DocumentService, "update_by_id", fake_update_by_id) + res = _run(module.change_parser.__wrapped__()) + assert res["code"] == 0 + assert calls[0][1] == {"pipeline_id": "pipe2"} + assert calls[1][1]["run"] == module.TaskStatus.UNSTART.value + + doc.token_num = 3 + doc.chunk_num = 2 + doc.process_duration = 9 + monkeypatch.setattr(module.DocumentService, "increment_chunk_num", lambda *_args, **_kwargs: False) + res = _run(module.change_parser.__wrapped__()) + assert res["code"] == 0 + + monkeypatch.setattr(module.DocumentService, "increment_chunk_num", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.DocumentService, "get_tenant_id", lambda _doc_id: None) + res = _run(module.change_parser.__wrapped__()) + assert res["code"] == 0 + + side_effects = {"img": [], "delete": []} + + class _DocStore: + def index_exist(self, _idx, _kb_id): + return True + + def delete(self, where, _idx, kb_id): + side_effects["delete"].append((where["doc_id"], kb_id)) + + monkeypatch.setattr(module.DocumentService, "get_tenant_id", lambda _doc_id: "tenant1") + monkeypatch.setattr(module.DocumentService, "delete_chunk_images", lambda _doc, _tenant: side_effects["img"].append((_doc.id, _tenant))) + monkeypatch.setattr(module.search, "index_name", lambda tenant_id: f"idx_{tenant_id}") + monkeypatch.setattr(module.settings, "docStoreConn", _DocStore()) + res = _run(module.change_parser.__wrapped__()) + assert res["code"] == 0 + assert ("doc1", "tenant1") in side_effects["img"] + assert ("doc1", "kb1") in side_effects["delete"] + + async def req_same_parser_with_cfg(): + return {"doc_id": "doc1", "parser_id": "naive", "parser_config": {"a": 1}} + + doc_same_parser = SimpleNamespace( + id="doc1", + pipeline_id="pipe1", + parser_id="naive", + parser_config={"a": 1}, + token_num=0, + chunk_num=0, + process_duration=0, + kb_id="kb1", + type="doc", + name="doc.txt", + ) + monkeypatch.setattr(module, "get_request_json", req_same_parser_with_cfg) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (True, doc_same_parser)) + res = _run(module.change_parser.__wrapped__()) + assert res["code"] == 0 + + async def req_same_parser_no_cfg(): + return {"doc_id": "doc1", "parser_id": "naive"} + + monkeypatch.setattr(module, "get_request_json", req_same_parser_no_cfg) + res = _run(module.change_parser.__wrapped__()) + assert res["code"] == 0 + + parser_cfg_updates = [] + + async def req_parser_update(): + return {"doc_id": "doc1", "parser_id": "paper", "pipeline_id": "", "parser_config": {"beta": True}} + + doc_parser_update = SimpleNamespace( + id="doc1", + pipeline_id="pipe1", + parser_id="naive", + parser_config={"alpha": 1}, + token_num=0, + chunk_num=0, + process_duration=0, + kb_id="kb1", + type="doc", + name="doc.txt", + ) + monkeypatch.setattr(module, "get_request_json", req_parser_update) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (True, doc_parser_update)) + monkeypatch.setattr(module.DocumentService, "update_parser_config", lambda doc_id, cfg: parser_cfg_updates.append((doc_id, cfg))) + monkeypatch.setattr(module.DocumentService, "update_by_id", lambda *_args, **_kwargs: True) + res = _run(module.change_parser.__wrapped__()) + assert res["code"] == 0 + assert parser_cfg_updates == [("doc1", {"beta": True})] + + def raise_parser_config(*_args, **_kwargs): + raise RuntimeError("parser boom") + + monkeypatch.setattr(module.DocumentService, "update_parser_config", raise_parser_config) + res = _run(module.change_parser.__wrapped__()) + assert res["code"] == 500 + assert "parser boom" in res["message"] + + def test_get_image_success_and_exception_unit(self, document_app_module, monkeypatch): + module = document_app_module + + class _Headers(dict): + def set(self, key, value): + self[key] = value + + class _ImageResponse: + def __init__(self, data): + self.data = data + self.headers = _Headers() + + async def fake_thread_pool_exec(*_args, **_kwargs): + return b"image-bytes" + + async def fake_make_response(data): + return _ImageResponse(data) + + monkeypatch.setattr(module, "thread_pool_exec", fake_thread_pool_exec) + monkeypatch.setattr(module, "make_response", fake_make_response) + monkeypatch.setattr(module.settings, "STORAGE_IMPL", SimpleNamespace(get=lambda *_args, **_kwargs: b"image-bytes")) + res = _run(module.get_image("bucket-name")) + assert isinstance(res, _ImageResponse) + assert res.data == b"image-bytes" + assert res.headers["Content-Type"] == "image/JPEG" + + async def raise_error(*_args, **_kwargs): + raise RuntimeError("image boom") + + monkeypatch.setattr(module, "thread_pool_exec", raise_error) + monkeypatch.setattr(module, "server_error_response", lambda e: {"code": 500, "message": str(e)}) + res = _run(module.get_image("bucket-name")) + assert res["code"] == 500 + assert "image boom" in res["message"] + + def test_set_meta_validation_and_persistence_matrix_unit(self, document_app_module, monkeypatch): + module = document_app_module + + def set_req(payload): + async def fake_request_json(): + return payload + + monkeypatch.setattr(module, "get_request_json", fake_request_json) + + set_req({"doc_id": "doc1", "meta": "{}"}) + monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: False) + res = _run(module.set_meta.__wrapped__()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + + monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: True) + set_req({"doc_id": "doc1", "meta": "[]"}) + res = _run(module.set_meta.__wrapped__()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert "Only dictionary type supported." in res["message"] + + set_req({"doc_id": "doc1", "meta": '{"tags":[{"x":1}]}'}) + res = _run(module.set_meta.__wrapped__()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert "The type is not supported in list" in res["message"] + + set_req({"doc_id": "doc1", "meta": '{"obj":{"x":1}}'}) + res = _run(module.set_meta.__wrapped__()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert "The type is not supported" in res["message"] + + set_req({"doc_id": "doc1", "meta": "{"}) + res = _run(module.set_meta.__wrapped__()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert "Json syntax error:" in res["message"] + + set_req({"doc_id": "doc1", "meta": '{"author":"alice"}'}) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (False, None)) + res = _run(module.set_meta.__wrapped__()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "Document not found!" in res["message"] + + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (True, SimpleNamespace(id="doc1"))) + monkeypatch.setattr(module.DocMetadataService, "update_document_metadata", lambda *_args, **_kwargs: False) + res = _run(module.set_meta.__wrapped__()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "Database error (meta updates)!" in res["message"] diff --git a/test/testcases/test_web_api/test_document_app/test_paser_documents.py b/test/testcases/test_web_api/test_document_app/test_paser_documents.py index 6593ec6070..e3efa403d8 100644 --- a/test/testcases/test_web_api/test_document_app/test_paser_documents.py +++ b/test/testcases/test_web_api/test_document_app/test_paser_documents.py @@ -13,7 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import asyncio from concurrent.futures import ThreadPoolExecutor, as_completed +from types import SimpleNamespace import pytest from common import bulk_upload_documents, list_documents, parse_documents @@ -22,6 +24,10 @@ from libs.auth import RAGFlowWebApiAuth from utils import wait_for +def _run(coro): + return asyncio.run(coro) + + @wait_for(30, 1, "Document parsing timeout") def condition(_auth, _kb_id, _document_ids=None): res = list_documents(_auth, {"kb_id": _kb_id}) @@ -194,6 +200,94 @@ def test_concurrent_parse(WebApiAuth, add_dataset_func, tmp_path): validate_document_parse_done(WebApiAuth, kb_id, document_ids) +@pytest.mark.p2 +class TestDocumentsParseUnit: + def test_run_branch_matrix_unit(self, document_app_module, monkeypatch): + module = document_app_module + calls = {"clear": [], "filter_delete": [], "docstore_delete": [], "cancel": [], "run": []} + + async def fake_thread_pool_exec(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(module, "thread_pool_exec", fake_thread_pool_exec) + monkeypatch.setattr(module, "server_error_response", lambda e: {"code": 500, "message": str(e)}) + monkeypatch.setattr(module.search, "index_name", lambda tenant_id: f"idx_{tenant_id}") + monkeypatch.setattr(module, "cancel_all_task_of", lambda doc_id: calls["cancel"].append(doc_id)) + + class _DocStore: + def index_exist(self, _index_name, _kb_id): + return True + + def delete(self, where, _index_name, _kb_id): + calls["docstore_delete"].append(where["doc_id"]) + + monkeypatch.setattr(module.settings, "docStoreConn", _DocStore()) + + async def set_request(payload): + return payload + + def apply_request(payload): + async def fake_request_json(): + return await set_request(payload) + + monkeypatch.setattr(module, "get_request_json", fake_request_json) + + apply_request({"doc_ids": ["doc1"], "run": module.TaskStatus.RUNNING.value}) + monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: False) + res = _run(module.run.__wrapped__()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + + monkeypatch.setattr(module.DocumentService, "accessible", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.DocumentService, "get_tenant_id", lambda _doc_id: None) + res = _run(module.run.__wrapped__()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "Tenant not found!" in res["message"] + + monkeypatch.setattr(module.DocumentService, "get_tenant_id", lambda _doc_id: "tenant1") + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (False, None)) + res = _run(module.run.__wrapped__()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "Document not found!" in res["message"] + + apply_request({"doc_ids": ["doc1"], "run": module.TaskStatus.CANCEL.value}) + doc_cancel = SimpleNamespace(id="doc1", run=module.TaskStatus.DONE.value, kb_id="kb1", parser_config={}, to_dict=lambda: {"id": "doc1"}) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (True, doc_cancel)) + monkeypatch.setattr(module.TaskService, "query", lambda **_kwargs: [SimpleNamespace(progress=1)]) + res = _run(module.run.__wrapped__()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "Cannot cancel a task that is not in RUNNING status" in res["message"] + + apply_request({"doc_ids": ["doc1"], "run": module.TaskStatus.RUNNING.value, "delete": True}) + doc_rerun = SimpleNamespace(id="doc1", run=module.TaskStatus.DONE.value, kb_id="kb1", parser_config={}, to_dict=lambda: {"id": "doc1"}) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (True, doc_rerun)) + monkeypatch.setattr(module.DocumentService, "clear_chunk_num_when_rerun", lambda doc_id: calls["clear"].append(doc_id)) + monkeypatch.setattr(module.TaskService, "filter_delete", lambda _filters: calls["filter_delete"].append(True)) + monkeypatch.setattr(module.DocumentService, "update_by_id", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.DocumentService, "run", lambda tenant_id, doc_dict, _kb_map: calls["run"].append((tenant_id, doc_dict))) + res = _run(module.run.__wrapped__()) + assert res["code"] == 0 + assert calls["clear"] == ["doc1"] + assert calls["filter_delete"] == [True] + assert calls["docstore_delete"] == ["doc1"] + assert calls["run"] == [("tenant1", {"id": "doc1"})] + + apply_request({"doc_ids": ["doc1"], "run": module.TaskStatus.RUNNING.value, "apply_kb": True}) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (False, None)) + res = _run(module.run.__wrapped__()) + assert res["code"] == 500 + assert "Can't find this dataset!" in res["message"] + + apply_request({"doc_ids": ["doc1"], "run": module.TaskStatus.RUNNING.value}) + + def raise_run_error(*_args, **_kwargs): + raise RuntimeError("run boom") + + monkeypatch.setattr(module.DocumentService, "run", raise_run_error) + res = _run(module.run.__wrapped__()) + assert res["code"] == 500 + assert "run boom" in res["message"] + + # @pytest.mark.skip class TestDocumentsParseStop: @pytest.mark.parametrize( diff --git a/test/testcases/test_web_api/test_document_app/test_rm_documents.py b/test/testcases/test_web_api/test_document_app/test_rm_documents.py index 589b6bdf8e..ebcf570fa0 100644 --- a/test/testcases/test_web_api/test_document_app/test_rm_documents.py +++ b/test/testcases/test_web_api/test_document_app/test_rm_documents.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # +import asyncio from concurrent.futures import ThreadPoolExecutor, as_completed import pytest @@ -21,6 +22,10 @@ from configs import INVALID_API_TOKEN from libs.auth import RAGFlowWebApiAuth +def _run(coro): + return asyncio.run(coro) + + @pytest.mark.p2 class TestAuthorization: @pytest.mark.parametrize( @@ -75,6 +80,32 @@ class TestDocumentsDeletion: assert res["message"] == "No authorization.", res +@pytest.mark.p2 +class TestDocumentsDeletionUnit: + def test_rm_string_doc_id_normalization_success_unit(self, document_app_module, monkeypatch): + module = document_app_module + captured = {} + + async def fake_request_json(): + return {"doc_id": "doc1"} + + async def fake_thread_pool_exec(func, doc_ids, user_id): + captured["func"] = func + captured["doc_ids"] = doc_ids + captured["user_id"] = user_id + return None + + monkeypatch.setattr(module, "get_request_json", fake_request_json) + monkeypatch.setattr(module.DocumentService, "accessible4deletion", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module, "thread_pool_exec", fake_thread_pool_exec) + res = _run(module.rm.__wrapped__()) + assert res["code"] == 0 + assert res["data"] is True + assert captured["func"] == module.FileService.delete_docs + assert captured["doc_ids"] == ["doc1"] + assert captured["user_id"] == module.current_user.id + + @pytest.mark.p3 def test_concurrent_deletion(WebApiAuth, add_dataset, tmp_path): count = 100 diff --git a/test/testcases/test_web_api/test_document_app/test_upload_documents.py b/test/testcases/test_web_api/test_document_app/test_upload_documents.py index 228310a35f..b4a151551e 100644 --- a/test/testcases/test_web_api/test_document_app/test_upload_documents.py +++ b/test/testcases/test_web_api/test_document_app/test_upload_documents.py @@ -14,8 +14,9 @@ # limitations under the License. # import asyncio +import sys import string -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace from concurrent.futures import ThreadPoolExecutor, as_completed import pytest @@ -333,6 +334,136 @@ class TestDocumentsUploadUnit: assert res["code"] == 102 assert "file format" in res["message"] + def test_upload_and_parse_matrix_unit(self, document_app_module, monkeypatch): + module = document_app_module + monkeypatch.setattr(module, "request", _DummyRequest(form={"conversation_id": "conv-1"}, files=_DummyFiles({"file": [_DummyFile("")]}))) + res = _run(module.upload_and_parse.__wrapped__()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert res["message"] == "No file selected!" + + files = _DummyFiles({"file": [_DummyFile("note.txt")]}) + monkeypatch.setattr(module, "request", _DummyRequest(form={"conversation_id": "conv-1"}, files=files)) + monkeypatch.setattr(module, "doc_upload_and_parse", lambda _conv_id, _files, _uid: ["doc-1"]) + res = _run(module.upload_and_parse.__wrapped__()) + assert res["code"] == 0 + assert res["data"] == ["doc-1"] + + def test_parse_url_and_multipart_matrix_unit(self, document_app_module, monkeypatch, tmp_path): + module = document_app_module + + async def req_invalid_url(): + return {"url": "not-a-url"} + + monkeypatch.setattr(module, "get_request_json", req_invalid_url) + monkeypatch.setattr(module, "is_valid_url", lambda _url: False) + res = _run(module.parse()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert res["message"] == "The URL format is invalid" + + webdriver_mod = ModuleType("seleniumwire.webdriver") + + class _FakeChromeOptions: + def __init__(self): + self.args = [] + self.experimental = {} + + def add_argument(self, arg): + self.args.append(arg) + + def add_experimental_option(self, key, value): + self.experimental[key] = value + + class _Req: + def __init__(self, headers): + self.response = SimpleNamespace(headers=headers) + + class _FakeDriver: + def __init__(self, requests, page_source): + self.requests = requests + self.page_source = page_source + self.quit_called = False + self.visited = [] + self.options = None + + def get(self, url): + self.visited.append(url) + + def quit(self): + self.quit_called = True + + queue = [] + created = [] + + def _fake_chrome(options=None): + driver = queue.pop(0) + driver.options = options + created.append(driver) + return driver + + webdriver_mod.Chrome = _fake_chrome + webdriver_mod.ChromeOptions = _FakeChromeOptions + + seleniumwire_mod = ModuleType("seleniumwire") + seleniumwire_mod.webdriver = webdriver_mod + monkeypatch.setitem(sys.modules, "seleniumwire", seleniumwire_mod) + monkeypatch.setitem(sys.modules, "seleniumwire.webdriver", webdriver_mod) + monkeypatch.setattr(module, "get_project_base_directory", lambda: str(tmp_path)) + monkeypatch.setattr(module, "is_valid_url", lambda _url: True) + + class _Parser: + def parser_txt(self, page_source): + assert "page" in page_source + return ["section1", "section2"] + + monkeypatch.setattr(module, "RAGFlowHtmlParser", lambda: _Parser()) + queue.append(_FakeDriver([_Req({"x": "1"}), _Req({"y": "2"})], "page")) + + async def req_url_html(): + return {"url": "http://example.com/html"} + + monkeypatch.setattr(module, "get_request_json", req_url_html) + res = _run(module.parse()) + assert res["code"] == 0 + assert res["data"] == "section1\nsection2" + assert created[-1].quit_called is True + + (tmp_path / "logs" / "downloads").mkdir(parents=True, exist_ok=True) + (tmp_path / "logs" / "downloads" / "doc.txt").write_bytes(b"downloaded-bytes") + queue.append(_FakeDriver([_Req({"content-disposition": 'attachment; filename="doc.txt"'})], "file")) + captured = {} + + def parse_docs_read(files, _uid): + captured["filename"] = files[0].filename + captured["content"] = files[0].read() + return "parsed-download" + + monkeypatch.setattr(module.FileService, "parse_docs", parse_docs_read) + + async def req_url_file(): + return {"url": "http://example.com/file"} + + monkeypatch.setattr(module, "get_request_json", req_url_file) + res = _run(module.parse()) + assert res["code"] == 0 + assert res["data"] == "parsed-download" + assert captured["filename"] == "doc.txt" + assert captured["content"] == b"downloaded-bytes" + + async def req_no_url(): + return {} + + monkeypatch.setattr(module, "get_request_json", req_no_url) + monkeypatch.setattr(module, "request", _DummyRequest(files=_DummyFiles())) + res = _run(module.parse()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert res["message"] == "No file part!" + + monkeypatch.setattr(module, "request", _DummyRequest(files=_DummyFiles({"file": [_DummyFile("f1.txt")]}))) + monkeypatch.setattr(module.FileService, "parse_docs", lambda _files, _uid: "parsed-upload") + res = _run(module.parse()) + assert res["code"] == 0 + assert res["data"] == "parsed-upload" + @pytest.mark.p2 class TestWebCrawlUnit: diff --git a/test/testcases/test_web_api/test_evaluation_app/test_evaluation_routes_unit.py b/test/testcases/test_web_api/test_evaluation_app/test_evaluation_routes_unit.py new file mode 100644 index 0000000000..938d82d3d2 --- /dev/null +++ b/test/testcases/test_web_api/test_evaluation_app/test_evaluation_routes_unit.py @@ -0,0 +1,575 @@ +# +# 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 pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +class _DummyManager: + def route(self, *_args, **_kwargs): + def decorator(func): + return func + + return decorator + + +class _Args(dict): + def get(self, key, default=None): + return super().get(key, default) + + +class _DummyRetCode: + SUCCESS = 0 + EXCEPTION_ERROR = 100 + ARGUMENT_ERROR = 101 + DATA_ERROR = 102 + OPERATING_ERROR = 103 + AUTHENTICATION_ERROR = 109 + + +def _run(coro): + return asyncio.run(coro) + + +def _set_request_json(monkeypatch, module, payload): + async def _request_json(): + return payload + + monkeypatch.setattr(module, "get_request_json", _request_json) + + +def _set_request_args(monkeypatch, module, args=None): + monkeypatch.setattr(module, "request", SimpleNamespace(args=_Args(args or {}))) + + +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + +def _load_evaluation_app(monkeypatch): + repo_root = Path(__file__).resolve().parents[4] + + quart_mod = ModuleType("quart") + quart_mod.request = SimpleNamespace(args=_Args()) + monkeypatch.setitem(sys.modules, "quart", quart_mod) + + common_pkg = ModuleType("common") + common_pkg.__path__ = [str(repo_root / "common")] + monkeypatch.setitem(sys.modules, "common", common_pkg) + + constants_mod = ModuleType("common.constants") + constants_mod.RetCode = _DummyRetCode + monkeypatch.setitem(sys.modules, "common.constants", constants_mod) + common_pkg.constants = constants_mod + + api_pkg = ModuleType("api") + api_pkg.__path__ = [str(repo_root / "api")] + monkeypatch.setitem(sys.modules, "api", api_pkg) + + apps_mod = ModuleType("api.apps") + apps_mod.__path__ = [str(repo_root / "api" / "apps")] + apps_mod.current_user = SimpleNamespace(id="tenant-1") + apps_mod.login_required = lambda func: func + monkeypatch.setitem(sys.modules, "api.apps", apps_mod) + api_pkg.apps = apps_mod + + db_pkg = ModuleType("api.db") + db_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.db", db_pkg) + api_pkg.db = db_pkg + + services_pkg = ModuleType("api.db.services") + services_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.db.services", services_pkg) + + evaluation_service_mod = ModuleType("api.db.services.evaluation_service") + + class _EvaluationService: + @staticmethod + def create_dataset(**_kwargs): + return True, "dataset-1" + + @staticmethod + def list_datasets(**_kwargs): + return {"datasets": [], "total": 0} + + @staticmethod + def get_dataset(_dataset_id): + return {"id": _dataset_id} + + @staticmethod + def update_dataset(_dataset_id, **_kwargs): + return True + + @staticmethod + def delete_dataset(_dataset_id): + return True + + @staticmethod + def add_test_case(**_kwargs): + return True, "case-1" + + @staticmethod + def import_test_cases(**_kwargs): + return 0, 0 + + @staticmethod + def get_test_cases(_dataset_id): + return [] + + @staticmethod + def delete_test_case(_case_id): + return True + + @staticmethod + def start_evaluation(**_kwargs): + return True, "run-1" + + @staticmethod + def get_run_results(_run_id): + return {"id": _run_id} + + @staticmethod + def get_recommendations(_run_id): + return [] + + evaluation_service_mod.EvaluationService = _EvaluationService + monkeypatch.setitem(sys.modules, "api.db.services.evaluation_service", evaluation_service_mod) + + utils_pkg = ModuleType("api.utils") + utils_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.utils", utils_pkg) + + api_utils_mod = ModuleType("api.utils.api_utils") + + async def _default_request_json(): + return {} + + def _get_data_error_result(code=_DummyRetCode.DATA_ERROR, message="Sorry! Data missing!"): + return {"code": code, "message": message} + + def _get_json_result(code=_DummyRetCode.SUCCESS, message="success", data=None): + return {"code": code, "message": message, "data": data} + + def _server_error_response(error): + return {"code": _DummyRetCode.EXCEPTION_ERROR, "message": repr(error)} + + def _validate_request(*_args, **_kwargs): + def _decorator(func): + return func + + return _decorator + + api_utils_mod.get_data_error_result = _get_data_error_result + api_utils_mod.get_json_result = _get_json_result + api_utils_mod.get_request_json = _default_request_json + api_utils_mod.server_error_response = _server_error_response + api_utils_mod.validate_request = _validate_request + monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod) + utils_pkg.api_utils = api_utils_mod + + module_name = "test_evaluation_routes_unit_module" + module_path = repo_root / "api" / "apps" / "evaluation_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 + + +@pytest.mark.p2 +def test_dataset_routes_matrix_unit(monkeypatch): + module = _load_evaluation_app(monkeypatch) + + _set_request_json(monkeypatch, module, {"name": " data-1 ", "description": "desc", "kb_ids": ["kb-1"]}) + monkeypatch.setattr(module.EvaluationService, "create_dataset", lambda **_kwargs: (True, "dataset-ok")) + res = _run(module.create_dataset()) + assert res["code"] == 0 + assert res["data"]["dataset_id"] == "dataset-ok" + + _set_request_json(monkeypatch, module, {"name": " ", "kb_ids": ["kb-1"]}) + res = _run(module.create_dataset()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "empty" in res["message"].lower() + + _set_request_json(monkeypatch, module, {"name": "data-2", "kb_ids": "kb-1"}) + res = _run(module.create_dataset()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "kb_ids" in res["message"] + + _set_request_json(monkeypatch, module, {"name": "data-3", "kb_ids": ["kb-1"]}) + monkeypatch.setattr(module.EvaluationService, "create_dataset", lambda **_kwargs: (False, "create failed")) + res = _run(module.create_dataset()) + assert res["code"] == module.RetCode.DATA_ERROR + assert res["message"] == "create failed" + + def _raise_create(**_kwargs): + raise RuntimeError("create boom") + + monkeypatch.setattr(module.EvaluationService, "create_dataset", _raise_create) + res = _run(module.create_dataset()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "create boom" in res["message"] + + _set_request_args(monkeypatch, module, {"page": "2", "page_size": "3"}) + monkeypatch.setattr(module.EvaluationService, "list_datasets", lambda **_kwargs: {"datasets": [{"id": "a"}], "total": 1}) + res = _run(module.list_datasets()) + assert res["code"] == 0 + assert res["data"]["total"] == 1 + + _set_request_args(monkeypatch, module, {"page": "x"}) + res = _run(module.list_datasets()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + + monkeypatch.setattr(module.EvaluationService, "get_dataset", lambda _dataset_id: None) + res = _run(module.get_dataset("dataset-1")) + assert res["code"] == module.RetCode.DATA_ERROR + assert "not found" in res["message"].lower() + + monkeypatch.setattr(module.EvaluationService, "get_dataset", lambda _dataset_id: {"id": _dataset_id}) + res = _run(module.get_dataset("dataset-2")) + assert res["code"] == 0 + assert res["data"]["id"] == "dataset-2" + + def _raise_get(_dataset_id): + raise RuntimeError("get dataset boom") + + monkeypatch.setattr(module.EvaluationService, "get_dataset", _raise_get) + res = _run(module.get_dataset("dataset-3")) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "get dataset boom" in res["message"] + + captured = {} + + def _update(dataset_id, **kwargs): + captured["dataset_id"] = dataset_id + captured["kwargs"] = kwargs + return True + + _set_request_json( + monkeypatch, + module, + { + "id": "forbidden", + "tenant_id": "forbidden", + "created_by": "forbidden", + "create_time": 123, + "name": "new-name", + }, + ) + monkeypatch.setattr(module.EvaluationService, "update_dataset", _update) + res = _run(module.update_dataset("dataset-4")) + assert res["code"] == 0 + assert res["data"]["dataset_id"] == "dataset-4" + assert captured["dataset_id"] == "dataset-4" + assert "id" not in captured["kwargs"] + assert "tenant_id" not in captured["kwargs"] + assert "created_by" not in captured["kwargs"] + assert "create_time" not in captured["kwargs"] + + _set_request_json(monkeypatch, module, {"name": "new-name"}) + monkeypatch.setattr(module.EvaluationService, "update_dataset", lambda _dataset_id, **_kwargs: False) + res = _run(module.update_dataset("dataset-5")) + assert res["code"] == module.RetCode.DATA_ERROR + assert "failed" in res["message"].lower() + + def _raise_update(_dataset_id, **_kwargs): + raise RuntimeError("update boom") + + monkeypatch.setattr(module.EvaluationService, "update_dataset", _raise_update) + res = _run(module.update_dataset("dataset-6")) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "update boom" in res["message"] + + monkeypatch.setattr(module.EvaluationService, "delete_dataset", lambda _dataset_id: False) + res = _run(module.delete_dataset("dataset-7")) + assert res["code"] == module.RetCode.DATA_ERROR + assert "failed" in res["message"].lower() + + monkeypatch.setattr(module.EvaluationService, "delete_dataset", lambda _dataset_id: True) + res = _run(module.delete_dataset("dataset-8")) + assert res["code"] == 0 + assert res["data"]["dataset_id"] == "dataset-8" + + def _raise_delete(_dataset_id): + raise RuntimeError("delete dataset boom") + + monkeypatch.setattr(module.EvaluationService, "delete_dataset", _raise_delete) + res = _run(module.delete_dataset("dataset-9")) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "delete dataset boom" in res["message"] + + +@pytest.mark.p2 +def test_test_case_routes_matrix_unit(monkeypatch): + module = _load_evaluation_app(monkeypatch) + + _set_request_json(monkeypatch, module, {"question": " "}) + res = _run(module.add_test_case("dataset-1")) + assert res["code"] == module.RetCode.DATA_ERROR + assert "question" in res["message"].lower() + + _set_request_json(monkeypatch, module, {"question": "q1"}) + monkeypatch.setattr(module.EvaluationService, "add_test_case", lambda **_kwargs: (False, "add failed")) + res = _run(module.add_test_case("dataset-2")) + assert res["code"] == module.RetCode.DATA_ERROR + assert "add failed" in res["message"] + + _set_request_json( + monkeypatch, + module, + { + "question": "q2", + "reference_answer": "a2", + "relevant_doc_ids": ["doc-1"], + "relevant_chunk_ids": ["chunk-1"], + "metadata": {"k": "v"}, + }, + ) + monkeypatch.setattr(module.EvaluationService, "add_test_case", lambda **_kwargs: (True, "case-ok")) + res = _run(module.add_test_case("dataset-3")) + assert res["code"] == 0 + assert res["data"]["case_id"] == "case-ok" + + def _raise_add(**_kwargs): + raise RuntimeError("add case boom") + + monkeypatch.setattr(module.EvaluationService, "add_test_case", _raise_add) + res = _run(module.add_test_case("dataset-4")) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "add case boom" in res["message"] + + _set_request_json(monkeypatch, module, {"cases": {}}) + res = _run(module.import_test_cases("dataset-5")) + assert res["code"] == module.RetCode.DATA_ERROR + assert "cases" in res["message"] + + _set_request_json(monkeypatch, module, {"cases": [{"question": "q1"}, {"question": "q2"}]}) + monkeypatch.setattr(module.EvaluationService, "import_test_cases", lambda **_kwargs: (2, 0)) + res = _run(module.import_test_cases("dataset-6")) + assert res["code"] == 0 + assert res["data"]["success_count"] == 2 + assert res["data"]["failure_count"] == 0 + assert res["data"]["total"] == 2 + + def _raise_import(**_kwargs): + raise RuntimeError("import boom") + + monkeypatch.setattr(module.EvaluationService, "import_test_cases", _raise_import) + res = _run(module.import_test_cases("dataset-7")) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "import boom" in res["message"] + + monkeypatch.setattr(module.EvaluationService, "get_test_cases", lambda _dataset_id: [{"id": "case-1"}]) + res = _run(module.get_test_cases("dataset-8")) + assert res["code"] == 0 + assert res["data"]["total"] == 1 + assert res["data"]["cases"][0]["id"] == "case-1" + + def _raise_get_cases(_dataset_id): + raise RuntimeError("get cases boom") + + monkeypatch.setattr(module.EvaluationService, "get_test_cases", _raise_get_cases) + res = _run(module.get_test_cases("dataset-9")) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "get cases boom" in res["message"] + + monkeypatch.setattr(module.EvaluationService, "delete_test_case", lambda _case_id: False) + res = _run(module.delete_test_case("case-1")) + assert res["code"] == module.RetCode.DATA_ERROR + assert "failed" in res["message"].lower() + + monkeypatch.setattr(module.EvaluationService, "delete_test_case", lambda _case_id: True) + res = _run(module.delete_test_case("case-2")) + assert res["code"] == 0 + assert res["data"]["case_id"] == "case-2" + + def _raise_delete_case(_case_id): + raise RuntimeError("delete case boom") + + monkeypatch.setattr(module.EvaluationService, "delete_test_case", _raise_delete_case) + res = _run(module.delete_test_case("case-3")) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "delete case boom" in res["message"] + + +@pytest.mark.p2 +def test_run_and_recommendation_routes_matrix_unit(monkeypatch): + module = _load_evaluation_app(monkeypatch) + + _set_request_json(monkeypatch, module, {"dataset_id": "d1", "dialog_id": "dialog-1", "name": "run 1"}) + monkeypatch.setattr(module.EvaluationService, "start_evaluation", lambda **_kwargs: (False, "start failed")) + res = _run(module.start_evaluation()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "start failed" in res["message"] + + monkeypatch.setattr(module.EvaluationService, "start_evaluation", lambda **_kwargs: (True, "run-ok")) + res = _run(module.start_evaluation()) + assert res["code"] == 0 + assert res["data"]["run_id"] == "run-ok" + + def _raise_start(**_kwargs): + raise RuntimeError("start boom") + + monkeypatch.setattr(module.EvaluationService, "start_evaluation", _raise_start) + res = _run(module.start_evaluation()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "start boom" in res["message"] + + monkeypatch.setattr(module.EvaluationService, "get_run_results", lambda _run_id: None) + res = _run(module.get_evaluation_run("run-1")) + assert res["code"] == module.RetCode.DATA_ERROR + assert "not found" in res["message"].lower() + + monkeypatch.setattr(module.EvaluationService, "get_run_results", lambda _run_id: {"id": _run_id}) + res = _run(module.get_evaluation_run("run-2")) + assert res["code"] == 0 + assert res["data"]["id"] == "run-2" + + def _raise_get_run(_run_id): + raise RuntimeError("get run boom") + + monkeypatch.setattr(module.EvaluationService, "get_run_results", _raise_get_run) + res = _run(module.get_evaluation_run("run-3")) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "get run boom" in res["message"] + + monkeypatch.setattr(module.EvaluationService, "get_run_results", lambda _run_id: None) + res = _run(module.get_run_results("run-4")) + assert res["code"] == module.RetCode.DATA_ERROR + assert "not found" in res["message"].lower() + + monkeypatch.setattr(module.EvaluationService, "get_run_results", lambda _run_id: {"id": _run_id, "score": 0.9}) + res = _run(module.get_run_results("run-5")) + assert res["code"] == 0 + assert res["data"]["id"] == "run-5" + + def _raise_results(_run_id): + raise RuntimeError("get results boom") + + monkeypatch.setattr(module.EvaluationService, "get_run_results", _raise_results) + res = _run(module.get_run_results("run-6")) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "get results boom" in res["message"] + + res = _run(module.list_evaluation_runs()) + assert res["code"] == 0 + assert res["data"]["total"] == 0 + + def _raise_json_list(*_args, **_kwargs): + raise RuntimeError("list runs boom") + + monkeypatch.setattr(module, "get_json_result", _raise_json_list) + res = _run(module.list_evaluation_runs()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "list runs boom" in res["message"] + + monkeypatch.setattr(module, "get_json_result", lambda code=0, message="success", data=None: {"code": code, "message": message, "data": data}) + res = _run(module.delete_evaluation_run("run-7")) + assert res["code"] == 0 + assert res["data"]["run_id"] == "run-7" + + def _raise_json_delete(*_args, **_kwargs): + raise RuntimeError("delete run boom") + + monkeypatch.setattr(module, "get_json_result", _raise_json_delete) + res = _run(module.delete_evaluation_run("run-8")) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "delete run boom" in res["message"] + + monkeypatch.setattr(module, "get_json_result", lambda code=0, message="success", data=None: {"code": code, "message": message, "data": data}) + monkeypatch.setattr(module.EvaluationService, "get_recommendations", lambda _run_id: [{"name": "cfg-1"}]) + res = _run(module.get_recommendations("run-9")) + assert res["code"] == 0 + assert res["data"]["recommendations"][0]["name"] == "cfg-1" + + def _raise_recommend(_run_id): + raise RuntimeError("recommend boom") + + monkeypatch.setattr(module.EvaluationService, "get_recommendations", _raise_recommend) + res = _run(module.get_recommendations("run-10")) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "recommend boom" in res["message"] + + +@pytest.mark.p2 +def test_compare_export_and_evaluate_single_matrix_unit(monkeypatch): + module = _load_evaluation_app(monkeypatch) + + _set_request_json(monkeypatch, module, {"run_ids": ["run-1"]}) + res = _run(module.compare_runs()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "at least 2" in res["message"] + + _set_request_json(monkeypatch, module, {"run_ids": ["run-1", "run-2"]}) + res = _run(module.compare_runs()) + assert res["code"] == 0 + assert res["data"]["comparison"] == {} + + def _raise_json_compare(*_args, **_kwargs): + raise RuntimeError("compare boom") + + monkeypatch.setattr(module, "get_json_result", _raise_json_compare) + _set_request_json(monkeypatch, module, {"run_ids": ["run-1", "run-2", "run-3"]}) + res = _run(module.compare_runs()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "compare boom" in res["message"] + + monkeypatch.setattr(module, "get_json_result", lambda code=0, message="success", data=None: {"code": code, "message": message, "data": data}) + monkeypatch.setattr(module.EvaluationService, "get_run_results", lambda _run_id: None) + res = _run(module.export_results("run-11")) + assert res["code"] == module.RetCode.DATA_ERROR + assert "not found" in res["message"].lower() + + monkeypatch.setattr(module.EvaluationService, "get_run_results", lambda _run_id: {"id": _run_id, "rows": []}) + res = _run(module.export_results("run-12")) + assert res["code"] == 0 + assert res["data"]["id"] == "run-12" + + def _raise_export(_run_id): + raise RuntimeError("export boom") + + monkeypatch.setattr(module.EvaluationService, "get_run_results", _raise_export) + res = _run(module.export_results("run-13")) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "export boom" in res["message"] + + monkeypatch.setattr(module, "get_json_result", lambda code=0, message="success", data=None: {"code": code, "message": message, "data": data}) + res = _run(module.evaluate_single()) + assert res["code"] == 0 + assert res["data"]["answer"] == "" + assert res["data"]["metrics"] == {} + assert res["data"]["retrieved_chunks"] == [] + + def _raise_json_single(*_args, **_kwargs): + raise RuntimeError("single boom") + + monkeypatch.setattr(module, "get_json_result", _raise_json_single) + res = _run(module.evaluate_single()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "single boom" in res["message"] diff --git a/test/testcases/test_web_api/test_file_app/test_file2document_routes_unit.py b/test/testcases/test_web_api/test_file_app/test_file2document_routes_unit.py new file mode 100644 index 0000000000..df1294f791 --- /dev/null +++ b/test/testcases/test_web_api/test_file_app/test_file2document_routes_unit.py @@ -0,0 +1,367 @@ +# +# 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 functools +import importlib.util +import sys +from copy import deepcopy +from enum import Enum +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +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 _DummyFile: + def __init__(self, file_id, file_type, *, name="file.txt", location="loc", size=1): + self.id = file_id + self.type = file_type + self.name = name + self.location = location + self.size = size + + +class _FalsyFile(_DummyFile): + def __bool__(self): + return False + + +def _run(coro): + return asyncio.run(coro) + + +def _set_request_json(monkeypatch, module, payload_state): + async def _req_json(): + return deepcopy(payload_state) + + monkeypatch.setattr(module, "get_request_json", _req_json) + + +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + +def _load_file2document_module(monkeypatch): + repo_root = Path(__file__).resolve().parents[4] + + api_pkg = ModuleType("api") + api_pkg.__path__ = [str(repo_root / "api")] + monkeypatch.setitem(sys.modules, "api", api_pkg) + + apps_mod = ModuleType("api.apps") + apps_mod.__path__ = [str(repo_root / "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) + api_pkg.apps = apps_mod + + db_pkg = ModuleType("api.db") + db_pkg.__path__ = [] + + class _FileType(Enum): + FOLDER = "folder" + DOC = "doc" + + db_pkg.FileType = _FileType + monkeypatch.setitem(sys.modules, "api.db", db_pkg) + api_pkg.db = db_pkg + + services_pkg = ModuleType("api.db.services") + services_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.db.services", services_pkg) + + file2document_mod = ModuleType("api.db.services.file2document_service") + + class _StubFile2DocumentService: + @staticmethod + def get_by_file_id(_file_id): + return [] + + @staticmethod + def delete_by_file_id(*_args, **_kwargs): + return None + + @staticmethod + def insert(_payload): + return SimpleNamespace(to_json=lambda: {}) + + file2document_mod.File2DocumentService = _StubFile2DocumentService + monkeypatch.setitem(sys.modules, "api.db.services.file2document_service", file2document_mod) + services_pkg.file2document_service = file2document_mod + + file_service_mod = ModuleType("api.db.services.file_service") + + class _StubFileService: + @staticmethod + def get_by_ids(_file_ids): + return [] + + @staticmethod + def get_all_innermost_file_ids(_file_id, _acc): + return [] + + @staticmethod + def get_by_id(_file_id): + return True, _DummyFile(_file_id, _FileType.DOC.value) + + file_service_mod.FileService = _StubFileService + monkeypatch.setitem(sys.modules, "api.db.services.file_service", file_service_mod) + services_pkg.file_service = file_service_mod + + kb_service_mod = ModuleType("api.db.services.knowledgebase_service") + + class _StubKnowledgebaseService: + @staticmethod + def get_by_id(_kb_id): + return False, None + + kb_service_mod.KnowledgebaseService = _StubKnowledgebaseService + monkeypatch.setitem(sys.modules, "api.db.services.knowledgebase_service", kb_service_mod) + services_pkg.knowledgebase_service = kb_service_mod + + document_service_mod = ModuleType("api.db.services.document_service") + + class _StubDocumentService: + @staticmethod + def get_by_id(doc_id): + return True, SimpleNamespace(id=doc_id) + + @staticmethod + def get_tenant_id(_doc_id): + return "tenant-1" + + @staticmethod + def remove_document(*_args, **_kwargs): + return True + + @staticmethod + def insert(_payload): + return SimpleNamespace(id="doc-1") + + document_service_mod.DocumentService = _StubDocumentService + monkeypatch.setitem(sys.modules, "api.db.services.document_service", document_service_mod) + services_pkg.document_service = document_service_mod + + api_utils_mod = ModuleType("api.utils.api_utils") + + def get_json_result(data=None, message="", code=0): + return {"code": code, "data": data, "message": message} + + def get_data_error_result(message=""): + return {"code": 102, "data": None, "message": message} + + async def get_request_json(): + return {} + + def server_error_response(err): + return {"code": 500, "data": None, "message": str(err)} + + def validate_request(*_keys): + def _decorator(func): + @functools.wraps(func) + async def _wrapper(*args, **kwargs): + return await func(*args, **kwargs) + + return _wrapper + + return _decorator + + api_utils_mod.get_json_result = get_json_result + api_utils_mod.get_data_error_result = get_data_error_result + api_utils_mod.get_request_json = get_request_json + api_utils_mod.server_error_response = server_error_response + api_utils_mod.validate_request = validate_request + monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod) + + misc_utils_mod = ModuleType("common.misc_utils") + misc_utils_mod.get_uuid = lambda: "uuid" + monkeypatch.setitem(sys.modules, "common.misc_utils", misc_utils_mod) + + constants_mod = ModuleType("common.constants") + + class _RetCode: + ARGUMENT_ERROR = 101 + + constants_mod.RetCode = _RetCode + monkeypatch.setitem(sys.modules, "common.constants", constants_mod) + + module_name = "test_file2document_routes_unit_module" + module_path = repo_root / "api" / "apps" / "file2document_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 + + +@pytest.mark.p2 +def test_convert_branch_matrix_unit(monkeypatch): + module = _load_file2document_module(monkeypatch) + req_state = {"kb_ids": ["kb-1"], "file_ids": ["f1"]} + _set_request_json(monkeypatch, module, req_state) + + events = {"deleted": []} + + monkeypatch.setattr(module.FileService, "get_by_ids", lambda _ids: [_FalsyFile("f1", module.FileType.DOC.value)]) + res = _run(module.convert()) + assert res["message"] == "File not found!" + + monkeypatch.setattr(module.FileService, "get_by_ids", lambda _ids: [_DummyFile("f1", module.FileType.DOC.value)]) + monkeypatch.setattr(module.File2DocumentService, "get_by_file_id", lambda _file_id: [SimpleNamespace(document_id="doc-1")]) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (False, None)) + res = _run(module.convert()) + assert res["message"] == "Document not found!" + + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (True, SimpleNamespace(id=_doc_id))) + monkeypatch.setattr(module.DocumentService, "get_tenant_id", lambda _doc_id: None) + res = _run(module.convert()) + assert res["message"] == "Tenant not found!" + + monkeypatch.setattr(module.DocumentService, "get_tenant_id", lambda _doc_id: "tenant-1") + monkeypatch.setattr(module.DocumentService, "remove_document", lambda *_args, **_kwargs: False) + res = _run(module.convert()) + assert "Document removal" in res["message"] + + monkeypatch.setattr(module.DocumentService, "remove_document", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.File2DocumentService, "get_by_file_id", lambda _file_id: []) + monkeypatch.setattr(module.File2DocumentService, "delete_by_file_id", lambda file_id: events["deleted"].append(file_id)) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (False, None)) + res = _run(module.convert()) + assert res["message"] == "Can't find this dataset!" + assert events["deleted"] == ["f1"] + + kb = SimpleNamespace(id="kb-1", parser_id="naive", pipeline_id="p1", parser_config={}) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (True, kb)) + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (False, None)) + res = _run(module.convert()) + assert res["message"] == "Can't find this file!" + + req_state["file_ids"] = ["folder-1"] + monkeypatch.setattr(module.FileService, "get_by_ids", lambda _ids: [_DummyFile("folder-1", module.FileType.FOLDER.value, name="folder")]) + monkeypatch.setattr(module.FileService, "get_all_innermost_file_ids", lambda _file_id, _acc: ["inner-1"]) + monkeypatch.setattr( + module.FileService, + "get_by_id", + lambda _file_id: (True, _DummyFile("inner-1", module.FileType.DOC.value, name="inner.txt", location="inner.loc", size=2)), + ) + monkeypatch.setattr(module.DocumentService, "insert", lambda _payload: SimpleNamespace(id="doc-new")) + monkeypatch.setattr( + module.File2DocumentService, + "insert", + lambda _payload: SimpleNamespace(to_json=lambda: {"file_id": "inner-1", "document_id": "doc-new"}), + ) + res = _run(module.convert()) + assert res["code"] == 0 + assert res["data"] == [{"file_id": "inner-1", "document_id": "doc-new"}] + + req_state["file_ids"] = ["f1"] + monkeypatch.setattr( + module.FileService, + "get_by_ids", + lambda _ids: (_ for _ in ()).throw(RuntimeError("convert boom")), + ) + res = _run(module.convert()) + assert res["code"] == 500 + assert "convert boom" in res["message"] + + +@pytest.mark.p2 +def test_rm_branch_matrix_unit(monkeypatch): + module = _load_file2document_module(monkeypatch) + req_state = {"file_ids": []} + _set_request_json(monkeypatch, module, req_state) + + deleted = [] + + res = _run(module.rm()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert 'Lack of "Files ID"' in res["message"] + + req_state["file_ids"] = ["f1"] + monkeypatch.setattr(module.File2DocumentService, "get_by_file_id", lambda _file_id: []) + res = _run(module.rm()) + assert res["message"] == "Inform not found!" + + monkeypatch.setattr(module.File2DocumentService, "get_by_file_id", lambda _file_id: [None]) + res = _run(module.rm()) + assert res["message"] == "Inform not found!" + + monkeypatch.setattr(module.File2DocumentService, "get_by_file_id", lambda _file_id: [SimpleNamespace(document_id="doc-1")]) + monkeypatch.setattr(module.File2DocumentService, "delete_by_file_id", lambda file_id: deleted.append(file_id)) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (False, None)) + res = _run(module.rm()) + assert res["message"] == "Document not found!" + assert deleted == ["f1"] + + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda _doc_id: (True, SimpleNamespace(id=_doc_id))) + monkeypatch.setattr(module.DocumentService, "get_tenant_id", lambda _doc_id: None) + res = _run(module.rm()) + assert res["message"] == "Tenant not found!" + + monkeypatch.setattr(module.DocumentService, "get_tenant_id", lambda _doc_id: "tenant-1") + monkeypatch.setattr(module.DocumentService, "remove_document", lambda *_args, **_kwargs: False) + res = _run(module.rm()) + assert "Document removal" in res["message"] + + req_state["file_ids"] = ["f1", "f2"] + monkeypatch.setattr( + module.File2DocumentService, + "get_by_file_id", + lambda file_id: [SimpleNamespace(document_id=f"doc-{file_id}")], + ) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda doc_id: (True, SimpleNamespace(id=doc_id))) + monkeypatch.setattr(module.DocumentService, "get_tenant_id", lambda _doc_id: "tenant-1") + monkeypatch.setattr(module.DocumentService, "remove_document", lambda *_args, **_kwargs: True) + res = _run(module.rm()) + assert res["code"] == 0 + assert res["data"] is True + + monkeypatch.setattr( + module.File2DocumentService, + "get_by_file_id", + lambda _file_id: (_ for _ in ()).throw(RuntimeError("rm boom")), + ) + req_state["file_ids"] = ["boom"] + res = _run(module.rm()) + assert res["code"] == 500 + assert "rm boom" in res["message"] diff --git a/test/testcases/test_web_api/test_file_app/test_file_routes_unit.py b/test/testcases/test_web_api/test_file_app/test_file_routes_unit.py new file mode 100644 index 0000000000..85ccc36d77 --- /dev/null +++ b/test/testcases/test_web_api/test_file_app/test_file_routes_unit.py @@ -0,0 +1,1226 @@ +# +# 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 enum import Enum +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +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 _Args(dict): + def get(self, key, default=None, type=None): + value = super().get(key, default) + if value is None or type is None: + return value + try: + return type(value) + except (TypeError, ValueError): + return default + + +class _DummyUploadFile: + def __init__(self, filename, blob=b"blob"): + self.filename = filename + self._blob = blob + + def read(self): + return self._blob + + +class _DummyFiles(dict): + def __init__(self, file_objs=None): + super().__init__() + self._file_objs = list(file_objs or []) + if file_objs is not None: + self["file"] = self._file_objs + + def getlist(self, key): + if key == "file": + return list(self._file_objs) + return [] + + +class _DummyRequest: + def __init__( + self, + *, + args=None, + form=None, + files=None, + json_data=None, + headers=None, + method="POST", + content_length=0, + ): + self.args = _Args(args or {}) + self.form = _AwaitableValue(form or {}) + self.files = _AwaitableValue(files if files is not None else _DummyFiles()) + self.json = _AwaitableValue(json_data or {}) + self.headers = headers or {} + self.method = method + self.content_length = content_length + + +class _DummyResponse: + def __init__(self, data): + self.data = data + self.headers = {} + + +class _DummyFile: + def __init__( + self, + file_id, + file_type, + *, + tenant_id="tenant1", + parent_id="pf1", + location="file.bin", + name="file.txt", + source_type="user", + ): + self.id = file_id + self.type = file_type + self.tenant_id = tenant_id + self.parent_id = parent_id + self.location = location + self.name = name + self.source_type = source_type + + def to_json(self): + return {"id": self.id, "name": self.name, "type": self.type} + + +def _run(coro): + return asyncio.run(coro) + + +def _set_request( + monkeypatch, + module, + *, + args=None, + form=None, + files=None, + json_data=None, + headers=None, + method="POST", + content_length=0, +): + monkeypatch.setattr( + module, + "request", + _DummyRequest( + args=args, + form=form, + files=files, + json_data=json_data, + headers=headers, + method=method, + content_length=content_length, + ), + ) + + +def _set_request_json(monkeypatch, module, payload_state): + async def _req_json(): + return deepcopy(payload_state) + + monkeypatch.setattr(module, "get_request_json", _req_json) + + +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + +def _load_file_app_module(monkeypatch): + repo_root = Path(__file__).resolve().parents[4] + + quart_mod = ModuleType("quart") + quart_mod.request = _DummyRequest() + + async def _make_response(data): + return _DummyResponse(data) + + quart_mod.make_response = _make_response + monkeypatch.setitem(sys.modules, "quart", quart_mod) + + api_pkg = ModuleType("api") + api_pkg.__path__ = [str(repo_root / "api")] + monkeypatch.setitem(sys.modules, "api", api_pkg) + + apps_mod = ModuleType("api.apps") + apps_mod.__path__ = [str(repo_root / "api" / "apps")] + apps_mod.current_user = SimpleNamespace(id="tenant1", tenant_id="tenant1") + apps_mod.login_required = lambda func: func + monkeypatch.setitem(sys.modules, "api.apps", apps_mod) + api_pkg.apps = apps_mod + + api_common_pkg = ModuleType("api.common") + api_common_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.common", api_common_pkg) + + permission_mod = ModuleType("api.common.check_team_permission") + permission_mod.check_file_team_permission = lambda *_args, **_kwargs: True + monkeypatch.setitem(sys.modules, "api.common.check_team_permission", permission_mod) + api_common_pkg.check_team_permission = permission_mod + + db_pkg = ModuleType("api.db") + db_pkg.__path__ = [] + + class _FileType(Enum): + FOLDER = "folder" + VIRTUAL = "virtual" + DOC = "doc" + VISUAL = "visual" + + db_pkg.FileType = _FileType + monkeypatch.setitem(sys.modules, "api.db", db_pkg) + api_pkg.db = db_pkg + + services_pkg = ModuleType("api.db.services") + services_pkg.__path__ = [] + services_pkg.duplicate_name = lambda _query, **kwargs: kwargs.get("name", "") + monkeypatch.setitem(sys.modules, "api.db.services", services_pkg) + + document_service_mod = ModuleType("api.db.services.document_service") + + class _StubDocumentService: + @staticmethod + def get_doc_count(_uid): + return 0 + + @staticmethod + def get_by_id(doc_id): + return True, SimpleNamespace(id=doc_id) + + @staticmethod + def get_tenant_id(_doc_id): + return "tenant1" + + @staticmethod + def remove_document(*_args, **_kwargs): + return True + + @staticmethod + def update_by_id(*_args, **_kwargs): + return True + + document_service_mod.DocumentService = _StubDocumentService + monkeypatch.setitem(sys.modules, "api.db.services.document_service", document_service_mod) + services_pkg.document_service = document_service_mod + + file2doc_mod = ModuleType("api.db.services.file2document_service") + + class _StubFile2DocumentService: + @staticmethod + def get_by_file_id(_file_id): + return [] + + @staticmethod + def delete_by_file_id(*_args, **_kwargs): + return None + + @staticmethod + def get_storage_address(**_kwargs): + return "bucket2", "location2" + + file2doc_mod.File2DocumentService = _StubFile2DocumentService + monkeypatch.setitem(sys.modules, "api.db.services.file2document_service", file2doc_mod) + services_pkg.file2document_service = file2doc_mod + + file_service_mod = ModuleType("api.db.services.file_service") + + class _StubFileService: + @staticmethod + def get_root_folder(_tenant_id): + return {"id": "root"} + + @staticmethod + def get_by_id(file_id): + return True, _DummyFile(file_id, _FileType.DOC.value, name="file.txt") + + @staticmethod + def get_id_list_by_id(_pf_id, _names, _index, ids): + return ids + + @staticmethod + def create_folder(_file, parent_id, _names, _len_id): + return SimpleNamespace(id=parent_id, name=str(parent_id)) + + @staticmethod + def query(**_kwargs): + return [] + + @staticmethod + def insert(data): + return SimpleNamespace(to_json=lambda: data) + + @staticmethod + def is_parent_folder_exist(_pf_id): + return True + + @staticmethod + def get_by_pf_id(*_args, **_kwargs): + return [], 0 + + @staticmethod + def get_parent_folder(_file_id): + return SimpleNamespace(to_json=lambda: {"id": "root"}) + + @staticmethod + def get_all_parent_folders(_file_id): + return [] + + @staticmethod + def init_knowledgebase_docs(*_args, **_kwargs): + return None + + @staticmethod + def list_all_files_by_parent_id(_parent_id): + return [] + + @staticmethod + def delete(_file): + return True + + @staticmethod + def update_by_id(*_args, **_kwargs): + return True + + @staticmethod + def get_by_ids(_file_ids): + return [] + + @staticmethod + def delete_by_id(_file_id): + return True + + file_service_mod.FileService = _StubFileService + monkeypatch.setitem(sys.modules, "api.db.services.file_service", file_service_mod) + services_pkg.file_service = file_service_mod + + api_utils_mod = ModuleType("api.utils.api_utils") + + class _RetCode: + SUCCESS = 0 + ARGUMENT_ERROR = 101 + AUTHENTICATION_ERROR = 401 + OPERATING_ERROR = 103 + + def get_json_result(data=None, message="", code=_RetCode.SUCCESS): + return {"code": code, "data": data, "message": message} + + async def get_request_json(): + return {} + + def get_data_error_result(message=""): + return {"code": _RetCode.OPERATING_ERROR, "data": None, "message": message} + + def server_error_response(err): + return {"code": 500, "data": None, "message": str(err)} + + def validate_request(*_required): + def _decorator(func): + return func + + return _decorator + + api_utils_mod.get_json_result = get_json_result + api_utils_mod.get_request_json = get_request_json + api_utils_mod.get_data_error_result = get_data_error_result + api_utils_mod.server_error_response = server_error_response + api_utils_mod.validate_request = validate_request + monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod) + + file_utils_mod = ModuleType("api.utils.file_utils") + file_utils_mod.filename_type = lambda _name: _FileType.DOC.value + monkeypatch.setitem(sys.modules, "api.utils.file_utils", file_utils_mod) + + web_utils_mod = ModuleType("api.utils.web_utils") + web_utils_mod.CONTENT_TYPE_MAP = {"txt": "text/plain", "json": "application/json"} + web_utils_mod.apply_safe_file_response_headers = ( + lambda response, content_type, ext: response.headers.update({"content_type": content_type, "extension": ext}) + ) + monkeypatch.setitem(sys.modules, "api.utils.web_utils", web_utils_mod) + + common_pkg = ModuleType("common") + common_pkg.__path__ = [str(repo_root / "common")] + monkeypatch.setitem(sys.modules, "common", common_pkg) + + settings_mod = ModuleType("common.settings") + settings_mod.STORAGE_IMPL = SimpleNamespace( + obj_exist=lambda *_args, **_kwargs: False, + put=lambda *_args, **_kwargs: None, + rm=lambda *_args, **_kwargs: None, + get=lambda *_args, **_kwargs: b"", + ) + common_pkg.settings = settings_mod + monkeypatch.setitem(sys.modules, "common.settings", settings_mod) + + constants_mod = ModuleType("common.constants") + + class _FileSource: + KNOWLEDGEBASE = "knowledgebase" + + constants_mod.RetCode = _RetCode + constants_mod.FileSource = _FileSource + monkeypatch.setitem(sys.modules, "common.constants", constants_mod) + + misc_utils_mod = ModuleType("common.misc_utils") + misc_utils_mod.get_uuid = lambda: "uuid-1" + + async def thread_pool_exec(func, *args, **kwargs): + return func(*args, **kwargs) + + misc_utils_mod.thread_pool_exec = thread_pool_exec + monkeypatch.setitem(sys.modules, "common.misc_utils", misc_utils_mod) + + module_name = "test_file_app_routes_unit_module" + module_path = repo_root / "api" / "apps" / "file_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 + + +@pytest.mark.p2 +def test_upload_branch_matrix_unit(monkeypatch): + module = _load_file_app_module(monkeypatch) + monkeypatch.setattr(module.FileService, "get_root_folder", lambda _uid: {"id": "root"}) + + _set_request(monkeypatch, module, form={}, files=_DummyFiles()) + res = _run(module.upload()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert res["message"] == "No file part!" + + _set_request( + monkeypatch, + module, + form={"parent_id": "pf1"}, + files=_DummyFiles([_DummyUploadFile("")]), + ) + res = _run(module.upload()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert res["message"] == "No file selected!" + + _set_request( + monkeypatch, + module, + form={"parent_id": "pf1"}, + files=_DummyFiles([_DummyUploadFile("a.txt")]), + ) + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (False, None)) + res = _run(module.upload()) + assert res["code"] == module.RetCode.OPERATING_ERROR + assert res["message"] == "Can't find this folder!" + + monkeypatch.setenv("MAX_FILE_NUM_PER_USER", "1") + _set_request( + monkeypatch, + module, + form={"parent_id": "pf1"}, + files=_DummyFiles([_DummyUploadFile("cap.txt")]), + ) + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (True, SimpleNamespace(id="pf1", name="pf1"))) + monkeypatch.setattr(module.DocumentService, "get_doc_count", lambda _uid: 1) + res = _run(module.upload()) + assert res["code"] == module.RetCode.SUCCESS + assert "Exceed the maximum file number of a free user!" in res["data"][0]["message"] + monkeypatch.delenv("MAX_FILE_NUM_PER_USER", raising=False) + + class _StorageNoCollision: + def __init__(self): + self.put_calls = [] + + def obj_exist(self, _bucket, _location): + return False + + def put(self, bucket, location, blob): + self.put_calls.append((bucket, location, blob)) + + storage_no_collision = _StorageNoCollision() + monkeypatch.setattr(module.settings, "STORAGE_IMPL", storage_no_collision) + _set_request( + monkeypatch, + module, + form={"parent_id": "pf1"}, + files=_DummyFiles([_DummyUploadFile(None, b"none-blob")]), + ) + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (True, SimpleNamespace(id="pf1", name="pf1"))) + monkeypatch.setattr(module.FileService, "get_id_list_by_id", lambda *_args, **_kwargs: ["pf1"]) + monkeypatch.setattr( + module.FileService, + "create_folder", + lambda _file, parent_id, _names, _len_id: SimpleNamespace(id=f"{parent_id}-folder"), + ) + monkeypatch.setattr(module, "filename_type", lambda _name: module.FileType.DOC.value) + monkeypatch.setattr(module, "duplicate_name", lambda _query, **kwargs: kwargs.get("name")) + monkeypatch.setattr(module, "get_uuid", lambda: "uuid-none") + monkeypatch.setattr(module.FileService, "insert", lambda data: SimpleNamespace(to_json=lambda: {"id": data["id"]})) + res = _run(module.upload()) + assert res["code"] == module.RetCode.SUCCESS + assert len(res["data"]) == 1 + assert storage_no_collision.put_calls == [("pf1-folder", None, b"none-blob")] + + _set_request( + monkeypatch, + module, + form={"parent_id": "pf1"}, + files=_DummyFiles([_DummyUploadFile("dir/a.txt")]), + ) + monkeypatch.setattr(module.FileService, "get_id_list_by_id", lambda *_args, **_kwargs: ["pf1", "missing-child"]) + + def _get_by_id_missing_child(file_id): + if file_id == "missing-child": + return False, None + return True, SimpleNamespace(id=file_id, name=file_id) + + monkeypatch.setattr(module.FileService, "get_by_id", _get_by_id_missing_child) + res = _run(module.upload()) + assert res["code"] == module.RetCode.SUCCESS + assert res["data"][0]["message"] == "Folder not found!" + + _set_request( + monkeypatch, + module, + form={"parent_id": "pf1"}, + files=_DummyFiles([_DummyUploadFile("b.txt")]), + ) + monkeypatch.setattr(module.FileService, "get_id_list_by_id", lambda *_args, **_kwargs: ["pf1", "leaf"]) + pf1_calls = {"count": 0} + + def _get_by_id_missing_parent_else(file_id): + if file_id == "pf1": + pf1_calls["count"] += 1 + if pf1_calls["count"] == 1: + return True, SimpleNamespace(id="pf1", name="pf1") + return False, None + return True, SimpleNamespace(id=file_id, name=file_id) + + monkeypatch.setattr(module.FileService, "get_by_id", _get_by_id_missing_parent_else) + res = _run(module.upload()) + assert res["code"] == module.RetCode.SUCCESS + assert res["data"][0]["message"] == "Folder not found!" + + class _StorageCollision: + def __init__(self): + self.obj_calls = 0 + self.put_calls = [] + + def obj_exist(self, _bucket, _location): + self.obj_calls += 1 + return self.obj_calls == 1 + + def put(self, bucket, location, blob): + self.put_calls.append((bucket, location, blob)) + + storage_collision = _StorageCollision() + monkeypatch.setattr(module.settings, "STORAGE_IMPL", storage_collision) + _set_request( + monkeypatch, + module, + form={"parent_id": "pf1"}, + files=_DummyFiles([_DummyUploadFile("dir/a.txt", b"a"), _DummyUploadFile("b.txt", b"b")]), + ) + + def _get_by_id_ok(file_id): + return True, SimpleNamespace(id=file_id, name=file_id) + + def _get_id_list(_pf_id, file_obj_names, _idx, _ids): + if file_obj_names[-1] == "a.txt": + return ["pf1", "mid-id"] + return ["pf1", "leaf-id"] + + def _create_folder(_file, parent_id, _names, _len_id): + return SimpleNamespace(id=f"{parent_id}-folder") + + inserted_payloads = [] + monkeypatch.setattr(module.FileService, "get_by_id", _get_by_id_ok) + monkeypatch.setattr(module.FileService, "get_id_list_by_id", _get_id_list) + monkeypatch.setattr(module.FileService, "create_folder", _create_folder) + monkeypatch.setattr(module, "filename_type", lambda _name: module.FileType.DOC.value) + monkeypatch.setattr(module, "duplicate_name", lambda _query, **kwargs: kwargs["name"]) + monkeypatch.setattr(module, "get_uuid", lambda: "file-id") + monkeypatch.setattr(module.FileService, "query", lambda **_kwargs: []) + + def _insert(data): + inserted_payloads.append(data) + return SimpleNamespace(to_json=lambda: {"id": data["id"], "location": data["location"]}) + + monkeypatch.setattr(module.FileService, "insert", _insert) + res = _run(module.upload()) + assert res["code"] == module.RetCode.SUCCESS + assert len(res["data"]) == 2 + assert len(storage_collision.put_calls) == 2 + assert any(location.endswith("_") for _, location, _ in storage_collision.put_calls) + assert len(inserted_payloads) == 2 + + _set_request( + monkeypatch, + module, + form={"parent_id": "pf1"}, + files=_DummyFiles([_DummyUploadFile("boom.txt")]), + ) + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (_ for _ in ()).throw(RuntimeError("upload boom"))) + monkeypatch.setattr(module, "server_error_response", lambda err: {"code": 500, "message": str(err)}) + res = _run(module.upload()) + assert res["code"] == 500 + assert "upload boom" in res["message"] + + +@pytest.mark.p2 +def test_create_and_list_branch_matrix_unit(monkeypatch): + module = _load_file_app_module(monkeypatch) + req_state = {"name": "file1"} + _set_request_json(monkeypatch, module, req_state) + monkeypatch.setattr(module.FileService, "get_root_folder", lambda _uid: {"id": "root"}) + + monkeypatch.setattr(module.FileService, "is_parent_folder_exist", lambda _pf_id: False) + res = _run(module.create()) + assert res["code"] == module.RetCode.OPERATING_ERROR + assert "Parent Folder Doesn't Exist!" in res["message"] + + req_state.update({"name": "dup", "parent_id": "pf1"}) + monkeypatch.setattr(module.FileService, "is_parent_folder_exist", lambda _pf_id: True) + monkeypatch.setattr(module.FileService, "query", lambda **_kwargs: [object()]) + res = _run(module.create()) + assert "Duplicated folder name" in res["message"] + + inserted = {} + + def _insert(data): + inserted["payload"] = data + return SimpleNamespace(to_json=lambda: data) + + monkeypatch.setattr(module.FileService, "query", lambda **_kwargs: []) + monkeypatch.setattr(module, "get_uuid", lambda: "uuid-folder") + monkeypatch.setattr(module.FileService, "insert", _insert) + + req_state.update({"name": "folder", "parent_id": "pf1", "type": module.FileType.FOLDER.value}) + res = _run(module.create()) + assert res["code"] == module.RetCode.SUCCESS + assert inserted["payload"]["type"] == module.FileType.FOLDER.value + + req_state.update({"name": "virtual", "parent_id": "pf1", "type": "UNKNOWN"}) + res = _run(module.create()) + assert res["code"] == module.RetCode.SUCCESS + assert inserted["payload"]["type"] == module.FileType.VIRTUAL.value + + monkeypatch.setattr( + module.FileService, + "is_parent_folder_exist", + lambda _pf_id: (_ for _ in ()).throw(RuntimeError("create boom")), + ) + monkeypatch.setattr(module, "server_error_response", lambda err: {"code": 500, "message": str(err)}) + res = _run(module.create()) + assert res["code"] == 500 + assert "create boom" in res["message"] + + list_calls = {"init": 0} + monkeypatch.setattr(module.FileService, "get_root_folder", lambda _uid: {"id": "root"}) + monkeypatch.setattr( + module.FileService, + "init_knowledgebase_docs", + lambda _pf_id, _uid: list_calls.__setitem__("init", list_calls["init"] + 1), + ) + _set_request(monkeypatch, module, args={}) + monkeypatch.setattr(module.FileService, "get_by_id", lambda _pf_id: (False, None)) + res = module.list_files() + assert res["message"] == "Folder not found!" + assert list_calls["init"] == 1 + + _set_request( + monkeypatch, + module, + args={ + "parent_id": "p1", + "keywords": "k", + "page": "2", + "page_size": "10", + "orderby": "name", + "desc": "False", + }, + ) + monkeypatch.setattr(module.FileService, "get_by_id", lambda _pf_id: (True, SimpleNamespace(id="p1"))) + monkeypatch.setattr(module.FileService, "get_by_pf_id", lambda *_args, **_kwargs: ([{"id": "f1"}], 1)) + monkeypatch.setattr(module.FileService, "get_parent_folder", lambda _pf_id: None) + res = module.list_files() + assert res["message"] == "File not found!" + + monkeypatch.setattr(module.FileService, "get_parent_folder", lambda _pf_id: SimpleNamespace(to_json=lambda: {"id": "p0"})) + res = module.list_files() + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["total"] == 1 + assert res["data"]["parent_folder"]["id"] == "p0" + + monkeypatch.setattr(module.FileService, "get_by_id", lambda _pf_id: (_ for _ in ()).throw(RuntimeError("list boom"))) + monkeypatch.setattr(module, "server_error_response", lambda err: {"code": 500, "message": str(err)}) + res = module.list_files() + assert res["code"] == 500 + assert "list boom" in res["message"] + + +@pytest.mark.p2 +def test_folder_lookup_routes_branch_matrix_unit(monkeypatch): + module = _load_file_app_module(monkeypatch) + + monkeypatch.setattr(module.FileService, "get_root_folder", lambda _uid: {"id": "root"}) + res = module.get_root_folder() + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["root_folder"]["id"] == "root" + + monkeypatch.setattr(module.FileService, "get_root_folder", lambda _uid: (_ for _ in ()).throw(RuntimeError("root boom"))) + monkeypatch.setattr(module, "server_error_response", lambda err: {"code": 500, "message": str(err)}) + res = module.get_root_folder() + assert res["code"] == 500 + assert "root boom" in res["message"] + + _set_request(monkeypatch, module, args={"file_id": "missing"}) + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (False, None)) + res = module.get_parent_folder() + assert res["message"] == "Folder not found!" + + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (True, SimpleNamespace(id="f1"))) + monkeypatch.setattr(module.FileService, "get_parent_folder", lambda _file_id: SimpleNamespace(to_json=lambda: {"id": "p1"})) + res = module.get_parent_folder() + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["parent_folder"]["id"] == "p1" + + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (_ for _ in ()).throw(RuntimeError("parent boom"))) + monkeypatch.setattr(module, "server_error_response", lambda err: {"code": 500, "message": str(err)}) + res = module.get_parent_folder() + assert res["code"] == 500 + assert "parent boom" in res["message"] + + _set_request(monkeypatch, module, args={"file_id": "missing"}) + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (False, None)) + res = module.get_all_parent_folders() + assert res["message"] == "Folder not found!" + + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (True, SimpleNamespace(id="f1"))) + monkeypatch.setattr( + module.FileService, + "get_all_parent_folders", + lambda _file_id: [SimpleNamespace(to_json=lambda: {"id": "p1"}), SimpleNamespace(to_json=lambda: {"id": "p2"})], + ) + res = module.get_all_parent_folders() + assert res["code"] == module.RetCode.SUCCESS + assert res["data"]["parent_folders"] == [{"id": "p1"}, {"id": "p2"}] + + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (_ for _ in ()).throw(RuntimeError("all-parent boom"))) + monkeypatch.setattr(module, "server_error_response", lambda err: {"code": 500, "message": str(err)}) + res = module.get_all_parent_folders() + assert res["code"] == 500 + assert "all-parent boom" in res["message"] + + +@pytest.mark.p2 +def test_rm_branch_matrix_unit(monkeypatch): + module = _load_file_app_module(monkeypatch) + req_state = {"file_ids": ["missing"]} + _set_request_json(monkeypatch, module, req_state) + + allow = {"value": True} + monkeypatch.setattr(module, "check_file_team_permission", lambda _file, _uid: allow["value"]) + + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (False, None)) + res = _run(module.rm()) + assert res["message"] == "File or Folder not found!" + + req_state["file_ids"] = ["tenant-missing"] + monkeypatch.setattr( + module.FileService, + "get_by_id", + lambda _file_id: (True, _DummyFile(_file_id, module.FileType.DOC.value, tenant_id=None)), + ) + res = _run(module.rm()) + assert res["message"] == "Tenant not found!" + + req_state["file_ids"] = ["deny"] + allow["value"] = False + monkeypatch.setattr( + module.FileService, + "get_by_id", + lambda _file_id: (True, _DummyFile(_file_id, module.FileType.DOC.value)), + ) + res = _run(module.rm()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + assert res["message"] == "No authorization." + allow["value"] = True + + req_state["file_ids"] = ["kb"] + monkeypatch.setattr( + module.FileService, + "get_by_id", + lambda _file_id: ( + True, + _DummyFile( + _file_id, + module.FileType.DOC.value, + source_type=module.FileSource.KNOWLEDGEBASE, + ), + ), + ) + res = _run(module.rm()) + assert res["code"] == module.RetCode.SUCCESS + assert res["data"] is True + + events = { + "rm_calls": [], + "deleted_files": [], + "deleted_links": [], + "removed_docs": [], + } + + class _Storage: + def rm(self, bucket, location): + events["rm_calls"].append((bucket, location)) + raise RuntimeError("storage rm boom") + + monkeypatch.setattr(module.settings, "STORAGE_IMPL", _Storage()) + monkeypatch.setattr(module.File2DocumentService, "get_by_file_id", lambda file_id: [SimpleNamespace(document_id=f"doc-{file_id}")]) + monkeypatch.setattr(module.DocumentService, "get_by_id", lambda doc_id: (True, SimpleNamespace(id=doc_id))) + monkeypatch.setattr(module.DocumentService, "get_tenant_id", lambda _doc_id: "tenant1") + monkeypatch.setattr( + module.DocumentService, + "remove_document", + lambda doc, tenant: events["removed_docs"].append((doc.id, tenant)), + ) + monkeypatch.setattr( + module.File2DocumentService, + "delete_by_file_id", + lambda file_id: events["deleted_links"].append(file_id), + ) + monkeypatch.setattr(module.FileService, "delete", lambda file: events["deleted_files"].append(file.id)) + + req_state["file_ids"] = ["doc-top"] + monkeypatch.setattr( + module.FileService, + "get_by_id", + lambda _file_id: (True, _DummyFile("doc-top", module.FileType.DOC.value, location="top.bin")), + ) + res = _run(module.rm()) + assert res["code"] == module.RetCode.SUCCESS + + req_state["file_ids"] = ["folder1"] + folder1 = _DummyFile("folder1", module.FileType.FOLDER.value, location="") + nested_folder = _DummyFile("nested-folder", module.FileType.FOLDER.value, parent_id="folder1", location="") + doc1 = _DummyFile("doc1", module.FileType.DOC.value, parent_id="folder1", location="doc1.bin") + doc2 = _DummyFile("doc2", module.FileType.DOC.value, parent_id="nested-folder", location="doc2.bin") + + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (True, folder1)) + + def _list_all(parent_id): + if parent_id == "folder1": + return [nested_folder, doc1] + if parent_id == "nested-folder": + return [doc2] + return [] + + monkeypatch.setattr(module.FileService, "list_all_files_by_parent_id", _list_all) + res = _run(module.rm()) + assert res["code"] == module.RetCode.SUCCESS + assert res["data"] is True + assert ("pf1", "top.bin") in events["rm_calls"] + assert ("folder1", "doc1.bin") in events["rm_calls"] + assert ("nested-folder", "doc2.bin") in events["rm_calls"] + assert {"doc-top", "doc1", "doc2", "nested-folder", "folder1"}.issubset(set(events["deleted_files"])) + assert {"doc-top", "doc1", "doc2"}.issubset(set(events["deleted_links"])) + assert len(events["removed_docs"]) >= 3 + + async def _thread_pool_boom(_func, *_args, **_kwargs): + raise RuntimeError("rm route boom") + + monkeypatch.setattr(module, "thread_pool_exec", _thread_pool_boom) + req_state["file_ids"] = ["boom"] + res = _run(module.rm()) + assert res["code"] == 500 + assert "rm route boom" in res["message"] + + +@pytest.mark.p2 +def test_rename_branch_matrix_unit(monkeypatch): + module = _load_file_app_module(monkeypatch) + req_state = {"file_id": "f1", "name": "new.txt"} + _set_request_json(monkeypatch, module, req_state) + + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (False, None)) + res = _run(module.rename()) + assert res["message"] == "File not found!" + + monkeypatch.setattr( + module.FileService, + "get_by_id", + lambda _file_id: (True, _DummyFile("f1", module.FileType.DOC.value, name="origin.txt")), + ) + monkeypatch.setattr(module, "check_file_team_permission", lambda _file, _uid: False) + res = _run(module.rename()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + assert res["message"] == "No authorization." + + monkeypatch.setattr(module, "check_file_team_permission", lambda _file, _uid: True) + req_state["name"] = "new.pdf" + res = _run(module.rename()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR + assert "extension of file can't be changed" in res["message"] + + req_state["name"] = "new.txt" + monkeypatch.setattr(module.FileService, "query", lambda **_kwargs: [SimpleNamespace(name="new.txt")]) + res = _run(module.rename()) + assert "Duplicated file name in the same folder." in res["message"] + + monkeypatch.setattr(module.FileService, "query", lambda **_kwargs: []) + monkeypatch.setattr(module.FileService, "update_by_id", lambda *_args, **_kwargs: False) + res = _run(module.rename()) + assert "Database error (File rename)!" in res["message"] + + monkeypatch.setattr(module.FileService, "update_by_id", lambda *_args, **_kwargs: True) + monkeypatch.setattr(module.File2DocumentService, "get_by_file_id", lambda _file_id: [SimpleNamespace(document_id="doc1")]) + monkeypatch.setattr(module.DocumentService, "update_by_id", lambda *_args, **_kwargs: False) + res = _run(module.rename()) + assert "Database error (Document rename)!" in res["message"] + + monkeypatch.setattr(module.File2DocumentService, "get_by_file_id", lambda _file_id: []) + res = _run(module.rename()) + assert res["code"] == module.RetCode.SUCCESS + assert res["data"] is True + + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (_ for _ in ()).throw(RuntimeError("rename boom"))) + monkeypatch.setattr(module, "server_error_response", lambda err: {"code": 500, "message": str(err)}) + res = _run(module.rename()) + assert res["code"] == 500 + assert "rename boom" in res["message"] + + +@pytest.mark.p2 +def test_get_file_branch_matrix_unit(monkeypatch): + module = _load_file_app_module(monkeypatch) + + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (False, None)) + res = _run(module.get("missing")) + assert res["message"] == "Document not found!" + + monkeypatch.setattr( + module.FileService, + "get_by_id", + lambda _file_id: (True, _DummyFile("f1", module.FileType.DOC.value, name="a.txt")), + ) + monkeypatch.setattr(module, "check_file_team_permission", lambda _file, _uid: False) + res = _run(module.get("f1")) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + assert res["message"] == "No authorization." + + class _Storage: + def __init__(self): + self.calls = [] + + def get(self, bucket, location): + self.calls.append((bucket, location)) + if len(self.calls) == 1: + return None + return b"blob-data" + + storage = _Storage() + monkeypatch.setattr(module.settings, "STORAGE_IMPL", storage) + monkeypatch.setattr( + module.FileService, + "get_by_id", + lambda _file_id: ( + True, + _DummyFile( + "f1", + module.FileType.VISUAL.value, + parent_id="pf1", + location="loc1", + name="image.abc", + ), + ), + ) + monkeypatch.setattr(module, "check_file_team_permission", lambda _file, _uid: True) + monkeypatch.setattr(module.File2DocumentService, "get_storage_address", lambda **_kwargs: ("pf2", "loc2")) + + async def _make_response(data): + return _DummyResponse(data) + + monkeypatch.setattr(module, "make_response", _make_response) + monkeypatch.setattr( + module, + "apply_safe_file_response_headers", + lambda response, content_type, ext: response.headers.update( + {"content_type": content_type, "extension": ext} + ), + ) + res = _run(module.get("f1")) + assert isinstance(res, _DummyResponse) + assert res.data == b"blob-data" + assert storage.calls == [("pf1", "loc1"), ("pf2", "loc2")] + assert res.headers["extension"] == "abc" + assert res.headers["content_type"] == "image/abc" + + +@pytest.mark.p2 +def test_get_file_content_type_and_error_paths_unit(monkeypatch): + module = _load_file_app_module(monkeypatch) + monkeypatch.setattr(module, "check_file_team_permission", lambda _file, _uid: True) + + class _Storage: + @staticmethod + def get(_bucket, _location): + return b"blob-data" + + monkeypatch.setattr(module.settings, "STORAGE_IMPL", _Storage()) + monkeypatch.setattr(module.File2DocumentService, "get_storage_address", lambda **_kwargs: ("pf2", "loc2")) + + async def _make_response(data): + return _DummyResponse(data) + + headers_calls = [] + + def _apply_headers(response, content_type, ext): + headers_calls.append((content_type, ext)) + response.headers["content_type"] = content_type + response.headers["extension"] = ext + + monkeypatch.setattr(module, "make_response", _make_response) + monkeypatch.setattr(module, "apply_safe_file_response_headers", _apply_headers) + + monkeypatch.setattr( + module.FileService, + "get_by_id", + lambda _file_id: ( + True, + _DummyFile("img", module.FileType.VISUAL.value, parent_id="pf1", location="loc1", name="image.abc"), + ), + ) + res = _run(module.get("img")) + assert isinstance(res, _DummyResponse) + assert res.headers["content_type"] == "image/abc" + assert res.headers["extension"] == "abc" + + monkeypatch.setattr( + module.FileService, + "get_by_id", + lambda _file_id: ( + True, + _DummyFile("noext", module.FileType.DOC.value, parent_id="pf1", location="loc1", name="README"), + ), + ) + res = _run(module.get("noext")) + assert isinstance(res, _DummyResponse) + assert res.headers["content_type"] is None + assert res.headers["extension"] is None + assert headers_calls == [("image/abc", "abc"), (None, None)] + + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (_ for _ in ()).throw(RuntimeError("get crash"))) + monkeypatch.setattr(module, "server_error_response", lambda err: {"code": 500, "message": str(err)}) + res = _run(module.get("boom")) + assert res["code"] == 500 + assert "get crash" in res["message"] + + +@pytest.mark.p2 +def test_move_recursive_branch_matrix_unit(monkeypatch): + module = _load_file_app_module(monkeypatch) + req_state = {"src_file_ids": ["f1"], "dest_file_id": "dest"} + _set_request_json(monkeypatch, module, req_state) + + async def _thread_pool_exec(fn, *args, **kwargs): + return fn(*args, **kwargs) + + monkeypatch.setattr(module, "thread_pool_exec", _thread_pool_exec) + monkeypatch.setattr(module, "check_file_team_permission", lambda _file, _uid: True) + + dest_folder = SimpleNamespace(id="dest") + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (False, None)) + res = _run(module.move()) + assert res["message"] == "Parent folder not found!" + + monkeypatch.setattr(module.FileService, "get_by_id", lambda _file_id: (True, dest_folder)) + monkeypatch.setattr(module.FileService, "get_by_ids", lambda _file_ids: []) + res = _run(module.move()) + assert res["message"] == "Source files not found!" + + req_state["src_file_ids"] = ["f1", "f2"] + monkeypatch.setattr(module.FileService, "get_by_ids", lambda _file_ids: [_DummyFile("f1", module.FileType.DOC.value)]) + res = _run(module.move()) + assert res["message"] == "File or folder not found!" + + req_state["src_file_ids"] = ["tenant-missing"] + monkeypatch.setattr( + module.FileService, + "get_by_ids", + lambda _file_ids: [_DummyFile("tenant-missing", module.FileType.DOC.value, tenant_id=None)], + ) + res = _run(module.move()) + assert res["message"] == "Tenant not found!" + + req_state["src_file_ids"] = ["deny"] + monkeypatch.setattr(module.FileService, "get_by_ids", lambda _file_ids: [_DummyFile("deny", module.FileType.DOC.value)]) + monkeypatch.setattr(module, "check_file_team_permission", lambda _file, _uid: False) + res = _run(module.move()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + assert res["message"] == "No authorization." + + monkeypatch.setattr(module, "check_file_team_permission", lambda _file, _uid: True) + + req_state["src_file_ids"] = ["folder_existing", "folder_new", "doc_main"] + folder_existing = _DummyFile( + "folder_existing", + module.FileType.FOLDER.value, + tenant_id="tenant1", + parent_id="old_bucket", + location="", + name="existing-folder", + ) + folder_new = _DummyFile( + "folder_new", + module.FileType.FOLDER.value, + tenant_id="tenant1", + parent_id="old_bucket", + location="", + name="new-folder", + ) + doc_main = _DummyFile( + "doc_main", + module.FileType.DOC.value, + tenant_id="tenant1", + parent_id="old_bucket", + location="doc.bin", + name="doc.bin", + ) + sub_doc = _DummyFile( + "sub_doc", + module.FileType.DOC.value, + tenant_id="tenant1", + parent_id="folder_existing", + location="sub.txt", + name="sub.txt", + ) + + monkeypatch.setattr(module.FileService, "get_by_ids", lambda _file_ids: [folder_existing, folder_new, doc_main]) + + inserted = [] + deleted = [] + updated = [] + existing_dest = SimpleNamespace(id="dest-existing") + new_dest = SimpleNamespace(id="dest-new") + + def _query(**kwargs): + if kwargs.get("name") == "existing-folder": + return [existing_dest] + if kwargs.get("name") == "new-folder": + return [] + return [] + + def _insert(payload): + inserted.append(payload) + return new_dest + + def _list_subfiles(parent_id): + if parent_id == "folder_existing": + return [sub_doc] + if parent_id == "folder_new": + return [] + return [] + + class _Storage: + def __init__(self): + self.move_calls = [] + self._collision = 0 + + def obj_exist(self, _bucket, location): + if location == "doc.bin" and self._collision == 0: + self._collision += 1 + return True + return False + + def move(self, old_parent, old_location, new_parent, new_location): + self.move_calls.append((old_parent, old_location, new_parent, new_location)) + + storage = _Storage() + monkeypatch.setattr(module.settings, "STORAGE_IMPL", storage) + monkeypatch.setattr(module.FileService, "query", _query) + monkeypatch.setattr(module.FileService, "insert", _insert) + monkeypatch.setattr(module.FileService, "list_all_files_by_parent_id", _list_subfiles) + monkeypatch.setattr(module.FileService, "delete_by_id", lambda file_id: deleted.append(file_id)) + monkeypatch.setattr(module.FileService, "update_by_id", lambda file_id, payload: updated.append((file_id, payload)) or True) + + res = _run(module.move()) + assert res["code"] == module.RetCode.SUCCESS + assert res["data"] is True + assert inserted and inserted[0]["name"] == "new-folder" + assert set(deleted) == {"folder_existing", "folder_new"} + assert ("old_bucket", "doc.bin", "dest", "doc.bin_") in storage.move_calls + assert ("folder_existing", "sub.txt", "dest-existing", "sub.txt") in storage.move_calls + assert ("doc_main", {"parent_id": "dest", "location": "doc.bin_"}) in updated + assert ("sub_doc", {"parent_id": "dest-existing", "location": "sub.txt"}) in updated + + req_state["src_file_ids"] = ["boom_doc"] + monkeypatch.setattr( + module.FileService, + "get_by_ids", + lambda _file_ids: [ + _DummyFile("boom_doc", module.FileType.DOC.value, tenant_id="tenant1", parent_id="old_bucket", location="boom", name="boom") + ], + ) + + class _StorageBoom: + @staticmethod + def obj_exist(_bucket, _location): + return False + + @staticmethod + def move(*_args, **_kwargs): + raise RuntimeError("storage down") + + monkeypatch.setattr(module.settings, "STORAGE_IMPL", _StorageBoom()) + monkeypatch.setattr(module, "server_error_response", lambda err: {"code": 500, "message": str(err)}) + res = _run(module.move()) + assert res["code"] == 500 + assert "Move file failed at storage layer: storage down" in res["message"] diff --git a/test/testcases/test_web_api/test_kb_app/test_kb_routes_unit.py b/test/testcases/test_web_api/test_kb_app/test_kb_routes_unit.py index 060918be82..bcb177d73c 100644 --- a/test/testcases/test_web_api/test_kb_app/test_kb_routes_unit.py +++ b/test/testcases/test_web_api/test_kb_app/test_kb_routes_unit.py @@ -26,6 +26,8 @@ from types import ModuleType, SimpleNamespace import pytest +pytestmark = pytest.mark.filterwarnings("ignore:.*joblib will operate in serial mode.*:UserWarning") + class _DummyManager: def route(self, *_args, **_kwargs): @@ -169,6 +171,16 @@ def _base_update_payload(**kwargs): return 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_create_branches(monkeypatch): module = _load_kb_module(monkeypatch) @@ -1046,3 +1058,236 @@ def test_unbind_task_branch_matrix(monkeypatch): res = route() assert res["code"] == module.RetCode.EXCEPTION_ERROR, res assert "cannot delete task" in res["message"], res + + +@pytest.mark.p2 +def test_check_embedding_similarity_threshold_matrix_unit(monkeypatch): + module = _load_kb_module(monkeypatch) + route = inspect.unwrap(module.check_embedding) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (True, SimpleNamespace(tenant_id="tenant-1"))) + monkeypatch.setattr(module.search, "index_name", lambda _tenant_id: "idx") + + class _FlipBool: + def __init__(self): + self._calls = 0 + + def __bool__(self): + self._calls += 1 + return self._calls == 1 + + monkeypatch.setattr( + module.re, + "sub", + lambda _pattern, _repl, text: _FlipBool() if "TRIGGER_NO_TEXT" in str(text) else text, + ) + + def _fixed_sample(population, k): + return list(population)[:k] + + monkeypatch.setattr(module.random, "sample", _fixed_sample) + + class _DocStore: + def __init__(self, total, ids_by_offset, docs): + self.total = total + self.ids_by_offset = ids_by_offset + self.docs = docs + + def search(self, select_fields, **kwargs): + if not select_fields: + return {"kind": "total"} + return {"kind": "sample", "offset": kwargs["offset"]} + + def get_total(self, _res): + return self.total + + def get_doc_ids(self, res): + return self.ids_by_offset.get(res.get("offset", -1), []) + + def get(self, cid, _index_name, _kb_ids): + return self.docs.get(cid, {}) + + class _EmbModel: + def __init__(self): + self.calls = [] + + def encode(self, pair): + title, _txt = pair + self.calls.append(title) + if title == "Doc Mix": + # title+content mix wins over content only path. + return [module.np.array([1.0, 0.0]), module.np.array([0.0, 1.0])], None + if title == "Doc High": + return [module.np.array([1.0, 0.0]), module.np.array([1.0, 0.0])], None + return [module.np.array([0.0, 1.0]), module.np.array([0.0, 1.0])], None + + emb_model = _EmbModel() + monkeypatch.setattr(module, "LLMBundle", lambda *_args, **_kwargs: emb_model) + + low_docs = { + "chunk-no-vec": { + "doc_id": "doc-no-vec", + "docnm_kwd": "Doc No Vec", + "content_with_weight": "body-no-vec", + "page_num_int": 1, + "position_int": 1, + "top_int": 1, + }, + "chunk-bad-type": { + "doc_id": "doc-bad-type", + "docnm_kwd": "Doc Bad Type", + "content_with_weight": "body-bad-type", + "question_kwd": [], + "q_vec": {"bad": "type"}, + "page_num_int": 1, + "position_int": 2, + "top_int": 2, + }, + "chunk-low-zero": { + "doc_id": "doc-low-zero", + "docnm_kwd": "Doc Low Zero", + "content_with_weight": "body-low", + "question_kwd": [], + "q_vec": "0\t0", + "page_num_int": 1, + "position_int": 3, + "top_int": 3, + }, + "chunk-no-text": { + "doc_id": "doc-no-text", + "docnm_kwd": "Doc No Text", + "content_with_weight": "TRIGGER_NO_TEXT", + "q_vec": [1.0, 0.0], + "page_num_int": 1, + "position_int": 4, + "top_int": 4, + }, + "chunk-mix": { + "doc_id": "doc-mix", + "docnm_kwd": "Doc Mix", + "content_with_weight": "body-mix", + "q_vec": [1.0, 0.0], + "page_num_int": 1, + "position_int": 5, + "top_int": 5, + }, + } + + monkeypatch.setattr( + module.settings, + "docStoreConn", + _DocStore( + total=6, + ids_by_offset={ + 0: [], + 1: ["chunk-no-vec"], + 2: ["chunk-bad-type"], + 3: ["chunk-low-zero"], + 4: ["chunk-no-text"], + 5: ["chunk-mix"], + }, + docs=low_docs, + ), + ) + + _set_request_json(monkeypatch, module, {"kb_id": "kb-1", "embd_id": "emb-1", "check_num": 6}) + res = _run(route()) + assert res["code"] == module.RetCode.NOT_EFFECTIVE, res + assert "average similarity" in res["message"], res + summary = res["data"]["summary"] + assert summary["sampled"] == 5, summary + assert summary["valid"] == 2, summary + reasons = {item.get("reason") for item in res["data"]["results"] if "reason" in item} + assert "no_stored_vector" in reasons, res + assert "no_text" in reasons, res + assert any(item.get("chunk_id") == "chunk-low-zero" and "cos_sim" in item for item in res["data"]["results"]), res + assert summary["match_mode"] in {"content_only", "title+content"}, summary + + high_docs = { + "chunk-high": { + "doc_id": "doc-high", + "docnm_kwd": "Doc High", + "content_with_weight": "body-high", + "q_vec": [1.0, 0.0], + "page_num_int": 1, + "position_int": 1, + "top_int": 1, + } + } + monkeypatch.setattr( + module.settings, + "docStoreConn", + _DocStore(total=1, ids_by_offset={0: ["chunk-high"]}, docs=high_docs), + ) + _set_request_json(monkeypatch, module, {"kb_id": "kb-1", "embd_id": "emb-1", "check_num": 1}) + res = _run(route()) + assert res["code"] == module.RetCode.SUCCESS, res + assert res["data"]["summary"]["avg_cos_sim"] > 0.9, res + + +@pytest.mark.p2 +def test_check_embedding_error_and_empty_sample_paths_unit(monkeypatch): + module = _load_kb_module(monkeypatch) + route = inspect.unwrap(module.check_embedding) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: (True, SimpleNamespace(tenant_id="tenant-1"))) + monkeypatch.setattr(module.search, "index_name", lambda _tenant_id: "idx") + monkeypatch.setattr(module.random, "sample", lambda population, k: list(population)[:k]) + + class _DocStore: + def __init__(self, total, ids_by_offset, docs): + self.total = total + self.ids_by_offset = ids_by_offset + self.docs = docs + + def search(self, select_fields, **kwargs): + if not select_fields: + return {"kind": "total"} + return {"kind": "sample", "offset": kwargs["offset"]} + + def get_total(self, _res): + return self.total + + def get_doc_ids(self, res): + return self.ids_by_offset.get(res.get("offset", -1), []) + + def get(self, cid, _index_name, _kb_ids): + return self.docs.get(cid, {}) + + class _BoomEmbModel: + def encode(self, _pair): + raise RuntimeError("encode boom") + + monkeypatch.setattr(module, "LLMBundle", lambda *_args, **_kwargs: _BoomEmbModel()) + monkeypatch.setattr( + module.settings, + "docStoreConn", + _DocStore( + total=1, + ids_by_offset={0: ["chunk-err"]}, + docs={ + "chunk-err": { + "doc_id": "doc-err", + "docnm_kwd": "Doc Err", + "content_with_weight": "body-err", + "q_vec": [1.0, 0.0], + "page_num_int": 1, + "position_int": 1, + "top_int": 1, + } + }, + ), + ) + _set_request_json(monkeypatch, module, {"kb_id": "kb-1", "embd_id": "emb-1", "check_num": 1}) + res = _run(route()) + assert res["code"] == module.RetCode.DATA_ERROR, res + assert "Embedding failure." in res["message"], res + assert "encode boom" in res["message"], res + + class _OkEmbModel: + def encode(self, _pair): + return [module.np.array([1.0, 0.0]), module.np.array([1.0, 0.0])], None + + monkeypatch.setattr(module, "LLMBundle", lambda *_args, **_kwargs: _OkEmbModel()) + monkeypatch.setattr(module.settings, "docStoreConn", _DocStore(total=0, ids_by_offset={}, docs={})) + _set_request_json(monkeypatch, module, {"kb_id": "kb-1", "embd_id": "emb-1", "check_num": 1}) + with pytest.raises(UnboundLocalError): + _run(route()) diff --git a/test/testcases/test_web_api/test_llm_app/test_llm_list_unit.py b/test/testcases/test_web_api/test_llm_app/test_llm_list_unit.py index f0a285e016..18179b8592 100644 --- a/test/testcases/test_web_api/test_llm_app/test_llm_list_unit.py +++ b/test/testcases/test_web_api/test_llm_app/test_llm_list_unit.py @@ -16,6 +16,7 @@ import asyncio import importlib.util +import json import sys from pathlib import Path from types import ModuleType, SimpleNamespace @@ -39,18 +40,39 @@ class _ExprField: return (self.name, other) +class _StrEnum(str): + @property + def value(self): + return str(self) + + class _DummyTenantLLMModel: tenant_id = _ExprField("tenant_id") llm_factory = _ExprField("llm_factory") + llm_name = _ExprField("llm_name") class _TenantLLMRow: - def __init__(self, *, llm_name, llm_factory, model_type, api_key="key", status="1"): + def __init__( + self, + *, + llm_name, + llm_factory, + model_type, + api_key="key", + status="1", + used_tokens=0, + api_base="", + max_tokens=8192, + ): self.llm_name = llm_name self.llm_factory = llm_factory self.model_type = model_type self.api_key = api_key self.status = status + self.used_tokens = used_tokens + self.api_base = api_base + self.max_tokens = max_tokens def to_dict(self): return { @@ -58,15 +80,19 @@ class _TenantLLMRow: "llm_factory": self.llm_factory, "model_type": self.model_type, "status": self.status, + "used_tokens": self.used_tokens, + "api_base": self.api_base, + "max_tokens": self.max_tokens, } class _LLMRow: - def __init__(self, *, llm_name, fid, model_type, status="1"): + def __init__(self, *, llm_name, fid, model_type, status="1", max_tokens=2048): self.llm_name = llm_name self.fid = fid self.model_type = model_type self.status = status + self.max_tokens = max_tokens def to_dict(self): return { @@ -74,6 +100,7 @@ class _LLMRow: "fid": self.fid, "model_type": self.model_type, "status": self.status, + "max_tokens": self.max_tokens, } @@ -81,6 +108,13 @@ def _run(coro): return asyncio.run(coro) +def _set_request_json(monkeypatch, module, payload): + async def _get_request_json(): + return dict(payload) + + monkeypatch.setattr(module, "get_request_json", _get_request_json) + + def _load_llm_app(monkeypatch): repo_root = Path(__file__).resolve().parents[4] @@ -122,6 +156,10 @@ def _load_llm_app(monkeypatch): def filter_delete(_filters): return True + @staticmethod + def filter_update(_filters, _payload): + return True + tenant_llm_mod.LLMFactoriesService = _StubLLMFactoriesService tenant_llm_mod.TenantLLMService = _StubTenantLLMService monkeypatch.setitem(sys.modules, "api.db.services.tenant_llm_service", tenant_llm_mod) @@ -164,13 +202,13 @@ def _load_llm_app(monkeypatch): constants_mod = ModuleType("common.constants") constants_mod.StatusEnum = SimpleNamespace(VALID=SimpleNamespace(value="1"), INVALID=SimpleNamespace(value="0")) constants_mod.LLMType = SimpleNamespace( - CHAT="chat", - EMBEDDING="embedding", - SPEECH2TEXT="speech2text", - IMAGE2TEXT="image2text", - RERANK="rerank", - TTS="tts", - OCR="ocr", + CHAT=_StrEnum("chat"), + EMBEDDING=_StrEnum("embedding"), + SPEECH2TEXT=_StrEnum("speech2text"), + IMAGE2TEXT=_StrEnum("image2text"), + RERANK=_StrEnum("rerank"), + TTS=_StrEnum("tts"), + OCR=_StrEnum("ocr"), ) monkeypatch.setitem(sys.modules, "common.constants", constants_mod) @@ -179,7 +217,7 @@ def _load_llm_app(monkeypatch): monkeypatch.setitem(sys.modules, "api.db.db_models", db_models_mod) base64_mod = ModuleType("rag.utils.base64_image") - base64_mod.test_image = lambda _s: _s + base64_mod.test_image = b"image-bytes" monkeypatch.setitem(sys.modules, "rag.utils.base64_image", base64_mod) rag_llm_mod = ModuleType("rag.llm") @@ -288,3 +326,529 @@ def test_list_app_exception_path(monkeypatch): res = _run(module.list_app()) assert res["code"] == 500 assert "query boom" in res["message"] + + +@pytest.mark.p2 +def test_factories_route_success_and_exception_unit(monkeypatch): + module = _load_llm_app(monkeypatch) + + def _factory(name): + return SimpleNamespace(name=name, to_dict=lambda n=name: {"name": n}) + + monkeypatch.setattr( + module, + "get_allowed_llm_factories", + lambda: [ + _factory("OpenAI"), + _factory("CustomFactory"), + _factory("FastEmbed"), + _factory("Builtin"), + ], + ) + monkeypatch.setattr( + module.LLMService, + "get_all", + lambda: [ + _LLMRow(llm_name="m1", fid="OpenAI", model_type="chat", status="1"), + _LLMRow(llm_name="m2", fid="OpenAI", model_type="embedding", status="1"), + _LLMRow(llm_name="m3", fid="OpenAI", model_type="rerank", status="0"), + ], + ) + res = module.factories() + assert res["code"] == 0 + names = [item["name"] for item in res["data"]] + assert "FastEmbed" not in names + assert "Builtin" not in names + assert {"OpenAI", "CustomFactory"} == set(names) + openai = next(item for item in res["data"] if item["name"] == "OpenAI") + assert {"chat", "embedding"} == set(openai["model_types"]) + + monkeypatch.setattr(module, "get_allowed_llm_factories", lambda: (_ for _ in ()).throw(RuntimeError("factories boom"))) + res = module.factories() + assert res["code"] == 500 + assert "factories boom" in res["message"] + + +@pytest.mark.p2 +def test_set_api_key_model_probe_matrix_unit(monkeypatch): + module = _load_llm_app(monkeypatch) + + async def _wait_for(coro, *_args, **_kwargs): + return await coro + + async def _to_thread(fn, *args, **kwargs): + return fn(*args, **kwargs) + + monkeypatch.setattr(module.asyncio, "wait_for", _wait_for) + monkeypatch.setattr(module.asyncio, "to_thread", _to_thread) + + class _EmbeddingFail: + def __init__(self, *_args, **_kwargs): + pass + + def encode(self, _texts): + return [[]], 1 + + class _EmbeddingPass: + def __init__(self, *_args, **_kwargs): + pass + + def encode(self, _texts): + return [[0.1]], 1 + + class _ChatFail: + def __init__(self, *_args, **_kwargs): + pass + + async def async_chat(self, *_args, **_kwargs): + return "**ERROR** chat fail", 1 + + class _RerankFail: + def __init__(self, *_args, **_kwargs): + pass + + def similarity(self, *_args, **_kwargs): + return [], 0 + + factory = "FactoryA" + monkeypatch.setattr( + module.LLMService, + "query", + lambda **_kwargs: [ + _LLMRow(llm_name="emb", fid=factory, model_type=module.LLMType.EMBEDDING.value, max_tokens=321), + _LLMRow(llm_name="chat", fid=factory, model_type=module.LLMType.CHAT.value, max_tokens=654), + _LLMRow(llm_name="rerank", fid=factory, model_type=module.LLMType.RERANK.value, max_tokens=987), + ], + ) + monkeypatch.setattr(module, "EmbeddingModel", {factory: _EmbeddingFail}) + monkeypatch.setattr(module, "ChatModel", {factory: _ChatFail}) + monkeypatch.setattr(module, "RerankModel", {factory: _RerankFail}) + + req = {"llm_factory": factory, "api_key": "k", "base_url": "http://x", "verify": True} + _set_request_json(monkeypatch, module, req) + res = _run(module.set_api_key()) + assert res["code"] == 0 + assert res["data"]["success"] is False + assert "Fail to access embedding model(emb)" in res["data"]["message"] + assert "Fail to access model(FactoryA/chat)" in res["data"]["message"] + assert "Fail to access model(FactoryA/rerank)" in res["data"]["message"] + + req["verify"] = False + _set_request_json(monkeypatch, module, req) + res = _run(module.set_api_key()) + assert res["code"] == 400 + assert "Fail to access embedding model(emb)" in res["message"] + + calls = {"filter_update": [], "save": []} + + def _filter_update(filters, payload): + calls["filter_update"].append((filters, dict(payload))) + return False + + def _save(**kwargs): + calls["save"].append(kwargs) + return True + + monkeypatch.setattr(module, "EmbeddingModel", {factory: _EmbeddingPass}) + monkeypatch.setattr(module.LLMService, "query", lambda **_kwargs: [_LLMRow(llm_name="emb-pass", fid=factory, model_type=module.LLMType.EMBEDDING.value, max_tokens=2049)]) + monkeypatch.setattr(module.TenantLLMService, "filter_update", _filter_update) + monkeypatch.setattr(module.TenantLLMService, "save", _save) + + success_req = { + "llm_factory": factory, + "api_key": "k2", + "base_url": "http://y", + "model_type": "chat", + "llm_name": "manual-model", + } + _set_request_json(monkeypatch, module, success_req) + res = _run(module.set_api_key()) + assert res["code"] == 0 + assert res["data"] is True + assert calls["filter_update"] + assert calls["filter_update"][0][1]["model_type"] == "chat" + assert calls["filter_update"][0][1]["llm_name"] == "manual-model" + assert calls["filter_update"][0][1]["max_tokens"] == 2049 + assert calls["save"][0]["max_tokens"] == 2049 + assert calls["save"][0]["llm_name"] == "emb-pass" + + +@pytest.mark.p2 +def test_add_llm_factory_specific_key_assembly_unit(monkeypatch): + module = _load_llm_app(monkeypatch) + + async def _wait_for(coro, *_args, **_kwargs): + return await coro + + async def _to_thread(fn, *args, **kwargs): + return fn(*args, **kwargs) + + monkeypatch.setattr(module.asyncio, "wait_for", _wait_for) + monkeypatch.setattr(module.asyncio, "to_thread", _to_thread) + + allowed = [ + "VolcEngine", + "Tencent Cloud", + "Bedrock", + "LocalAI", + "HuggingFace", + "OpenAI-API-Compatible", + "VLLM", + "XunFei Spark", + "BaiduYiyan", + "Fish Audio", + "Google Cloud", + "Azure-OpenAI", + "OpenRouter", + "MinerU", + "PaddleOCR", + ] + monkeypatch.setattr(module, "get_allowed_llm_factories", lambda: [SimpleNamespace(name=name) for name in allowed]) + + captured = {"chat": [], "tts": [], "filter_payloads": []} + + class _ChatOK: + def __init__(self, key, model_name, base_url="", **_kwargs): + captured["chat"].append((key, model_name, base_url)) + + async def async_chat(self, *_args, **_kwargs): + return "ok", 1 + + class _TTSOK: + def __init__(self, key, model_name, base_url="", **_kwargs): + captured["tts"].append((key, model_name, base_url)) + + def tts(self, _text): + yield b"ok" + + monkeypatch.setattr(module, "ChatModel", {name: _ChatOK for name in allowed}) + monkeypatch.setattr(module, "TTSModel", {"XunFei Spark": _TTSOK}) + monkeypatch.setattr(module.TenantLLMService, "filter_update", lambda _filters, payload: captured["filter_payloads"].append(dict(payload)) or True) + + reject_req = {"llm_factory": "NotAllowed", "llm_name": "x", "model_type": module.LLMType.CHAT.value} + _set_request_json(monkeypatch, module, reject_req) + res = _run(module.add_llm()) + assert res["code"] == 400 + assert "is not allowed" in res["message"] + + def _run_case(factory, *, model_type=module.LLMType.CHAT.value, extra=None): + req = {"llm_factory": factory, "llm_name": "model", "model_type": model_type, "api_key": "k", "api_base": "http://api"} + if extra: + req.update(extra) + _set_request_json(monkeypatch, module, req) + out = _run(module.add_llm()) + assert out["code"] == 0 + assert out["data"] is True + return captured["filter_payloads"][-1] + + volc = _run_case("VolcEngine", extra={"ark_api_key": "ak", "endpoint_id": "eid"}) + assert json.loads(volc["api_key"]) == {"ark_api_key": "ak", "endpoint_id": "eid"} + + bedrock = _run_case( + "Bedrock", + extra={"auth_mode": "iam", "bedrock_ak": "ak", "bedrock_sk": "sk", "bedrock_region": "r", "aws_role_arn": "arn"}, + ) + assert json.loads(bedrock["api_key"]) == { + "auth_mode": "iam", + "bedrock_ak": "ak", + "bedrock_sk": "sk", + "bedrock_region": "r", + "aws_role_arn": "arn", + } + + localai = _run_case("LocalAI") + assert localai["llm_name"] == "model___LocalAI" + huggingface = _run_case("HuggingFace") + assert huggingface["llm_name"] == "model___HuggingFace" + openapi = _run_case("OpenAI-API-Compatible") + assert openapi["llm_name"] == "model___OpenAI-API" + vllm = _run_case("VLLM") + assert vllm["llm_name"] == "model___VLLM" + + spark_chat = _run_case("XunFei Spark", extra={"spark_api_password": "spark-pass"}) + assert spark_chat["api_key"] == "spark-pass" + spark_tts = _run_case( + "XunFei Spark", + model_type=module.LLMType.TTS.value, + extra={"spark_app_id": "app", "spark_api_secret": "secret", "spark_api_key": "key"}, + ) + assert json.loads(spark_tts["api_key"]) == { + "spark_app_id": "app", + "spark_api_secret": "secret", + "spark_api_key": "key", + } + + baidu = _run_case("BaiduYiyan", extra={"yiyan_ak": "ak", "yiyan_sk": "sk"}) + assert json.loads(baidu["api_key"]) == {"yiyan_ak": "ak", "yiyan_sk": "sk"} + fish = _run_case("Fish Audio", extra={"fish_audio_ak": "ak", "fish_audio_refid": "rid"}) + assert json.loads(fish["api_key"]) == {"fish_audio_ak": "ak", "fish_audio_refid": "rid"} + google = _run_case( + "Google Cloud", + extra={"google_project_id": "pid", "google_region": "us", "google_service_account_key": "sak"}, + ) + assert json.loads(google["api_key"]) == { + "google_project_id": "pid", + "google_region": "us", + "google_service_account_key": "sak", + } + azure = _run_case("Azure-OpenAI", extra={"api_key": "real-key", "api_version": "2024-01-01"}) + assert json.loads(azure["api_key"]) == {"api_key": "real-key", "api_version": "2024-01-01"} + openrouter = _run_case("OpenRouter", extra={"api_key": "or-key", "provider_order": "a,b"}) + assert json.loads(openrouter["api_key"]) == {"api_key": "or-key", "provider_order": "a,b"} + mineru = _run_case("MinerU", extra={"api_key": "m-key", "provider_order": "p1"}) + assert json.loads(mineru["api_key"]) == {"api_key": "m-key", "provider_order": "p1"} + paddle = _run_case("PaddleOCR", extra={"api_key": "p-key", "provider_order": "p2"}) + assert json.loads(paddle["api_key"]) == {"api_key": "p-key", "provider_order": "p2"} + + tencent_req = { + "llm_factory": "Tencent Cloud", + "llm_name": "model", + "model_type": module.LLMType.CHAT.value, + "tencent_cloud_sid": "sid", + "tencent_cloud_sk": "sk", + } + + async def _tencent_request_json(): + return tencent_req + + monkeypatch.setattr(module, "get_request_json", _tencent_request_json) + delegated = {} + + async def _fake_set_api_key(): + delegated["api_key"] = tencent_req.get("api_key") + return {"code": 0, "data": "delegated"} + + monkeypatch.setattr(module, "set_api_key", _fake_set_api_key) + res = _run(module.add_llm()) + assert res["code"] == 0 + assert res["data"] == "delegated" + assert json.loads(delegated["api_key"]) == {"tencent_cloud_sid": "sid", "tencent_cloud_sk": "sk"} + + +@pytest.mark.p2 +def test_add_llm_model_type_probe_and_persistence_matrix_unit(monkeypatch): + module = _load_llm_app(monkeypatch) + + async def _wait_for(coro, *_args, **_kwargs): + return await coro + + async def _to_thread(fn, *args, **kwargs): + return fn(*args, **kwargs) + + monkeypatch.setattr(module.asyncio, "wait_for", _wait_for) + monkeypatch.setattr(module.asyncio, "to_thread", _to_thread) + monkeypatch.setattr( + module, + "get_allowed_llm_factories", + lambda: [ + SimpleNamespace(name=name) + for name in [ + "FEmbFail", + "FEmbPass", + "FChatFail", + "FChatPass", + "FRKey", + "FRFail", + "FImgFail", + "FTTSFail", + "FOcrFail", + "FSttFail", + "FUnknown", + ] + ], + ) + + class _EmbeddingFail: + def __init__(self, *_args, **_kwargs): + pass + + def encode(self, _texts): + return [[]], 1 + + class _EmbeddingPass: + def __init__(self, *_args, **_kwargs): + pass + + def encode(self, _texts): + return [[0.5]], 1 + + class _ChatFail: + def __init__(self, *_args, **_kwargs): + pass + + async def async_chat(self, *_args, **_kwargs): + return "**ERROR**: chat failed", 0 + + class _ChatPass: + def __init__(self, *_args, **_kwargs): + pass + + async def async_chat(self, *_args, **_kwargs): + return "ok", 1 + + class _RerankFail: + def __init__(self, *_args, **_kwargs): + pass + + def similarity(self, *_args, **_kwargs): + return [], 1 + + class _CvFail: + def __init__(self, *_args, **_kwargs): + pass + + def describe(self, _image_data): + return "**ERROR**: image failed", 0 + + class _TTSFail: + def __init__(self, *_args, **_kwargs): + pass + + def tts(self, _text): + raise RuntimeError("tts fail") + yield b"x" + + class _OcrFail: + def __init__(self, *_args, **_kwargs): + pass + + def check_available(self): + return False, "ocr unavailable" + + class _SttFail: + def __init__(self, *_args, **_kwargs): + raise RuntimeError("stt fail") + + class _RerankKeyMap(dict): + def __contains__(self, key): + if key == "FRKey": + return True + return super().__contains__(key) + + def __getitem__(self, key): + if key == "FRKey": + raise KeyError("rerank key fail") + return super().__getitem__(key) + + monkeypatch.setattr(module, "EmbeddingModel", {"FEmbFail": _EmbeddingFail, "FEmbPass": _EmbeddingPass}) + monkeypatch.setattr(module, "ChatModel", {"FChatFail": _ChatFail, "FChatPass": _ChatPass}) + monkeypatch.setattr(module, "RerankModel", _RerankKeyMap({"FRFail": _RerankFail})) + monkeypatch.setattr(module, "CvModel", {"FImgFail": _CvFail}) + monkeypatch.setattr(module, "TTSModel", {"FTTSFail": _TTSFail}) + monkeypatch.setattr(module, "OcrModel", {"FOcrFail": _OcrFail}) + monkeypatch.setattr(module, "Seq2txtModel", {"FSttFail": _SttFail}) + + def _call(req): + _set_request_json(monkeypatch, module, req) + return _run(module.add_llm()) + + res = _call({"llm_factory": "FEmbFail", "llm_name": "m", "model_type": module.LLMType.EMBEDDING.value, "verify": True}) + assert res["code"] == 0 + assert res["data"]["success"] is False + assert "Fail to access embedding model(m)." in res["data"]["message"] + + res = _call({"llm_factory": "FEmbFail", "llm_name": "m", "model_type": module.LLMType.EMBEDDING.value}) + assert res["code"] == 400 + assert "Fail to access embedding model(m)." in res["message"] + + res = _call({"llm_factory": "FChatFail", "llm_name": "m", "model_type": module.LLMType.CHAT.value, "verify": True}) + assert res["code"] == 0 + assert "Fail to access model(FChatFail/m)." in res["data"]["message"] + + res = _call({"llm_factory": "FRKey", "llm_name": "m", "model_type": module.LLMType.RERANK.value, "verify": True}) + assert res["code"] == 0 + assert "dose not support this model(FRKey/m)" in res["data"]["message"] + + res = _call({"llm_factory": "FRFail", "llm_name": "m", "model_type": module.LLMType.RERANK.value, "verify": True}) + assert res["code"] == 0 + assert "Fail to access model(FRFail/m)." in res["data"]["message"] + + res = _call({"llm_factory": "FImgFail", "llm_name": "m", "model_type": module.LLMType.IMAGE2TEXT.value, "verify": True}) + assert res["code"] == 0 + assert "Fail to access model(FImgFail/m)." in res["data"]["message"] + + res = _call({"llm_factory": "FTTSFail", "llm_name": "m", "model_type": module.LLMType.TTS.value, "verify": True}) + assert res["code"] == 0 + assert "Fail to access model(FTTSFail/m)." in res["data"]["message"] + + res = _call({"llm_factory": "FOcrFail", "llm_name": "m", "model_type": module.LLMType.OCR.value, "verify": True}) + assert res["code"] == 0 + assert "Fail to access model(FOcrFail/m)." in res["data"]["message"] + + res = _call({"llm_factory": "FSttFail", "llm_name": "m", "model_type": module.LLMType.SPEECH2TEXT.value, "verify": True}) + assert res["code"] == 0 + assert "Fail to access model(FSttFail/m)." in res["data"]["message"] + + _set_request_json(monkeypatch, module, {"llm_factory": "FUnknown", "llm_name": "m", "model_type": "unknown"}) + with pytest.raises(RuntimeError, match="Unknown model type: unknown"): + _run(module.add_llm()) + + saved = [] + monkeypatch.setattr(module.TenantLLMService, "filter_update", lambda _filters, _payload: False) + monkeypatch.setattr(module.TenantLLMService, "save", lambda **kwargs: saved.append(kwargs) or True) + res = _call({"llm_factory": "FChatPass", "llm_name": "m", "model_type": module.LLMType.CHAT.value, "api_key": "k"}) + assert res["code"] == 0 + assert res["data"] is True + assert saved + assert saved[0]["llm_factory"] == "FChatPass" + + +@pytest.mark.p2 +def test_llm_mutation_routes_unit(monkeypatch): + module = _load_llm_app(monkeypatch) + calls = {"delete": [], "update": []} + monkeypatch.setattr(module.TenantLLMService, "filter_delete", lambda filters: calls["delete"].append(filters) or True) + monkeypatch.setattr(module.TenantLLMService, "filter_update", lambda filters, payload: calls["update"].append((filters, payload)) or True) + + _set_request_json(monkeypatch, module, {"llm_factory": "OpenAI", "llm_name": "gpt"}) + res = _run(module.delete_llm()) + assert res["code"] == 0 + assert res["data"] is True + + _set_request_json(monkeypatch, module, {"llm_factory": "OpenAI", "llm_name": "gpt", "status": 0}) + res = _run(module.enable_llm()) + assert res["code"] == 0 + assert res["data"] is True + assert calls["update"][0][1]["status"] == "0" + + _set_request_json(monkeypatch, module, {"llm_factory": "OpenAI"}) + res = _run(module.delete_factory()) + assert res["code"] == 0 + assert res["data"] is True + assert len(calls["delete"]) == 2 + + +@pytest.mark.p2 +def test_my_llms_include_details_and_exception_unit(monkeypatch): + module = _load_llm_app(monkeypatch) + monkeypatch.setattr(module, "request", SimpleNamespace(args={"include_details": "true"})) + ensure_calls = [] + monkeypatch.setattr(module.TenantLLMService, "ensure_mineru_from_env", lambda tenant_id: ensure_calls.append(tenant_id)) + monkeypatch.setattr( + module.TenantLLMService, + "query", + lambda **_kwargs: [ + _TenantLLMRow( + llm_name="chat-model", + llm_factory="FactoryX", + model_type="chat", + used_tokens=42, + api_base="", + max_tokens=4096, + status="1", + ) + ], + ) + monkeypatch.setattr(module.LLMFactoriesService, "query", lambda **_kwargs: [SimpleNamespace(name="FactoryX", tags=["tag-a"])]) + res = module.my_llms() + assert res["code"] == 0 + assert ensure_calls == ["tenant-1"] + assert "FactoryX" in res["data"] + assert res["data"]["FactoryX"]["tags"] == ["tag-a"] + assert res["data"]["FactoryX"]["llm"][0]["used_token"] == 42 + assert res["data"]["FactoryX"]["llm"][0]["max_tokens"] == 4096 + + monkeypatch.setattr(module.TenantLLMService, "ensure_mineru_from_env", lambda _tenant_id: (_ for _ in ()).throw(RuntimeError("my llms boom"))) + res = module.my_llms() + assert res["code"] == 500 + assert "my llms boom" in res["message"] diff --git a/test/testcases/test_web_api/test_mcp_server_app/test_mcp_server_app_unit.py b/test/testcases/test_web_api/test_mcp_server_app/test_mcp_server_app_unit.py index 49af8223af..9aad0e34eb 100644 --- a/test/testcases/test_web_api/test_mcp_server_app/test_mcp_server_app_unit.py +++ b/test/testcases/test_web_api/test_mcp_server_app/test_mcp_server_app_unit.py @@ -16,6 +16,7 @@ import asyncio import importlib.util import inspect +import json import sys from functools import wraps from pathlib import Path @@ -131,6 +132,16 @@ def _set_request_json(monkeypatch, module, payload): monkeypatch.setattr(module, "get_request_json", _request_json) +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + def _load_mcp_server_app(monkeypatch): repo_root = Path(__file__).resolve().parents[4] @@ -197,6 +208,28 @@ def _load_mcp_server_app(monkeypatch): api_utils_mod.get_mcp_tools = _get_mcp_tools monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod) + web_utils_mod = ModuleType("api.utils.web_utils") + + def _get_float(data, key, default): + try: + return float(data.get(key, default)) + except (TypeError, ValueError): + return default + + def _safe_json_parse(value): + if isinstance(value, (dict, list)): + return value + if value in (None, ""): + return {} + try: + return json.loads(value) + except (TypeError, ValueError): + return {} + + web_utils_mod.get_float = _get_float + web_utils_mod.safe_json_parse = _safe_json_parse + monkeypatch.setitem(sys.modules, "api.utils.web_utils", web_utils_mod) + module_name = "test_mcp_server_app_unit_module" module_path = repo_root / "api" / "apps" / "mcp_server_app.py" spec = importlib.util.spec_from_file_location(module_name, module_path) @@ -706,3 +739,159 @@ def test_test_tool_missing_mcp_id(monkeypatch): _set_request_json(monkeypatch, module, {"mcp_id": "", "tool_name": "tool_a", "arguments": {"x": 1}}) res = _run(module.test_tool.__wrapped__()) assert "No MCP server ID provided" in res["message"] + + +@pytest.mark.p2 +def test_test_tool_route_matrix_unit(monkeypatch): + module = _load_mcp_server_app(monkeypatch) + + _set_request_json(monkeypatch, module, {"mcp_id": "", "tool_name": "tool_a", "arguments": {"x": 1}}) + res = _run(module.test_tool.__wrapped__()) + assert "No MCP server ID provided" in res["message"] + + _set_request_json(monkeypatch, module, {"mcp_id": "id1", "tool_name": "", "arguments": {"x": 1}}) + res = _run(module.test_tool.__wrapped__()) + assert "Require provide tool name and arguments" in res["message"] + + _set_request_json(monkeypatch, module, {"mcp_id": "id1", "tool_name": "tool_a", "arguments": {}}) + res = _run(module.test_tool.__wrapped__()) + assert "Require provide tool name and arguments" in res["message"] + + _set_request_json(monkeypatch, module, {"mcp_id": "id1", "tool_name": "tool_a", "arguments": {"x": 1}}) + monkeypatch.setattr(module.MCPServerService, "get_by_id", lambda _mcp_id: (False, None)) + res = _run(module.test_tool.__wrapped__()) + assert "Cannot find MCP server id1 for user tenant_1" in res["message"] + + server_other = _DummyMCPServer(id="id1", name="srv", url="http://a", server_type="sse", tenant_id="other", variables={}) + monkeypatch.setattr(module.MCPServerService, "get_by_id", lambda _mcp_id: (True, server_other)) + res = _run(module.test_tool.__wrapped__()) + assert "Cannot find MCP server id1 for user tenant_1" in res["message"] + + server_ok = _DummyMCPServer(id="id1", name="srv", url="http://a", server_type="sse", tenant_id="tenant_1", variables={}) + monkeypatch.setattr(module.MCPServerService, "get_by_id", lambda _mcp_id: (True, server_ok)) + close_calls = [] + + async def _thread_pool_exec_success(func, *args): + if func is module.close_multiple_mcp_toolcall_sessions: + close_calls.append(args[0]) + return None + return func(*args) + + monkeypatch.setattr(module, "thread_pool_exec", _thread_pool_exec_success) + res = _run(module.test_tool.__wrapped__()) + assert res["code"] == 0 + assert res["data"] == "ok" + assert close_calls and len(close_calls[-1]) == 1 + + async def _thread_pool_exec_raise(func, *args): + if func is module.close_multiple_mcp_toolcall_sessions: + return None + raise RuntimeError("tool call explode") + + monkeypatch.setattr(module, "thread_pool_exec", _thread_pool_exec_raise) + res = _run(module.test_tool.__wrapped__()) + assert res["code"] == 100 + assert "tool call explode" in res["message"] + + +@pytest.mark.p2 +def test_cache_tool_route_matrix_unit(monkeypatch): + module = _load_mcp_server_app(monkeypatch) + + _set_request_json(monkeypatch, module, {"mcp_id": "", "tools": [{"name": "tool_a"}]}) + res = _run(module.cache_tool.__wrapped__()) + assert "No MCP server ID provided" in res["message"] + + _set_request_json(monkeypatch, module, {"mcp_id": "id1", "tools": [{"name": "tool_a"}]}) + monkeypatch.setattr(module.MCPServerService, "get_by_id", lambda _mcp_id: (False, None)) + res = _run(module.cache_tool.__wrapped__()) + assert "Cannot find MCP server id1 for user tenant_1" in res["message"] + + server_other = _DummyMCPServer(id="id1", name="srv", url="http://a", server_type="sse", tenant_id="other", variables={}) + monkeypatch.setattr(module.MCPServerService, "get_by_id", lambda _mcp_id: (True, server_other)) + res = _run(module.cache_tool.__wrapped__()) + assert "Cannot find MCP server id1 for user tenant_1" in res["message"] + + server_fail = _DummyMCPServer(id="id1", name="srv", url="http://a", server_type="sse", tenant_id="tenant_1", variables={}) + monkeypatch.setattr(module.MCPServerService, "get_by_id", lambda _mcp_id: (True, server_fail)) + monkeypatch.setattr(module.MCPServerService, "filter_update", lambda *_args, **_kwargs: False) + res = _run(module.cache_tool.__wrapped__()) + assert "Failed to updated MCP server" in res["message"] + + server_ok = _DummyMCPServer( + id="id1", + name="srv", + url="http://a", + server_type="sse", + tenant_id="tenant_1", + variables={"tools": {"old_tool": {"name": "old_tool"}}}, + ) + monkeypatch.setattr(module.MCPServerService, "get_by_id", lambda _mcp_id: (True, server_ok)) + monkeypatch.setattr(module.MCPServerService, "filter_update", lambda *_args, **_kwargs: True) + _set_request_json( + monkeypatch, + module, + { + "mcp_id": "id1", + "tools": [{"name": "tool_a", "enabled": True}, {"bad": 1}, "x", {"name": "tool_b", "enabled": False}], + }, + ) + res = _run(module.cache_tool.__wrapped__()) + assert res["code"] == 0 + assert sorted(res["data"].keys()) == ["tool_a", "tool_b"] + assert server_ok.variables["tools"]["tool_b"]["enabled"] is False + + +@pytest.mark.p2 +def test_test_mcp_route_matrix_unit(monkeypatch): + module = _load_mcp_server_app(monkeypatch) + + _set_request_json(monkeypatch, module, {"url": "", "server_type": "sse"}) + res = _run(module.test_mcp.__wrapped__()) + assert "Invalid MCP url" in res["message"] + + _set_request_json(monkeypatch, module, {"url": "http://a", "server_type": "invalid"}) + res = _run(module.test_mcp.__wrapped__()) + assert "Unsupported MCP server type" in res["message"] + + close_calls = [] + + async def _thread_pool_exec_inner_error(func, *args): + if func is module.close_multiple_mcp_toolcall_sessions: + close_calls.append(args[0]) + return None + if getattr(func, "__name__", "") == "get_tools": + raise RuntimeError("get tools explode") + return func(*args) + + monkeypatch.setattr(module, "thread_pool_exec", _thread_pool_exec_inner_error) + _set_request_json(monkeypatch, module, {"url": "http://a", "server_type": "sse"}) + res = _run(module.test_mcp.__wrapped__()) + assert res["code"] == 102 + assert "Test MCP error: get tools explode" in res["message"] + assert close_calls and len(close_calls[-1]) == 1 + + close_calls_success = [] + + async def _thread_pool_exec_success(func, *args): + if func is module.close_multiple_mcp_toolcall_sessions: + close_calls_success.append(args[0]) + return None + return func(*args) + + monkeypatch.setattr(module, "thread_pool_exec", _thread_pool_exec_success) + _set_request_json(monkeypatch, module, {"url": "http://a", "server_type": "sse"}) + res = _run(module.test_mcp.__wrapped__()) + assert res["code"] == 0 + assert res["data"][0]["name"] == "tool_a" + assert all(tool["enabled"] is True for tool in res["data"]) + assert close_calls_success and len(close_calls_success[-1]) == 1 + + def _raise_session(*_args, **_kwargs): + raise RuntimeError("session explode") + + monkeypatch.setattr(module, "MCPToolCallSession", _raise_session) + _set_request_json(monkeypatch, module, {"url": "http://a", "server_type": "sse"}) + res = _run(module.test_mcp.__wrapped__()) + assert res["code"] == 100 + assert "session explode" in res["message"] diff --git a/test/testcases/test_web_api/test_search_app/test_search_routes_unit.py b/test/testcases/test_web_api/test_search_app/test_search_routes_unit.py new file mode 100644 index 0000000000..e7c9adc897 --- /dev/null +++ b/test/testcases/test_web_api/test_search_app/test_search_routes_unit.py @@ -0,0 +1,509 @@ +# +# 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 +from copy import deepcopy +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +class _DummyManager: + def route(self, *_args, **_kwargs): + def decorator(func): + return func + + return decorator + + +class _DummyAtomic: + def __enter__(self): + return self + + def __exit__(self, _exc_type, _exc, _tb): + return False + + +class _Args(dict): + def get(self, key, default=None): + return super().get(key, default) + + +class _EnumValue: + def __init__(self, value): + self.value = value + + +class _DummyStatusEnum: + VALID = _EnumValue("1") + + +class _DummyRetCode: + SUCCESS = 0 + EXCEPTION_ERROR = 100 + ARGUMENT_ERROR = 101 + DATA_ERROR = 102 + OPERATING_ERROR = 103 + AUTHENTICATION_ERROR = 109 + + +class _SearchRecord: + def __init__(self, search_id="search-1", name="search", search_config=None): + self.id = search_id + self.name = name + self.search_config = {} if search_config is None else dict(search_config) + + def to_dict(self): + return {"id": self.id, "name": self.name, "search_config": dict(self.search_config)} + + +def _run(coro): + return asyncio.run(coro) + + +def _set_request_json(monkeypatch, module, payload): + async def _request_json(): + return deepcopy(payload) + + monkeypatch.setattr(module, "get_request_json", _request_json) + + +def _set_request_args(monkeypatch, module, args=None): + monkeypatch.setattr(module, "request", SimpleNamespace(args=_Args(args or {}))) + + +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + +def _load_search_app(monkeypatch): + repo_root = Path(__file__).resolve().parents[4] + + quart_mod = ModuleType("quart") + quart_mod.request = SimpleNamespace(args=_Args()) + monkeypatch.setitem(sys.modules, "quart", quart_mod) + + common_pkg = ModuleType("common") + common_pkg.__path__ = [str(repo_root / "common")] + monkeypatch.setitem(sys.modules, "common", common_pkg) + + misc_utils_mod = ModuleType("common.misc_utils") + misc_utils_mod.get_uuid = lambda: "search-uuid-1" + monkeypatch.setitem(sys.modules, "common.misc_utils", misc_utils_mod) + common_pkg.misc_utils = misc_utils_mod + + constants_mod = ModuleType("common.constants") + constants_mod.RetCode = _DummyRetCode + constants_mod.StatusEnum = _DummyStatusEnum + monkeypatch.setitem(sys.modules, "common.constants", constants_mod) + common_pkg.constants = constants_mod + + api_pkg = ModuleType("api") + api_pkg.__path__ = [str(repo_root / "api")] + monkeypatch.setitem(sys.modules, "api", api_pkg) + + apps_mod = ModuleType("api.apps") + apps_mod.__path__ = [str(repo_root / "api" / "apps")] + apps_mod.current_user = SimpleNamespace(id="tenant-1") + apps_mod.login_required = lambda func: func + monkeypatch.setitem(sys.modules, "api.apps", apps_mod) + api_pkg.apps = apps_mod + + constants_api_mod = ModuleType("api.constants") + constants_api_mod.DATASET_NAME_LIMIT = 255 + monkeypatch.setitem(sys.modules, "api.constants", constants_api_mod) + + db_pkg = ModuleType("api.db") + db_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.db", db_pkg) + api_pkg.db = db_pkg + + db_models_mod = ModuleType("api.db.db_models") + + class _DummyDB: + @staticmethod + def atomic(): + return _DummyAtomic() + + db_models_mod.DB = _DummyDB + monkeypatch.setitem(sys.modules, "api.db.db_models", db_models_mod) + + services_pkg = ModuleType("api.db.services") + services_pkg.__path__ = [] + services_pkg.duplicate_name = lambda _checker, **kwargs: kwargs.get("name", "") + monkeypatch.setitem(sys.modules, "api.db.services", services_pkg) + + search_service_mod = ModuleType("api.db.services.search_service") + + class _SearchService: + @staticmethod + def query(**_kwargs): + return [] + + @staticmethod + def save(**_kwargs): + return True + + @staticmethod + def accessible4deletion(_search_id, _user_id): + return True + + @staticmethod + def update_by_id(_search_id, _req): + return True + + @staticmethod + def get_by_id(_search_id): + return True, _SearchRecord(search_id=_search_id, name="updated") + + @staticmethod + def get_detail(_search_id): + return {"id": _search_id} + + @staticmethod + def get_by_tenant_ids(_tenants, _user_id, _page_number, _items_per_page, _orderby, _desc, _keywords): + return [], 0 + + @staticmethod + def delete_by_id(_search_id): + return True + + search_service_mod.SearchService = _SearchService + monkeypatch.setitem(sys.modules, "api.db.services.search_service", search_service_mod) + + user_service_mod = ModuleType("api.db.services.user_service") + + class _TenantService: + @staticmethod + def get_by_id(_tenant_id): + return True, SimpleNamespace(id=_tenant_id) + + class _UserTenantService: + @staticmethod + def query(**_kwargs): + return [SimpleNamespace(tenant_id="tenant-1")] + + user_service_mod.TenantService = _TenantService + user_service_mod.UserTenantService = _UserTenantService + monkeypatch.setitem(sys.modules, "api.db.services.user_service", user_service_mod) + + utils_pkg = ModuleType("api.utils") + utils_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.utils", utils_pkg) + + api_utils_mod = ModuleType("api.utils.api_utils") + + async def _default_request_json(): + return {} + + def _get_data_error_result(code=_DummyRetCode.DATA_ERROR, message="Sorry! Data missing!"): + return {"code": code, "message": message} + + def _get_json_result(code=_DummyRetCode.SUCCESS, message="success", data=None): + return {"code": code, "message": message, "data": data} + + def _server_error_response(error): + return {"code": _DummyRetCode.EXCEPTION_ERROR, "message": repr(error)} + + def _validate_request(*_args, **_kwargs): + def _decorator(func): + return func + + return _decorator + + def _not_allowed_parameters(*_params): + def _decorator(func): + return func + + return _decorator + + api_utils_mod.get_request_json = _default_request_json + api_utils_mod.get_data_error_result = _get_data_error_result + api_utils_mod.get_json_result = _get_json_result + api_utils_mod.server_error_response = _server_error_response + api_utils_mod.validate_request = _validate_request + api_utils_mod.not_allowed_parameters = _not_allowed_parameters + monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod) + utils_pkg.api_utils = api_utils_mod + + module_name = "test_search_routes_unit_module" + module_path = repo_root / "api" / "apps" / "search_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 + + +@pytest.mark.p2 +def test_create_route_matrix_unit(monkeypatch): + module = _load_search_app(monkeypatch) + + _set_request_json(monkeypatch, module, {"name": 1}) + res = _run(module.create()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "must be string" in res["message"] + + _set_request_json(monkeypatch, module, {"name": " "}) + res = _run(module.create()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "empty" in res["message"].lower() + + _set_request_json(monkeypatch, module, {"name": "a" * 256}) + res = _run(module.create()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "255" in res["message"] + + _set_request_json(monkeypatch, module, {"name": "create-auth-fail"}) + monkeypatch.setattr(module.TenantService, "get_by_id", lambda _tenant_id: (False, None)) + res = _run(module.create()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "authorized identity" in res["message"].lower() + + monkeypatch.setattr(module.TenantService, "get_by_id", lambda _tenant_id: (True, SimpleNamespace(id=_tenant_id))) + monkeypatch.setattr(module, "duplicate_name", lambda _checker, **kwargs: kwargs["name"] + "_dedup") + _set_request_json(monkeypatch, module, {"name": "create-fail", "description": "d"}) + monkeypatch.setattr(module.SearchService, "save", lambda **_kwargs: False) + res = _run(module.create()) + assert res["code"] == module.RetCode.DATA_ERROR + + _set_request_json(monkeypatch, module, {"name": "create-ok", "description": "d"}) + monkeypatch.setattr(module.SearchService, "save", lambda **_kwargs: True) + res = _run(module.create()) + assert res["code"] == 0 + assert res["data"]["search_id"] == "search-uuid-1" + + def _raise_save(**_kwargs): + raise RuntimeError("save boom") + + monkeypatch.setattr(module.SearchService, "save", _raise_save) + _set_request_json(monkeypatch, module, {"name": "create-exception", "description": "d"}) + res = _run(module.create()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "save boom" in res["message"] + + +@pytest.mark.p2 +def test_update_and_detail_route_matrix_unit(monkeypatch): + module = _load_search_app(monkeypatch) + + _set_request_json(monkeypatch, module, {"search_id": "s1", "name": 1, "search_config": {}, "tenant_id": "tenant-1"}) + res = _run(module.update()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "must be string" in res["message"] + + _set_request_json(monkeypatch, module, {"search_id": "s1", "name": " ", "search_config": {}, "tenant_id": "tenant-1"}) + res = _run(module.update()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "empty" in res["message"].lower() + + _set_request_json(monkeypatch, module, {"search_id": "s1", "name": "a" * 256, "search_config": {}, "tenant_id": "tenant-1"}) + res = _run(module.update()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "large than" in res["message"] + + _set_request_json(monkeypatch, module, {"search_id": "s1", "name": "ok", "search_config": {}, "tenant_id": "tenant-1"}) + monkeypatch.setattr(module.TenantService, "get_by_id", lambda _tenant_id: (False, None)) + res = _run(module.update()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "authorized identity" in res["message"].lower() + + monkeypatch.setattr(module.TenantService, "get_by_id", lambda _tenant_id: (True, SimpleNamespace(id=_tenant_id))) + monkeypatch.setattr(module.SearchService, "accessible4deletion", lambda _search_id, _user_id: False) + _set_request_json(monkeypatch, module, {"search_id": "s1", "name": "ok", "search_config": {}, "tenant_id": "tenant-1"}) + res = _run(module.update()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + assert "authorization" in res["message"].lower() + + monkeypatch.setattr(module.SearchService, "accessible4deletion", lambda _search_id, _user_id: True) + monkeypatch.setattr(module.SearchService, "query", lambda **_kwargs: [None]) + _set_request_json(monkeypatch, module, {"search_id": "s1", "name": "ok", "search_config": {}, "tenant_id": "tenant-1"}) + res = _run(module.update()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "cannot find search" in res["message"].lower() + + existing = _SearchRecord(search_id="s1", name="old-name", search_config={"existing": 1}) + + def _query_duplicate(**kwargs): + if "id" in kwargs: + return [existing] + if "name" in kwargs: + return [SimpleNamespace(id="dup")] + return [] + + monkeypatch.setattr(module.SearchService, "query", _query_duplicate) + _set_request_json(monkeypatch, module, {"search_id": "s1", "name": "new-name", "search_config": {}, "tenant_id": "tenant-1"}) + res = _run(module.update()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "duplicated" in res["message"].lower() + + monkeypatch.setattr(module.SearchService, "query", lambda **_kwargs: [existing]) + _set_request_json(monkeypatch, module, {"search_id": "s1", "name": "old-name", "search_config": [], "tenant_id": "tenant-1"}) + res = _run(module.update()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "json object" in res["message"].lower() + + captured = {} + + def _update_fail(search_id, req): + captured["search_id"] = search_id + captured["req"] = dict(req) + return False + + monkeypatch.setattr(module.SearchService, "update_by_id", _update_fail) + _set_request_json(monkeypatch, module, {"search_id": "s1", "name": "old-name", "search_config": {"top_k": 3}, "tenant_id": "tenant-1"}) + res = _run(module.update()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "failed to update" in res["message"].lower() + assert captured["search_id"] == "s1" + assert "search_id" not in captured["req"] + assert "tenant_id" not in captured["req"] + assert captured["req"]["search_config"] == {"existing": 1, "top_k": 3} + + monkeypatch.setattr(module.SearchService, "update_by_id", lambda _search_id, _req: True) + monkeypatch.setattr(module.SearchService, "get_by_id", lambda _search_id: (False, None)) + res = _run(module.update()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "failed to fetch" in res["message"].lower() + + monkeypatch.setattr( + module.SearchService, + "get_by_id", + lambda _search_id: (True, _SearchRecord(search_id=_search_id, name="old-name", search_config={"existing": 1, "top_k": 3})), + ) + res = _run(module.update()) + assert res["code"] == 0 + assert res["data"]["id"] == "s1" + + def _raise_query(**_kwargs): + raise RuntimeError("update boom") + + monkeypatch.setattr(module.SearchService, "query", _raise_query) + _set_request_json(monkeypatch, module, {"search_id": "s1", "name": "old-name", "search_config": {"top_k": 3}, "tenant_id": "tenant-1"}) + res = _run(module.update()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "update boom" in res["message"] + + _set_request_args(monkeypatch, module, {"search_id": "s1"}) + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [SimpleNamespace(tenant_id="tenant-a")]) + monkeypatch.setattr(module.SearchService, "query", lambda **_kwargs: []) + res = module.detail() + assert res["code"] == module.RetCode.OPERATING_ERROR + assert "permission" in res["message"].lower() + + monkeypatch.setattr(module.SearchService, "query", lambda **_kwargs: [SimpleNamespace(id="s1")]) + monkeypatch.setattr(module.SearchService, "get_detail", lambda _search_id: None) + res = module.detail() + assert res["code"] == module.RetCode.DATA_ERROR + assert "can't find" in res["message"].lower() + + monkeypatch.setattr(module.SearchService, "get_detail", lambda _search_id: {"id": _search_id, "name": "detail-name"}) + res = module.detail() + assert res["code"] == 0 + assert res["data"]["id"] == "s1" + + def _raise_detail(_search_id): + raise RuntimeError("detail boom") + + monkeypatch.setattr(module.SearchService, "get_detail", _raise_detail) + res = module.detail() + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "detail boom" in res["message"] + + +@pytest.mark.p2 +def test_list_and_rm_route_matrix_unit(monkeypatch): + module = _load_search_app(monkeypatch) + + _set_request_args( + monkeypatch, + module, + {"keywords": "k", "page": "1", "page_size": "2", "orderby": "create_time", "desc": "false"}, + ) + _set_request_json(monkeypatch, module, {"owner_ids": []}) + monkeypatch.setattr( + module.SearchService, + "get_by_tenant_ids", + lambda _tenants, _uid, _page, _size, _orderby, _desc, _keywords: ([{"id": "a", "tenant_id": "tenant-1"}], 1), + ) + res = _run(module.list_search_app()) + assert res["code"] == 0 + assert res["data"]["total"] == 1 + assert res["data"]["search_apps"][0]["id"] == "a" + + _set_request_args( + monkeypatch, + module, + {"keywords": "k", "page": "1", "page_size": "1", "orderby": "create_time", "desc": "true"}, + ) + _set_request_json(monkeypatch, module, {"owner_ids": ["tenant-1"]}) + monkeypatch.setattr( + module.SearchService, + "get_by_tenant_ids", + lambda _tenants, _uid, _page, _size, _orderby, _desc, _keywords: ( + [{"id": "x", "tenant_id": "tenant-1"}, {"id": "y", "tenant_id": "tenant-2"}], + 2, + ), + ) + res = _run(module.list_search_app()) + assert res["code"] == 0 + assert res["data"]["total"] == 1 + assert len(res["data"]["search_apps"]) == 1 + assert res["data"]["search_apps"][0]["tenant_id"] == "tenant-1" + + def _raise_list(*_args, **_kwargs): + raise RuntimeError("list boom") + + monkeypatch.setattr(module.SearchService, "get_by_tenant_ids", _raise_list) + _set_request_json(monkeypatch, module, {"owner_ids": []}) + res = _run(module.list_search_app()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "list boom" in res["message"] + + _set_request_json(monkeypatch, module, {"search_id": "search-1"}) + monkeypatch.setattr(module.SearchService, "accessible4deletion", lambda _search_id, _user_id: False) + res = _run(module.rm()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + assert "authorization" in res["message"].lower() + + monkeypatch.setattr(module.SearchService, "accessible4deletion", lambda _search_id, _user_id: True) + monkeypatch.setattr(module.SearchService, "delete_by_id", lambda _search_id: False) + res = _run(module.rm()) + assert res["code"] == module.RetCode.DATA_ERROR + assert "failed to delete" in res["message"].lower() + + monkeypatch.setattr(module.SearchService, "delete_by_id", lambda _search_id: True) + res = _run(module.rm()) + assert res["code"] == 0 + assert res["data"] is True + + def _raise_delete(_search_id): + raise RuntimeError("rm boom") + + monkeypatch.setattr(module.SearchService, "delete_by_id", _raise_delete) + res = _run(module.rm()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "rm boom" in res["message"] diff --git a/test/testcases/test_web_api/test_system_app/test_system_routes_unit.py b/test/testcases/test_web_api/test_system_app/test_system_routes_unit.py new file mode 100644 index 0000000000..08e24992d9 --- /dev/null +++ b/test/testcases/test_web_api/test_system_app/test_system_routes_unit.py @@ -0,0 +1,322 @@ +# +# 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 importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +class _DummyManager: + def route(self, *_args, **_kwargs): + def decorator(func): + return func + + return decorator + + +class _ExprField: + def __init__(self, name): + self.name = name + + def __eq__(self, other): + return (self.name, other) + + +class _DummyAPITokenModel: + tenant_id = _ExprField("tenant_id") + token = _ExprField("token") + + +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + +def _load_system_module(monkeypatch): + repo_root = Path(__file__).resolve().parents[4] + + api_pkg = ModuleType("api") + api_pkg.__path__ = [str(repo_root / "api")] + monkeypatch.setitem(sys.modules, "api", api_pkg) + + apps_mod = ModuleType("api.apps") + apps_mod.__path__ = [str(repo_root / "api" / "apps")] + apps_mod.login_required = lambda fn: fn + apps_mod.current_user = SimpleNamespace(id="user-1") + monkeypatch.setitem(sys.modules, "api.apps", apps_mod) + + common_pkg = ModuleType("common") + common_pkg.__path__ = [str(repo_root / "common")] + monkeypatch.setitem(sys.modules, "common", common_pkg) + + settings_mod = ModuleType("common.settings") + settings_mod.docStoreConn = SimpleNamespace(health=lambda: {"type": "doc", "status": "green"}) + settings_mod.STORAGE_IMPL = SimpleNamespace(health=lambda: True) + settings_mod.STORAGE_IMPL_TYPE = "MINIO" + settings_mod.DATABASE_TYPE = "MYSQL" + settings_mod.REGISTER_ENABLED = True + common_pkg.settings = settings_mod + monkeypatch.setitem(sys.modules, "common.settings", settings_mod) + + versions_mod = ModuleType("common.versions") + versions_mod.get_ragflow_version = lambda: "0.0.0-unit" + monkeypatch.setitem(sys.modules, "common.versions", versions_mod) + + time_utils_mod = ModuleType("common.time_utils") + time_utils_mod.current_timestamp = lambda: 111 + time_utils_mod.datetime_format = lambda _dt: "2026-01-01 00:00:00" + monkeypatch.setitem(sys.modules, "common.time_utils", time_utils_mod) + + api_utils_mod = ModuleType("api.utils.api_utils") + api_utils_mod.get_json_result = lambda data=None, message="success", code=0: { + "code": code, + "message": message, + "data": data, + } + api_utils_mod.get_data_error_result = lambda message="", code=102, data=None: { + "code": code, + "message": message, + "data": data, + } + api_utils_mod.server_error_response = lambda exc: { + "code": 100, + "message": repr(exc), + "data": None, + } + api_utils_mod.generate_confirmation_token = lambda: "ragflow-abcdefghijklmnopqrstuvwxyz0123456789" + monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod) + + api_service_mod = ModuleType("api.db.services.api_service") + api_service_mod.APITokenService = SimpleNamespace( + save=lambda **_kwargs: True, + query=lambda **_kwargs: [], + filter_update=lambda *_args, **_kwargs: True, + filter_delete=lambda *_args, **_kwargs: True, + ) + monkeypatch.setitem(sys.modules, "api.db.services.api_service", api_service_mod) + + kb_service_mod = ModuleType("api.db.services.knowledgebase_service") + kb_service_mod.KnowledgebaseService = SimpleNamespace(get_by_id=lambda _kb_id: True) + monkeypatch.setitem(sys.modules, "api.db.services.knowledgebase_service", kb_service_mod) + + user_service_mod = ModuleType("api.db.services.user_service") + user_service_mod.UserTenantService = SimpleNamespace( + query=lambda **_kwargs: [SimpleNamespace(role="owner", tenant_id="tenant-1")] + ) + monkeypatch.setitem(sys.modules, "api.db.services.user_service", user_service_mod) + + db_models_mod = ModuleType("api.db.db_models") + db_models_mod.APIToken = _DummyAPITokenModel + monkeypatch.setitem(sys.modules, "api.db.db_models", db_models_mod) + + rag_pkg = ModuleType("rag") + rag_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "rag", rag_pkg) + + rag_utils_pkg = ModuleType("rag.utils") + rag_utils_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "rag.utils", rag_utils_pkg) + + redis_mod = ModuleType("rag.utils.redis_conn") + redis_mod.REDIS_CONN = SimpleNamespace( + health=lambda: True, + smembers=lambda *_args, **_kwargs: set(), + zrangebyscore=lambda *_args, **_kwargs: [], + ) + monkeypatch.setitem(sys.modules, "rag.utils.redis_conn", redis_mod) + + health_utils_mod = ModuleType("api.utils.health_utils") + health_utils_mod.run_health_checks = lambda: ({"status": "ok"}, True) + health_utils_mod.get_oceanbase_status = lambda: {"status": "alive"} + monkeypatch.setitem(sys.modules, "api.utils.health_utils", health_utils_mod) + + quart_mod = ModuleType("quart") + quart_mod.jsonify = lambda payload: payload + monkeypatch.setitem(sys.modules, "quart", quart_mod) + + module_path = repo_root / "api" / "apps" / "system_app.py" + spec = importlib.util.spec_from_file_location("test_system_routes_unit_module", module_path) + module = importlib.util.module_from_spec(spec) + module.manager = _DummyManager() + monkeypatch.setitem(sys.modules, "test_system_routes_unit_module", module) + spec.loader.exec_module(module) + return module + + +@pytest.mark.p2 +def test_status_branch_matrix_unit(monkeypatch): + module = _load_system_module(monkeypatch) + + monkeypatch.setattr(module.settings, "docStoreConn", SimpleNamespace(health=lambda: {"type": "es", "status": "green"})) + monkeypatch.setattr(module.settings, "STORAGE_IMPL", SimpleNamespace(health=lambda: True)) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _kb_id: True) + monkeypatch.setattr(module.REDIS_CONN, "health", lambda: True) + monkeypatch.setattr(module.REDIS_CONN, "smembers", lambda _key: {"executor-1"}) + monkeypatch.setattr(module.REDIS_CONN, "zrangebyscore", lambda *_args, **_kwargs: ['{"beat": 1}']) + + res = module.status() + assert res["code"] == 0 + assert res["data"]["doc_engine"]["status"] == "green" + assert res["data"]["storage"]["status"] == "green" + assert res["data"]["database"]["status"] == "green" + assert res["data"]["redis"]["status"] == "green" + assert res["data"]["task_executor_heartbeats"]["executor-1"][0]["beat"] == 1 + + monkeypatch.setattr( + module.settings, + "docStoreConn", + SimpleNamespace(health=lambda: (_ for _ in ()).throw(RuntimeError("doc down"))), + ) + monkeypatch.setattr( + module.settings, + "STORAGE_IMPL", + SimpleNamespace(health=lambda: (_ for _ in ()).throw(RuntimeError("storage down"))), + ) + monkeypatch.setattr( + module.KnowledgebaseService, + "get_by_id", + lambda _kb_id: (_ for _ in ()).throw(RuntimeError("db down")), + ) + monkeypatch.setattr(module.REDIS_CONN, "health", lambda: False) + monkeypatch.setattr(module.REDIS_CONN, "smembers", lambda _key: (_ for _ in ()).throw(RuntimeError("hb down"))) + + res = module.status() + assert res["code"] == 0 + assert res["data"]["doc_engine"]["status"] == "red" + assert "doc down" in res["data"]["doc_engine"]["error"] + assert res["data"]["storage"]["status"] == "red" + assert "storage down" in res["data"]["storage"]["error"] + assert res["data"]["database"]["status"] == "red" + assert "db down" in res["data"]["database"]["error"] + assert res["data"]["redis"]["status"] == "red" + assert "Lost connection!" in res["data"]["redis"]["error"] + assert res["data"]["task_executor_heartbeats"] == {} + + +@pytest.mark.p2 +def test_healthz_and_oceanbase_status_matrix_unit(monkeypatch): + module = _load_system_module(monkeypatch) + + monkeypatch.setattr(module, "run_health_checks", lambda: ({"status": "ok"}, True)) + payload, status = module.healthz() + assert status == 200 + assert payload["status"] == "ok" + + monkeypatch.setattr(module, "run_health_checks", lambda: ({"status": "degraded"}, False)) + payload, status = module.healthz() + assert status == 500 + assert payload["status"] == "degraded" + + monkeypatch.setattr(module, "get_oceanbase_status", lambda: {"status": "alive", "latency_ms": 8}) + res = module.oceanbase_status() + assert res["code"] == 0 + assert res["data"]["status"] == "alive" + + monkeypatch.setattr(module, "get_oceanbase_status", lambda: (_ for _ in ()).throw(RuntimeError("ocean boom"))) + res = module.oceanbase_status() + assert res["code"] == 500 + assert res["data"]["status"] == "error" + assert "ocean boom" in res["data"]["message"] + + +@pytest.mark.p2 +def test_system_token_routes_matrix_unit(monkeypatch): + module = _load_system_module(monkeypatch) + + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: []) + res = module.new_token() + assert res["message"] == "Tenant not found!" + + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [SimpleNamespace(role="owner", tenant_id="tenant-1")]) + monkeypatch.setattr(module.APITokenService, "save", lambda **_kwargs: False) + res = module.new_token() + assert res["message"] == "Fail to new a dialog!" + + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("tenant query boom"))) + res = module.new_token() + assert res["code"] == 100 + assert "tenant query boom" in res["message"] + + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: []) + res = module.token_list() + assert res["message"] == "Tenant not found!" + + class _Token: + def __init__(self, token, beta): + self.token = token + self.beta = beta + + def to_dict(self): + return {"token": self.token, "beta": self.beta} + + filter_updates = [] + monkeypatch.setattr(module, "generate_confirmation_token", lambda: "ragflow-abcdefghijklmnopqrstuvwxyz0123456789") + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [SimpleNamespace(role="owner", tenant_id="tenant-9")]) + monkeypatch.setattr(module.APITokenService, "query", lambda **_kwargs: [_Token("tok-1", ""), _Token("tok-2", "beta-2")]) + monkeypatch.setattr(module.APITokenService, "filter_update", lambda conds, payload: filter_updates.append((conds, payload))) + res = module.token_list() + assert res["code"] == 0 + assert len(res["data"]) == 2 + assert len(res["data"][0]["beta"]) == 32 + assert res["data"][1]["beta"] == "beta-2" + assert len(filter_updates) == 1 + + monkeypatch.setattr( + module.APITokenService, + "query", + lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("token list boom")), + ) + res = module.token_list() + assert res["code"] == 100 + assert "token list boom" in res["message"] + + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: []) + res = module.rm("tok-1") + assert res["message"] == "Tenant not found!" + + deleted = [] + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [SimpleNamespace(role="owner", tenant_id="tenant-3")]) + monkeypatch.setattr(module.APITokenService, "filter_delete", lambda conds: deleted.append(conds)) + res = module.rm("tok-1") + assert res["code"] == 0 + assert res["data"] is True + assert deleted + + monkeypatch.setattr( + module.APITokenService, + "filter_delete", + lambda _conds: (_ for _ in ()).throw(RuntimeError("delete boom")), + ) + res = module.rm("tok-1") + assert res["code"] == 100 + assert "delete boom" in res["message"] + + +@pytest.mark.p2 +def test_get_config_returns_register_enabled_unit(monkeypatch): + module = _load_system_module(monkeypatch) + monkeypatch.setattr(module.settings, "REGISTER_ENABLED", False) + res = module.get_config() + assert res["code"] == 0 + assert res["data"]["registerEnabled"] is False diff --git a/test/testcases/test_web_api/test_user_app/test_tenant_app_unit.py b/test/testcases/test_web_api/test_user_app/test_tenant_app_unit.py new file mode 100644 index 0000000000..b94a579db1 --- /dev/null +++ b/test/testcases/test_web_api/test_user_app/test_tenant_app_unit.py @@ -0,0 +1,318 @@ +# +# 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 pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +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 _ExprField: + def __init__(self, name): + self.name = name + + def __eq__(self, other): + return (self.name, other) + + +class _Invitee: + def __init__(self, user_id="invitee-1", email="invitee@example.com"): + self.id = user_id + self.email = email + + def to_dict(self): + return { + "id": self.id, + "avatar": "avatar-url", + "email": self.email, + "nickname": "Invitee", + "password": "ignored", + } + + +def _run(coro): + return asyncio.run(coro) + + +def _set_request_json(monkeypatch, module, payload): + monkeypatch.setattr(module, "get_request_json", lambda: _AwaitableValue(payload)) + + +def _load_tenant_module(monkeypatch): + repo_root = Path(__file__).resolve().parents[4] + + api_pkg = ModuleType("api") + api_pkg.__path__ = [str(repo_root / "api")] + monkeypatch.setitem(sys.modules, "api", api_pkg) + + apps_mod = ModuleType("api.apps") + apps_mod.__path__ = [str(repo_root / "api" / "apps")] + apps_mod.current_user = SimpleNamespace(id="tenant-1", email="owner@example.com") + apps_mod.login_required = lambda fn: fn + monkeypatch.setitem(sys.modules, "api.apps", apps_mod) + + db_mod = ModuleType("api.db") + db_mod.UserTenantRole = SimpleNamespace(NORMAL="normal", OWNER="owner", INVITE="invite") + monkeypatch.setitem(sys.modules, "api.db", db_mod) + + db_models_mod = ModuleType("api.db.db_models") + db_models_mod.UserTenant = type( + "UserTenant", + (), + { + "tenant_id": _ExprField("tenant_id"), + "user_id": _ExprField("user_id"), + }, + ) + monkeypatch.setitem(sys.modules, "api.db.db_models", db_models_mod) + + services_pkg = ModuleType("api.db.services") + services_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.db.services", services_pkg) + + user_service_mod = ModuleType("api.db.services.user_service") + + class _UserTenantService: + @staticmethod + def get_by_tenant_id(_tenant_id): + return [] + + @staticmethod + def query(**_kwargs): + return [] + + @staticmethod + def save(**_kwargs): + return True + + @staticmethod + def filter_delete(_conditions): + return True + + @staticmethod + def get_tenants_by_user_id(_user_id): + return [] + + @staticmethod + def filter_update(_conditions, _payload): + return True + + class _UserService: + @staticmethod + def query(**_kwargs): + return [] + + @staticmethod + def get_by_id(_user_id): + return False, None + + user_service_mod.UserTenantService = _UserTenantService + user_service_mod.UserService = _UserService + monkeypatch.setitem(sys.modules, "api.db.services.user_service", user_service_mod) + + api_utils_mod = ModuleType("api.utils.api_utils") + api_utils_mod.get_json_result = lambda data=None, message="", code=0: {"code": code, "message": message, "data": data} + api_utils_mod.get_data_error_result = lambda message="": {"code": 102, "message": message, "data": False} + api_utils_mod.server_error_response = lambda exc: {"code": 100, "message": repr(exc), "data": False} + api_utils_mod.validate_request = lambda *_args, **_kwargs: (lambda fn: fn) + api_utils_mod.get_request_json = lambda: _AwaitableValue({}) + monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod) + + web_utils_mod = ModuleType("api.utils.web_utils") + web_utils_mod.send_invite_email = lambda **_kwargs: {"ok": True} + monkeypatch.setitem(sys.modules, "api.utils.web_utils", web_utils_mod) + + common_pkg = ModuleType("common") + common_pkg.__path__ = [str(repo_root / "common")] + monkeypatch.setitem(sys.modules, "common", common_pkg) + + constants_mod = ModuleType("common.constants") + constants_mod.RetCode = SimpleNamespace(AUTHENTICATION_ERROR=401, SERVER_ERROR=500, DATA_ERROR=102) + constants_mod.StatusEnum = SimpleNamespace(VALID=SimpleNamespace(value=1)) + monkeypatch.setitem(sys.modules, "common.constants", constants_mod) + + misc_utils_mod = ModuleType("common.misc_utils") + misc_utils_mod.get_uuid = lambda: "uuid-1" + monkeypatch.setitem(sys.modules, "common.misc_utils", misc_utils_mod) + + time_utils_mod = ModuleType("common.time_utils") + time_utils_mod.delta_seconds = lambda _value: 0 + monkeypatch.setitem(sys.modules, "common.time_utils", time_utils_mod) + + settings_mod = ModuleType("common.settings") + settings_mod.MAIL_FRONTEND_URL = "https://frontend.example/invite" + monkeypatch.setitem(sys.modules, "common.settings", settings_mod) + common_pkg.settings = settings_mod + + sys.modules.pop("test_tenant_app_unit_module", None) + module_path = repo_root / "api" / "apps" / "tenant_app.py" + spec = importlib.util.spec_from_file_location("test_tenant_app_unit_module", module_path) + module = importlib.util.module_from_spec(spec) + module.manager = _DummyManager() + monkeypatch.setitem(sys.modules, "test_tenant_app_unit_module", module) + spec.loader.exec_module(module) + return module + + +@pytest.mark.p2 +def test_user_list_auth_success_exception_matrix_unit(monkeypatch): + module = _load_tenant_module(monkeypatch) + + module.current_user.id = "other-user" + res = module.user_list("tenant-1") + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + assert res["message"] == "No authorization.", res + + module.current_user.id = "tenant-1" + monkeypatch.setattr( + module.UserTenantService, + "get_by_tenant_id", + lambda _tenant_id: [{"id": "u1", "update_date": "2024-01-01 00:00:00"}], + ) + monkeypatch.setattr(module, "delta_seconds", lambda _value: 42) + res = module.user_list("tenant-1") + assert res["code"] == 0, res + assert res["data"][0]["delta_seconds"] == 42, res + + monkeypatch.setattr(module.UserTenantService, "get_by_tenant_id", lambda _tenant_id: (_ for _ in ()).throw(RuntimeError("list boom"))) + res = module.user_list("tenant-1") + assert res["code"] == 100, res + assert "list boom" in res["message"], res + + +@pytest.mark.p2 +def test_create_invite_role_and_email_failure_matrix_unit(monkeypatch): + module = _load_tenant_module(monkeypatch) + + module.current_user.id = "other-user" + _set_request_json(monkeypatch, module, {"email": "invitee@example.com"}) + res = _run(module.create("tenant-1")) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + assert res["message"] == "No authorization.", res + + module.current_user.id = "tenant-1" + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: []) + res = _run(module.create("tenant-1")) + assert res["message"] == "User not found.", res + + invitee = _Invitee() + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [invitee]) + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [SimpleNamespace(role=module.UserTenantRole.NORMAL)]) + res = _run(module.create("tenant-1")) + assert "already in the team." in res["message"], res + + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [SimpleNamespace(role=module.UserTenantRole.OWNER)]) + res = _run(module.create("tenant-1")) + assert "owner of the team." in res["message"], res + + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [SimpleNamespace(role="strange-role")]) + res = _run(module.create("tenant-1")) + assert "role: strange-role is invalid." in res["message"], res + + saved = [] + scheduled = [] + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: []) + monkeypatch.setattr(module.UserTenantService, "save", lambda **kwargs: saved.append(kwargs) or True) + monkeypatch.setattr(module.UserService, "get_by_id", lambda _user_id: (True, SimpleNamespace(nickname="Inviter Nick"))) + monkeypatch.setattr(module, "send_invite_email", lambda **kwargs: kwargs) + monkeypatch.setattr(module.asyncio, "create_task", lambda payload: scheduled.append(payload) or SimpleNamespace()) + res = _run(module.create("tenant-1")) + assert res["code"] == 0, res + assert saved and saved[-1]["role"] == module.UserTenantRole.INVITE, saved + assert scheduled and scheduled[-1]["inviter"] == "Inviter Nick", scheduled + assert sorted(res["data"].keys()) == ["avatar", "email", "id", "nickname"], res + + monkeypatch.setattr(module.asyncio, "create_task", lambda _payload: (_ for _ in ()).throw(RuntimeError("send boom"))) + res = _run(module.create("tenant-1")) + assert res["code"] == module.RetCode.SERVER_ERROR, res + assert "Failed to send invite email." in res["message"], res + + +@pytest.mark.p2 +def test_rm_and_tenant_list_matrix_unit(monkeypatch): + module = _load_tenant_module(monkeypatch) + + module.current_user.id = "outsider" + res = module.rm("tenant-1", "user-2") + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + assert res["message"] == "No authorization.", res + + module.current_user.id = "tenant-1" + deleted = [] + monkeypatch.setattr(module.UserTenantService, "filter_delete", lambda conditions: deleted.append(conditions) or True) + res = module.rm("tenant-1", "user-2") + assert res["code"] == 0, res + assert res["data"] is True, res + assert deleted, "filter_delete should be called" + + monkeypatch.setattr(module.UserTenantService, "filter_delete", lambda _conditions: (_ for _ in ()).throw(RuntimeError("rm boom"))) + res = module.rm("tenant-1", "user-2") + assert res["code"] == 100, res + assert "rm boom" in res["message"], res + + monkeypatch.setattr( + module.UserTenantService, + "get_tenants_by_user_id", + lambda _user_id: [{"id": "tenant-1", "update_date": "2024-01-01 00:00:00"}], + ) + monkeypatch.setattr(module, "delta_seconds", lambda _value: 9) + res = module.tenant_list() + assert res["code"] == 0, res + assert res["data"][0]["delta_seconds"] == 9, res + + monkeypatch.setattr(module.UserTenantService, "get_tenants_by_user_id", lambda _user_id: (_ for _ in ()).throw(RuntimeError("tenant boom"))) + res = module.tenant_list() + assert res["code"] == 100, res + assert "tenant boom" in res["message"], res + + +@pytest.mark.p2 +def test_agree_success_and_exception_unit(monkeypatch): + module = _load_tenant_module(monkeypatch) + + calls = [] + monkeypatch.setattr(module.UserTenantService, "filter_update", lambda conditions, payload: calls.append((conditions, payload)) or True) + res = module.agree("tenant-1") + assert res["code"] == 0, res + assert res["data"] is True, res + assert calls and calls[-1][1]["role"] == module.UserTenantRole.NORMAL + + monkeypatch.setattr(module.UserTenantService, "filter_update", lambda _conditions, _payload: (_ for _ in ()).throw(RuntimeError("agree boom"))) + res = module.agree("tenant-1") + assert res["code"] == 100, res + assert "agree boom" in res["message"], res diff --git a/test/testcases/test_web_api/test_user_app/test_user_app_unit.py b/test/testcases/test_web_api/test_user_app/test_user_app_unit.py new file mode 100644 index 0000000000..9ad71c7acb --- /dev/null +++ b/test/testcases/test_web_api/test_user_app/test_user_app_unit.py @@ -0,0 +1,1324 @@ +# +# 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 base64 +import importlib.util +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + + +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 _Args(dict): + def get(self, key, default=None, type=None): + value = super().get(key, default) + if type is None: + return value + try: + return type(value) + except (TypeError, ValueError): + return default + + +class _DummyResponse: + def __init__(self, data): + self.data = data + self.headers = {} + + +class _DummyHTTPResponse: + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + +class _DummyRedis: + def __init__(self): + self.store = {} + + def get(self, key): + return self.store.get(key) + + def set(self, key, value, _ttl=None): + self.store[key] = value + + def delete(self, key): + self.store.pop(key, None) + + +class _DummyUser: + def __init__(self, user_id, email, *, password="stored-password", is_active="1", nickname="nick"): + self.id = user_id + self.email = email + self.password = password + self.is_active = is_active + self.nickname = nickname + self.access_token = "" + self.save_calls = 0 + + def save(self): + self.save_calls += 1 + + def get_id(self): + return self.id + + def to_json(self): + return {"id": self.id, "email": self.email, "nickname": self.nickname} + + def to_dict(self): + return {"id": self.id, "email": self.email} + + +class _Field: + def __init__(self, name): + self.name = name + + def __eq__(self, other): + return (self.name, other) + + +def _run(coro): + return asyncio.run(coro) + + +def _set_request_json(monkeypatch, module, payload): + async def _request_json(): + return payload + + monkeypatch.setattr(module, "get_request_json", _request_json) + + +def _set_request_args(monkeypatch, module, args=None): + monkeypatch.setattr(module, "request", SimpleNamespace(args=_Args(args or {}))) + + +@pytest.fixture(scope="session") +def auth(): + return "unit-auth" + + +@pytest.fixture(scope="session", autouse=True) +def set_tenant_info(): + return None + + +def _load_user_app(monkeypatch): + repo_root = Path(__file__).resolve().parents[4] + + quart_mod = ModuleType("quart") + quart_mod.session = {} + quart_mod.request = SimpleNamespace(args=_Args({})) + + async def _make_response(data): + return _DummyResponse(data) + + quart_mod.make_response = _make_response + quart_mod.redirect = lambda url: {"redirect": url} + monkeypatch.setitem(sys.modules, "quart", quart_mod) + + api_pkg = ModuleType("api") + api_pkg.__path__ = [str(repo_root / "api")] + monkeypatch.setitem(sys.modules, "api", api_pkg) + + apps_mod = ModuleType("api.apps") + apps_mod.__path__ = [str(repo_root / "api" / "apps")] + apps_mod.current_user = _DummyUser("current-user", "current@example.com") + apps_mod.login_required = lambda fn: fn + apps_mod.login_user = lambda _user: True + apps_mod.logout_user = lambda: True + monkeypatch.setitem(sys.modules, "api.apps", apps_mod) + api_pkg.apps = apps_mod + + apps_auth_mod = ModuleType("api.apps.auth") + apps_auth_mod.get_auth_client = lambda _config: SimpleNamespace( + get_authorization_url=lambda state: f"https://oauth.example/{state}" + ) + monkeypatch.setitem(sys.modules, "api.apps.auth", apps_auth_mod) + + db_mod = ModuleType("api.db") + db_mod.FileType = SimpleNamespace(FOLDER=SimpleNamespace(value="folder")) + db_mod.UserTenantRole = SimpleNamespace(OWNER="owner") + monkeypatch.setitem(sys.modules, "api.db", db_mod) + api_pkg.db = db_mod + + db_models_mod = ModuleType("api.db.db_models") + + class _DummyTenantLLMModel: + tenant_id = _Field("tenant_id") + + @staticmethod + def delete(): + class _DeleteQuery: + def where(self, *_args, **_kwargs): + return self + + def execute(self): + return 1 + + return _DeleteQuery() + + db_models_mod.TenantLLM = _DummyTenantLLMModel + monkeypatch.setitem(sys.modules, "api.db.db_models", db_models_mod) + + services_pkg = ModuleType("api.db.services") + services_pkg.__path__ = [] + monkeypatch.setitem(sys.modules, "api.db.services", services_pkg) + + file_service_mod = ModuleType("api.db.services.file_service") + + class _StubFileService: + @staticmethod + def insert(_data): + return True + + file_service_mod.FileService = _StubFileService + monkeypatch.setitem(sys.modules, "api.db.services.file_service", file_service_mod) + + llm_service_mod = ModuleType("api.db.services.llm_service") + llm_service_mod.get_init_tenant_llm = lambda _user_id: [] + monkeypatch.setitem(sys.modules, "api.db.services.llm_service", llm_service_mod) + + tenant_llm_service_mod = ModuleType("api.db.services.tenant_llm_service") + + class _StubTenantLLMService: + @staticmethod + def insert_many(_payload): + return True + + tenant_llm_service_mod.TenantLLMService = _StubTenantLLMService + monkeypatch.setitem(sys.modules, "api.db.services.tenant_llm_service", tenant_llm_service_mod) + + user_service_mod = ModuleType("api.db.services.user_service") + + class _StubTenantService: + @staticmethod + def insert(**_kwargs): + return True + + @staticmethod + def delete_by_id(_tenant_id): + return True + + @staticmethod + def get_by_id(_tenant_id): + return True, SimpleNamespace(id=_tenant_id) + + @staticmethod + def get_info_by(_user_id): + return [] + + @staticmethod + def update_by_id(_tenant_id, _payload): + return True + + class _StubUserService: + @staticmethod + def query(**_kwargs): + return [] + + @staticmethod + def query_user(_email, _password): + return None + + @staticmethod + def query_user_by_email(**_kwargs): + return [] + + @staticmethod + def save(**_kwargs): + return True + + @staticmethod + def delete_by_id(_user_id): + return True + + @staticmethod + def update_by_id(_user_id, _payload): + return True + + @staticmethod + def update_user_password(_user_id, _new_password): + return True + + class _StubUserTenantService: + @staticmethod + def insert(**_kwargs): + return True + + @staticmethod + def query(**_kwargs): + return [] + + @staticmethod + def delete_by_id(_user_tenant_id): + return True + + user_service_mod.TenantService = _StubTenantService + user_service_mod.UserService = _StubUserService + user_service_mod.UserTenantService = _StubUserTenantService + monkeypatch.setitem(sys.modules, "api.db.services.user_service", user_service_mod) + + api_utils_mod = ModuleType("api.utils.api_utils") + + async def _default_request_json(): + return {} + + def _get_json_result(code=0, message="success", data=None): + return {"code": code, "message": message, "data": data} + + def _get_data_error_result(code=102, message="Sorry! Data missing!", data=None): + return {"code": code, "message": message, "data": data} + + def _server_error_response(error): + return {"code": 100, "message": repr(error)} + + def _validate_request(*_args, **_kwargs): + def _decorator(func): + return func + + return _decorator + + api_utils_mod.get_request_json = _default_request_json + api_utils_mod.get_json_result = _get_json_result + api_utils_mod.get_data_error_result = _get_data_error_result + api_utils_mod.server_error_response = _server_error_response + api_utils_mod.validate_request = _validate_request + monkeypatch.setitem(sys.modules, "api.utils.api_utils", api_utils_mod) + + crypt_mod = ModuleType("api.utils.crypt") + crypt_mod.decrypt = lambda value: value + monkeypatch.setitem(sys.modules, "api.utils.crypt", crypt_mod) + + web_utils_mod = ModuleType("api.utils.web_utils") + web_utils_mod.send_email_html = lambda *_args, **_kwargs: _AwaitableValue(True) + web_utils_mod.OTP_LENGTH = 6 + web_utils_mod.OTP_TTL_SECONDS = 600 + web_utils_mod.ATTEMPT_LIMIT = 5 + web_utils_mod.ATTEMPT_LOCK_SECONDS = 600 + web_utils_mod.RESEND_COOLDOWN_SECONDS = 60 + web_utils_mod.otp_keys = lambda email: ( + f"otp:{email}:code", + f"otp:{email}:attempts", + f"otp:{email}:last", + f"otp:{email}:lock", + ) + web_utils_mod.hash_code = lambda code, _salt: f"hash:{code}" + web_utils_mod.captcha_key = lambda email: f"captcha:{email}" + monkeypatch.setitem(sys.modules, "api.utils.web_utils", web_utils_mod) + + common_pkg = ModuleType("common") + common_pkg.__path__ = [str(repo_root / "common")] + monkeypatch.setitem(sys.modules, "common", common_pkg) + + settings_mod = ModuleType("common.settings") + settings_mod.OAUTH_CONFIG = { + "github": {"display_name": "GitHub", "icon": "gh"}, + "feishu": {"display_name": "Feishu", "icon": "fs"}, + } + settings_mod.GITHUB_OAUTH = {"url": "https://github.example/oauth", "client_id": "cid", "secret_key": "sk"} + settings_mod.FEISHU_OAUTH = { + "app_access_token_url": "https://feishu.example/app_token", + "user_access_token_url": "https://feishu.example/user_token", + "app_id": "app-id", + "app_secret": "app-secret", + "grant_type": "authorization_code", + } + settings_mod.CHAT_MDL = "chat-mdl" + settings_mod.EMBEDDING_MDL = "embd-mdl" + settings_mod.ASR_MDL = "asr-mdl" + settings_mod.PARSERS = [] + settings_mod.IMAGE2TEXT_MDL = "img-mdl" + settings_mod.RERANK_MDL = "rerank-mdl" + settings_mod.REGISTER_ENABLED = True + monkeypatch.setitem(sys.modules, "common.settings", settings_mod) + common_pkg.settings = settings_mod + + constants_mod = ModuleType("common.constants") + constants_mod.RetCode = SimpleNamespace( + AUTHENTICATION_ERROR=401, + SERVER_ERROR=500, + FORBIDDEN=403, + EXCEPTION_ERROR=100, + OPERATING_ERROR=300, + ARGUMENT_ERROR=101, + DATA_ERROR=102, + NOT_EFFECTIVE=103, + SUCCESS=0, + ) + monkeypatch.setitem(sys.modules, "common.constants", constants_mod) + + connection_utils_mod = ModuleType("common.connection_utils") + + async def _construct_response(data=None, auth=None, message=""): + return {"code": 0, "message": message, "data": data, "auth": auth} + + connection_utils_mod.construct_response = _construct_response + monkeypatch.setitem(sys.modules, "common.connection_utils", connection_utils_mod) + + time_utils_mod = ModuleType("common.time_utils") + time_utils_mod.current_timestamp = lambda: 111 + time_utils_mod.datetime_format = lambda _dt: "2024-01-01 00:00:00" + time_utils_mod.get_format_time = lambda: "2024-01-01 00:00:00" + monkeypatch.setitem(sys.modules, "common.time_utils", time_utils_mod) + + misc_utils_mod = ModuleType("common.misc_utils") + misc_utils_mod.download_img = lambda _url: "avatar" + misc_utils_mod.get_uuid = lambda: "uuid-default" + monkeypatch.setitem(sys.modules, "common.misc_utils", misc_utils_mod) + + http_client_mod = ModuleType("common.http_client") + + async def _async_request(_method, _url, **_kwargs): + return _DummyHTTPResponse({}) + + http_client_mod.async_request = _async_request + monkeypatch.setitem(sys.modules, "common.http_client", http_client_mod) + + rag_pkg = ModuleType("rag") + rag_pkg.__path__ = [str(repo_root / "rag")] + monkeypatch.setitem(sys.modules, "rag", rag_pkg) + + rag_utils_pkg = ModuleType("rag.utils") + rag_utils_pkg.__path__ = [str(repo_root / "rag" / "utils")] + monkeypatch.setitem(sys.modules, "rag.utils", rag_utils_pkg) + + redis_mod = ModuleType("rag.utils.redis_conn") + redis_mod.REDIS_CONN = _DummyRedis() + monkeypatch.setitem(sys.modules, "rag.utils.redis_conn", redis_mod) + + module_name = "test_user_app_unit_module" + module_path = repo_root / "api" / "apps" / "user_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 + + +@pytest.mark.p2 +def test_login_route_branch_matrix_unit(monkeypatch): + module = _load_user_app(monkeypatch) + + _set_request_json(monkeypatch, module, {}) + res = _run(module.login()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + assert "Unauthorized" in res["message"] + + _set_request_json(monkeypatch, module, {"email": "unknown@example.com", "password": "enc"}) + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: []) + res = _run(module.login()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + assert "not registered" in res["message"] + + _set_request_json(monkeypatch, module, {"email": "known@example.com", "password": "enc"}) + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [SimpleNamespace(email="known@example.com")]) + + def _raise_decrypt(_value): + raise RuntimeError("decrypt explode") + + monkeypatch.setattr(module, "decrypt", _raise_decrypt) + res = _run(module.login()) + assert res["code"] == module.RetCode.SERVER_ERROR + assert "Fail to crypt password" in res["message"] + + user_inactive = _DummyUser("u-inactive", "known@example.com", is_active="0") + monkeypatch.setattr(module, "decrypt", lambda value: value) + monkeypatch.setattr(module.UserService, "query_user", lambda _email, _password: user_inactive) + res = _run(module.login()) + assert res["code"] == module.RetCode.FORBIDDEN + assert "disabled" in res["message"] + + monkeypatch.setattr(module.UserService, "query_user", lambda _email, _password: None) + res = _run(module.login()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + assert "do not match" in res["message"] + + +@pytest.mark.p2 +def test_login_channels_and_oauth_login_matrix_unit(monkeypatch): + module = _load_user_app(monkeypatch) + + module.settings.OAUTH_CONFIG = {"github": {"display_name": "GitHub", "icon": "gh"}} + res = _run(module.get_login_channels()) + assert res["code"] == 0 + assert res["data"][0]["channel"] == "github" + + class _BrokenOAuthConfig: + @staticmethod + def items(): + raise RuntimeError("broken oauth config") + + module.settings.OAUTH_CONFIG = _BrokenOAuthConfig() + res = _run(module.get_login_channels()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "Load channels failure" in res["message"] + + module.settings.OAUTH_CONFIG = {"github": {"display_name": "GitHub", "icon": "gh"}} + with pytest.raises(ValueError, match="Invalid channel name: missing"): + _run(module.oauth_login("missing")) + + module.session.clear() + monkeypatch.setattr(module, "get_uuid", lambda: "state-123") + + class _AuthClient: + @staticmethod + def get_authorization_url(state): + return f"https://oauth.example/{state}" + + monkeypatch.setattr(module, "get_auth_client", lambda _config: _AuthClient()) + res = _run(module.oauth_login("github")) + assert res["redirect"] == "https://oauth.example/state-123" + assert module.session["oauth_state"] == "state-123" + + +@pytest.mark.p2 +def test_oauth_callback_matrix_unit(monkeypatch): + module = _load_user_app(monkeypatch) + module.settings.OAUTH_CONFIG = {"github": {"display_name": "GitHub", "icon": "gh"}} + + class _SyncAuthClient: + def __init__(self, token_info, user_info): + self._token_info = token_info + self._user_info = user_info + + def exchange_code_for_token(self, _code): + return self._token_info + + def fetch_user_info(self, _token, id_token=None): + _ = id_token + return self._user_info + + class _AsyncAuthClient: + def __init__(self, token_info, user_info): + self._token_info = token_info + self._user_info = user_info + + async def async_exchange_code_for_token(self, _code): + return self._token_info + + async def async_fetch_user_info(self, _token, id_token=None): + _ = id_token + return self._user_info + + _set_request_args(monkeypatch, module, {"state": "x", "code": "c"}) + module.session.clear() + res = _run(module.oauth_callback("missing")) + assert "Invalid channel name: missing" in res["redirect"] + + sync_ok = _SyncAuthClient( + token_info={"access_token": "token-sync", "id_token": "id-sync"}, + user_info=SimpleNamespace(email="sync@example.com", avatar_url="http://img", nickname="sync"), + ) + monkeypatch.setattr(module, "get_auth_client", lambda _config: sync_ok) + + module.session.clear() + module.session["oauth_state"] = "expected" + _set_request_args(monkeypatch, module, {"state": "wrong", "code": "code"}) + res = _run(module.oauth_callback("github")) + assert res["redirect"] == "/?error=invalid_state" + + module.session.clear() + module.session["oauth_state"] = "ok-state" + _set_request_args(monkeypatch, module, {"state": "ok-state"}) + res = _run(module.oauth_callback("github")) + assert res["redirect"] == "/?error=missing_code" + + sync_missing_token = _SyncAuthClient( + token_info={"id_token": "id-only"}, + user_info=SimpleNamespace(email="sync@example.com", avatar_url="http://img", nickname="sync"), + ) + monkeypatch.setattr(module, "get_auth_client", lambda _config: sync_missing_token) + module.session.clear() + module.session["oauth_state"] = "token-state" + _set_request_args(monkeypatch, module, {"state": "token-state", "code": "code"}) + res = _run(module.oauth_callback("github")) + assert res["redirect"] == "/?error=token_failed" + + sync_missing_email = _SyncAuthClient( + token_info={"access_token": "token-sync", "id_token": "id-sync"}, + user_info=SimpleNamespace(email=None, avatar_url="http://img", nickname="sync"), + ) + monkeypatch.setattr(module, "get_auth_client", lambda _config: sync_missing_email) + module.session.clear() + module.session["oauth_state"] = "email-state" + _set_request_args(monkeypatch, module, {"state": "email-state", "code": "code"}) + res = _run(module.oauth_callback("github")) + assert res["redirect"] == "/?error=email_missing" + + async_new_user = _AsyncAuthClient( + token_info={"access_token": "token-async", "id_token": "id-async"}, + user_info=SimpleNamespace(email="new@example.com", avatar_url="http://img", nickname="new-user"), + ) + monkeypatch.setattr(module, "get_auth_client", lambda _config: async_new_user) + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: []) + + def _raise_download(_url): + raise RuntimeError("download explode") + + monkeypatch.setattr(module, "download_img", _raise_download) + monkeypatch.setattr(module, "user_register", lambda _user_id, _user: None) + rollback_calls = [] + monkeypatch.setattr(module, "rollback_user_registration", lambda user_id: rollback_calls.append(user_id)) + monkeypatch.setattr(module, "get_uuid", lambda: "new-user-id") + module.session.clear() + module.session["oauth_state"] = "new-user-state" + _set_request_args(monkeypatch, module, {"state": "new-user-state", "code": "code"}) + res = _run(module.oauth_callback("github")) + assert "Failed to register new@example.com" in res["redirect"] + assert rollback_calls == ["new-user-id"] + + monkeypatch.setattr(module, "download_img", lambda _url: "avatar") + monkeypatch.setattr( + module, + "user_register", + lambda _user_id, _user: [_DummyUser("dup-1", "new@example.com"), _DummyUser("dup-2", "new@example.com")], + ) + rollback_calls.clear() + module.session.clear() + module.session["oauth_state"] = "dup-user-state" + _set_request_args(monkeypatch, module, {"state": "dup-user-state", "code": "code"}) + res = _run(module.oauth_callback("github")) + assert "Same email: new@example.com exists!" in res["redirect"] + assert rollback_calls == ["new-user-id"] + + new_user = _DummyUser("new-user", "new@example.com") + login_calls = [] + monkeypatch.setattr(module, "login_user", lambda user: login_calls.append(user)) + monkeypatch.setattr(module, "user_register", lambda _user_id, _user: [new_user]) + module.session.clear() + module.session["oauth_state"] = "create-user-state" + _set_request_args(monkeypatch, module, {"state": "create-user-state", "code": "code"}) + res = _run(module.oauth_callback("github")) + assert res["redirect"] == "/?auth=new-user" + assert login_calls and login_calls[-1] is new_user + + async_existing_inactive = _AsyncAuthClient( + token_info={"access_token": "token-existing", "id_token": "id-existing"}, + user_info=SimpleNamespace(email="existing@example.com", avatar_url="http://img", nickname="existing"), + ) + monkeypatch.setattr(module, "get_auth_client", lambda _config: async_existing_inactive) + inactive_user = _DummyUser("existing-user", "existing@example.com", is_active="0") + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [inactive_user]) + module.session.clear() + module.session["oauth_state"] = "inactive-state" + _set_request_args(monkeypatch, module, {"state": "inactive-state", "code": "code"}) + res = _run(module.oauth_callback("github")) + assert res["redirect"] == "/?error=user_inactive" + + async_existing_ok = _AsyncAuthClient( + token_info={"access_token": "token-existing", "id_token": "id-existing"}, + user_info=SimpleNamespace(email="existing@example.com", avatar_url="http://img", nickname="existing"), + ) + monkeypatch.setattr(module, "get_auth_client", lambda _config: async_existing_ok) + existing_user = _DummyUser("existing-user", "existing@example.com") + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [existing_user]) + login_calls.clear() + monkeypatch.setattr(module, "login_user", lambda user: login_calls.append(user)) + monkeypatch.setattr(module, "get_uuid", lambda: "existing-token") + module.session.clear() + module.session["oauth_state"] = "existing-state" + _set_request_args(monkeypatch, module, {"state": "existing-state", "code": "code"}) + res = _run(module.oauth_callback("github")) + assert res["redirect"] == "/?auth=existing-user" + assert existing_user.access_token == "existing-token" + assert existing_user.save_calls == 1 + assert login_calls and login_calls[-1] is existing_user + + +@pytest.mark.p2 +def test_github_callback_matrix_unit(monkeypatch): + module = _load_user_app(monkeypatch) + + _set_request_args(monkeypatch, module, {"code": "code"}) + module.session.clear() + + async def _request_error(_method, _url, **_kwargs): + return _DummyHTTPResponse({"error": "bad", "error_description": "boom"}) + + monkeypatch.setattr(module, "async_request", _request_error) + res = _run(module.github_callback()) + assert res["redirect"] == "/?error=boom" + + async def _request_scope_missing(_method, _url, **_kwargs): + return _DummyHTTPResponse({"scope": "repo", "access_token": "token-gh"}) + + monkeypatch.setattr(module, "async_request", _request_scope_missing) + res = _run(module.github_callback()) + assert res["redirect"] == "/?error=user:email not in scope" + + async def _request_token(_method, _url, **_kwargs): + return _DummyHTTPResponse({"scope": "user:email,repo", "access_token": "token-gh"}) + + monkeypatch.setattr(module, "async_request", _request_token) + monkeypatch.setattr( + module, + "user_info_from_github", + lambda _token: _AwaitableValue({"email": "gh@example.com", "avatar_url": "http://img", "login": "gh-user"}), + ) + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: []) + rollback_calls = [] + monkeypatch.setattr(module, "rollback_user_registration", lambda user_id: rollback_calls.append(user_id)) + monkeypatch.setattr(module, "get_uuid", lambda: "gh-user-id") + + def _raise_download(_url): + raise RuntimeError("download explode") + + monkeypatch.setattr(module, "download_img", _raise_download) + monkeypatch.setattr(module, "user_register", lambda _user_id, _user: None) + res = _run(module.github_callback()) + assert "Fail to register gh@example.com." in res["redirect"] + assert rollback_calls == ["gh-user-id"] + + monkeypatch.setattr(module, "download_img", lambda _url: "avatar") + monkeypatch.setattr( + module, + "user_register", + lambda _user_id, _user: [_DummyUser("dup-1", "gh@example.com"), _DummyUser("dup-2", "gh@example.com")], + ) + rollback_calls.clear() + res = _run(module.github_callback()) + assert "Same email: gh@example.com exists!" in res["redirect"] + assert rollback_calls == ["gh-user-id"] + + new_user = _DummyUser("gh-new-user", "gh@example.com") + login_calls = [] + monkeypatch.setattr(module, "login_user", lambda user: login_calls.append(user)) + monkeypatch.setattr(module, "user_register", lambda _user_id, _user: [new_user]) + res = _run(module.github_callback()) + assert res["redirect"] == "/?auth=gh-new-user" + assert login_calls and login_calls[-1] is new_user + + inactive_user = _DummyUser("gh-existing", "gh@example.com", is_active="0") + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [inactive_user]) + res = _run(module.github_callback()) + assert res["redirect"] == "/?error=user_inactive" + + existing_user = _DummyUser("gh-existing", "gh@example.com") + login_calls.clear() + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [existing_user]) + monkeypatch.setattr(module, "login_user", lambda user: login_calls.append(user)) + monkeypatch.setattr(module, "get_uuid", lambda: "gh-existing-token") + res = _run(module.github_callback()) + assert res["redirect"] == "/?auth=gh-existing" + assert existing_user.access_token == "gh-existing-token" + assert existing_user.save_calls == 1 + assert login_calls and login_calls[-1] is existing_user + + +@pytest.mark.p2 +def test_feishu_callback_matrix_unit(monkeypatch): + module = _load_user_app(monkeypatch) + + _set_request_args(monkeypatch, module, {"code": "code"}) + module.session.clear() + + def _patch_async_queue(payloads): + queue = list(payloads) + + async def _request(_method, _url, **_kwargs): + return _DummyHTTPResponse(queue.pop(0)) + + monkeypatch.setattr(module, "async_request", _request) + + _patch_async_queue([{"code": 1}]) + res = _run(module.feishu_callback()) + assert "/?error=" in res["redirect"] + + _patch_async_queue( + [ + {"code": 0, "app_access_token": "app-token"}, + {"code": 1, "message": "bad token"}, + ] + ) + res = _run(module.feishu_callback()) + assert res["redirect"] == "/?error=bad token" + + _patch_async_queue( + [ + {"code": 0, "app_access_token": "app-token"}, + {"code": 0, "data": {"scope": "other", "access_token": "feishu-access"}}, + ] + ) + res = _run(module.feishu_callback()) + assert "contact:user.email:readonly not in scope" in res["redirect"] + + _patch_async_queue( + [ + {"code": 0, "app_access_token": "app-token"}, + {"code": 0, "data": {"scope": "contact:user.email:readonly", "access_token": "feishu-access"}}, + ] + ) + monkeypatch.setattr( + module, + "user_info_from_feishu", + lambda _token: _AwaitableValue({"email": "fs@example.com", "avatar_url": "http://img", "en_name": "fs-user"}), + ) + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: []) + rollback_calls = [] + monkeypatch.setattr(module, "rollback_user_registration", lambda user_id: rollback_calls.append(user_id)) + monkeypatch.setattr(module, "get_uuid", lambda: "fs-user-id") + + def _raise_download(_url): + raise RuntimeError("download explode") + + monkeypatch.setattr(module, "download_img", _raise_download) + monkeypatch.setattr(module, "user_register", lambda _user_id, _user: None) + res = _run(module.feishu_callback()) + assert "Fail to register fs@example.com." in res["redirect"] + assert rollback_calls == ["fs-user-id"] + + _patch_async_queue( + [ + {"code": 0, "app_access_token": "app-token"}, + {"code": 0, "data": {"scope": "contact:user.email:readonly", "access_token": "feishu-access"}}, + ] + ) + monkeypatch.setattr(module, "download_img", lambda _url: "avatar") + monkeypatch.setattr( + module, + "user_register", + lambda _user_id, _user: [_DummyUser("dup-1", "fs@example.com"), _DummyUser("dup-2", "fs@example.com")], + ) + rollback_calls.clear() + res = _run(module.feishu_callback()) + assert "Same email: fs@example.com exists!" in res["redirect"] + assert rollback_calls == ["fs-user-id"] + + _patch_async_queue( + [ + {"code": 0, "app_access_token": "app-token"}, + {"code": 0, "data": {"scope": "contact:user.email:readonly", "access_token": "feishu-access"}}, + ] + ) + new_user = _DummyUser("fs-new-user", "fs@example.com") + login_calls = [] + monkeypatch.setattr(module, "login_user", lambda user: login_calls.append(user)) + monkeypatch.setattr(module, "user_register", lambda _user_id, _user: [new_user]) + res = _run(module.feishu_callback()) + assert res["redirect"] == "/?auth=fs-new-user" + assert login_calls and login_calls[-1] is new_user + + _patch_async_queue( + [ + {"code": 0, "app_access_token": "app-token"}, + {"code": 0, "data": {"scope": "contact:user.email:readonly", "access_token": "feishu-access"}}, + ] + ) + inactive_user = _DummyUser("fs-existing", "fs@example.com", is_active="0") + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [inactive_user]) + res = _run(module.feishu_callback()) + assert res["redirect"] == "/?error=user_inactive" + + _patch_async_queue( + [ + {"code": 0, "app_access_token": "app-token"}, + {"code": 0, "data": {"scope": "contact:user.email:readonly", "access_token": "feishu-access"}}, + ] + ) + existing_user = _DummyUser("fs-existing", "fs@example.com") + login_calls.clear() + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [existing_user]) + monkeypatch.setattr(module, "login_user", lambda user: login_calls.append(user)) + monkeypatch.setattr(module, "get_uuid", lambda: "fs-existing-token") + res = _run(module.feishu_callback()) + assert res["redirect"] == "/?auth=fs-existing" + assert existing_user.access_token == "fs-existing-token" + assert existing_user.save_calls == 1 + assert login_calls and login_calls[-1] is existing_user + + +@pytest.mark.p2 +def test_oauth_user_info_helpers_unit(monkeypatch): + module = _load_user_app(monkeypatch) + + async def _request_feishu(_method, _url, **_kwargs): + return _DummyHTTPResponse({"data": {"email": "", "en_name": "Feishu User"}}) + + monkeypatch.setattr(module, "async_request", _request_feishu) + feishu_user = _run(module.user_info_from_feishu("token-feishu")) + assert feishu_user["email"] is None + assert feishu_user["en_name"] == "Feishu User" + + async def _request_github(_method, url, **_kwargs): + if "emails" in url: + return _DummyHTTPResponse( + [ + {"email": "secondary@example.com", "primary": False}, + {"email": "primary@example.com", "primary": True}, + ] + ) + return _DummyHTTPResponse({"login": "gh-user"}) + + monkeypatch.setattr(module, "async_request", _request_github) + github_user = _run(module.user_info_from_github("token-github")) + assert github_user["login"] == "gh-user" + assert github_user["email"] == "primary@example.com" + + +@pytest.mark.p2 +def test_logout_setting_profile_matrix_unit(monkeypatch): + module = _load_user_app(monkeypatch) + + current_user = _DummyUser("current-user", "current@example.com", password="stored-password") + monkeypatch.setattr(module, "current_user", current_user) + monkeypatch.setattr(module.secrets, "token_hex", lambda _n: "abcdef") + logout_calls = [] + monkeypatch.setattr(module, "logout_user", lambda: logout_calls.append(True)) + + res = _run(module.log_out()) + assert res["code"] == 0 + assert current_user.access_token == "INVALID_abcdef" + assert current_user.save_calls == 1 + assert logout_calls == [True] + + _set_request_json(monkeypatch, module, {"password": "old-password", "new_password": "new-password"}) + monkeypatch.setattr(module, "decrypt", lambda value: value) + monkeypatch.setattr(module, "check_password_hash", lambda _hashed, _plain: False) + res = _run(module.setting_user()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR + assert "Password error" in res["message"] + + _set_request_json( + monkeypatch, + module, + { + "password": "old-password", + "new_password": "new-password", + "nickname": "neo", + "email": "blocked@example.com", + "status": "disabled", + "theme": "dark", + }, + ) + monkeypatch.setattr(module, "check_password_hash", lambda _hashed, _plain: True) + monkeypatch.setattr(module, "decrypt", lambda value: f"dec:{value}") + monkeypatch.setattr(module, "generate_password_hash", lambda value: f"hash:{value}") + update_calls = {} + + def _update_by_id(user_id, payload): + update_calls["user_id"] = user_id + update_calls["payload"] = payload + return True + + monkeypatch.setattr(module.UserService, "update_by_id", _update_by_id) + res = _run(module.setting_user()) + assert res["code"] == 0 + assert res["data"] is True + assert update_calls["user_id"] == "current-user" + assert update_calls["payload"]["password"] == "hash:dec:new-password" + assert update_calls["payload"]["nickname"] == "neo" + assert update_calls["payload"]["theme"] == "dark" + assert "email" not in update_calls["payload"] + assert "status" not in update_calls["payload"] + + _set_request_json(monkeypatch, module, {"nickname": "neo"}) + + def _raise_update(_user_id, _payload): + raise RuntimeError("update explode") + + monkeypatch.setattr(module.UserService, "update_by_id", _raise_update) + res = _run(module.setting_user()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR + assert "Update failure" in res["message"] + + res = _run(module.user_profile()) + assert res["code"] == 0 + assert res["data"] == current_user.to_dict() + + +@pytest.mark.p2 +def test_registration_helpers_and_register_route_matrix_unit(monkeypatch): + module = _load_user_app(monkeypatch) + + deleted = {"user": 0, "tenant": 0, "user_tenant": 0, "tenant_llm": 0} + monkeypatch.setattr(module.UserService, "delete_by_id", lambda _user_id: deleted.__setitem__("user", deleted["user"] + 1)) + monkeypatch.setattr(module.TenantService, "delete_by_id", lambda _tenant_id: deleted.__setitem__("tenant", deleted["tenant"] + 1)) + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: [SimpleNamespace(id="ut-1")]) + monkeypatch.setattr(module.UserTenantService, "delete_by_id", lambda _ut_id: deleted.__setitem__("user_tenant", deleted["user_tenant"] + 1)) + + class _DeleteQuery: + def where(self, *_args, **_kwargs): + return self + + def execute(self): + deleted["tenant_llm"] += 1 + return 1 + + monkeypatch.setattr(module.TenantLLM, "delete", lambda: _DeleteQuery()) + module.rollback_user_registration("user-1") + assert deleted == {"user": 1, "tenant": 1, "user_tenant": 1, "tenant_llm": 1}, deleted + + monkeypatch.setattr(module.UserService, "delete_by_id", lambda _user_id: (_ for _ in ()).throw(RuntimeError("u boom"))) + monkeypatch.setattr(module.TenantService, "delete_by_id", lambda _tenant_id: (_ for _ in ()).throw(RuntimeError("t boom"))) + monkeypatch.setattr(module.UserTenantService, "query", lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("ut boom"))) + + class _RaisingDeleteQuery: + def where(self, *_args, **_kwargs): + raise RuntimeError("llm boom") + + monkeypatch.setattr(module.TenantLLM, "delete", lambda: _RaisingDeleteQuery()) + module.rollback_user_registration("user-2") + + monkeypatch.setattr(module.UserService, "save", lambda **_kwargs: False) + res = module.user_register( + "new-user", + { + "nickname": "new", + "email": "new@example.com", + "password": "pw", + "access_token": "tk", + "login_channel": "password", + "last_login_time": "2024-01-01 00:00:00", + "is_superuser": False, + }, + ) + assert res is None + + monkeypatch.setattr(module.settings, "REGISTER_ENABLED", False) + _set_request_json(monkeypatch, module, {"nickname": "neo", "email": "neo@example.com", "password": "enc"}) + res = _run(module.user_add()) + assert res["code"] == module.RetCode.OPERATING_ERROR, res + assert "disabled" in res["message"], res + + monkeypatch.setattr(module.settings, "REGISTER_ENABLED", True) + _set_request_json(monkeypatch, module, {"nickname": "neo", "email": "bad-email", "password": "enc"}) + res = _run(module.user_add()) + assert res["code"] == module.RetCode.OPERATING_ERROR, res + assert "Invalid email address" in res["message"], res + + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: []) + monkeypatch.setattr(module, "decrypt", lambda value: value) + monkeypatch.setattr(module, "get_uuid", lambda: "new-user-id") + rollback_calls = [] + monkeypatch.setattr(module, "rollback_user_registration", lambda user_id: rollback_calls.append(user_id)) + + _set_request_json(monkeypatch, module, {"nickname": "neo", "email": "neo@example.com", "password": "enc"}) + monkeypatch.setattr(module, "user_register", lambda _user_id, _payload: None) + res = _run(module.user_add()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR, res + assert "Fail to register neo@example.com." in res["message"], res + assert rollback_calls == ["new-user-id"], rollback_calls + + rollback_calls.clear() + monkeypatch.setattr( + module, + "user_register", + lambda _user_id, _payload: [_DummyUser("dup-1", "neo@example.com"), _DummyUser("dup-2", "neo@example.com")], + ) + _set_request_json(monkeypatch, module, {"nickname": "neo", "email": "neo@example.com", "password": "enc"}) + res = _run(module.user_add()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR, res + assert "Same email: neo@example.com exists!" in res["message"], res + assert rollback_calls == ["new-user-id"], rollback_calls + + +@pytest.mark.p2 +def test_tenant_info_and_set_tenant_info_exception_matrix_unit(monkeypatch): + module = _load_user_app(monkeypatch) + + monkeypatch.setattr(module.TenantService, "get_info_by", lambda _uid: []) + res = _run(module.tenant_info()) + assert res["code"] == module.RetCode.DATA_ERROR, res + assert "Tenant not found" in res["message"], res + + def _raise_tenant_info(_uid): + raise RuntimeError("tenant info boom") + + monkeypatch.setattr(module.TenantService, "get_info_by", _raise_tenant_info) + res = _run(module.tenant_info()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR, res + assert "tenant info boom" in res["message"], res + + _set_request_json( + monkeypatch, + module, + {"tenant_id": "tenant-1", "llm_id": "l", "embd_id": "e", "asr_id": "a", "img2txt_id": "i"}, + ) + + def _raise_update(_tenant_id, _payload): + raise RuntimeError("tenant update boom") + + monkeypatch.setattr(module.TenantService, "update_by_id", _raise_update) + res = _run(module.set_tenant_info()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR, res + assert "tenant update boom" in res["message"], res + + +@pytest.mark.p2 +def test_forget_captcha_and_send_otp_matrix_unit(monkeypatch): + module = _load_user_app(monkeypatch) + + class _Headers(dict): + def set(self, key, value): + self[key] = value + + async def _make_response(data): + return SimpleNamespace(data=data, headers=_Headers()) + + monkeypatch.setattr(module, "make_response", _make_response) + + captcha_pkg = ModuleType("captcha") + captcha_image_mod = ModuleType("captcha.image") + + class _ImageCaptcha: + def __init__(self, **_kwargs): + pass + + def generate(self, text): + return SimpleNamespace(read=lambda: f"img:{text}".encode()) + + captcha_image_mod.ImageCaptcha = _ImageCaptcha + monkeypatch.setitem(sys.modules, "captcha", captcha_pkg) + monkeypatch.setitem(sys.modules, "captcha.image", captcha_image_mod) + + _set_request_args(monkeypatch, module, {"email": ""}) + res = _run(module.forget_get_captcha()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR, res + + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: []) + _set_request_args(monkeypatch, module, {"email": "nobody@example.com"}) + res = _run(module.forget_get_captcha()) + assert res["code"] == module.RetCode.DATA_ERROR, res + + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [_DummyUser("u1", "ok@example.com")]) + monkeypatch.setattr(module.secrets, "choice", lambda _allowed: "A") + _set_request_args(monkeypatch, module, {"email": "ok@example.com"}) + res = _run(module.forget_get_captcha()) + assert res.data.startswith(b"img:"), res + assert res.headers["Content-Type"] == "image/JPEG", res.headers + assert module.REDIS_CONN.get(module.captcha_key("ok@example.com")), module.REDIS_CONN.store + + _set_request_json(monkeypatch, module, {"email": "", "captcha": ""}) + res = _run(module.forget_send_otp()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR, res + + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: []) + _set_request_json(monkeypatch, module, {"email": "none@example.com", "captcha": "AAAA"}) + res = _run(module.forget_send_otp()) + assert res["code"] == module.RetCode.DATA_ERROR, res + + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [_DummyUser("u1", "ok@example.com")]) + _set_request_json(monkeypatch, module, {"email": "ok@example.com", "captcha": "AAAA"}) + module.REDIS_CONN.store.pop(module.captcha_key("ok@example.com"), None) + res = _run(module.forget_send_otp()) + assert res["code"] == module.RetCode.NOT_EFFECTIVE, res + + module.REDIS_CONN.store[module.captcha_key("ok@example.com")] = "ABCD" + _set_request_json(monkeypatch, module, {"email": "ok@example.com", "captcha": "ZZZZ"}) + res = _run(module.forget_send_otp()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + + monkeypatch.setattr(module.time, "time", lambda: 1000) + k_code, k_attempts, k_last, k_lock = module.otp_keys("ok@example.com") + module.REDIS_CONN.store[module.captcha_key("ok@example.com")] = "ABCD" + module.REDIS_CONN.store[k_last] = "990" + _set_request_json(monkeypatch, module, {"email": "ok@example.com", "captcha": "ABCD"}) + res = _run(module.forget_send_otp()) + assert res["code"] == module.RetCode.NOT_EFFECTIVE, res + assert "wait" in res["message"], res + + module.REDIS_CONN.store[module.captcha_key("ok@example.com")] = "ABCD" + module.REDIS_CONN.store[k_last] = "bad-timestamp" + monkeypatch.setattr(module.secrets, "choice", lambda _allowed: "B") + monkeypatch.setattr(module.os, "urandom", lambda _n: b"\x00" * 16) + monkeypatch.setattr(module, "hash_code", lambda code, _salt: f"HASH_{code}") + + async def _raise_send_email(*_args, **_kwargs): + raise RuntimeError("send email boom") + + monkeypatch.setattr(module, "send_email_html", _raise_send_email) + _set_request_json(monkeypatch, module, {"email": "ok@example.com", "captcha": "ABCD"}) + res = _run(module.forget_send_otp()) + assert res["code"] == module.RetCode.SERVER_ERROR, res + assert "failed to send email" in res["message"], res + + async def _ok_send_email(*_args, **_kwargs): + return True + + module.REDIS_CONN.store[module.captcha_key("ok@example.com")] = "ABCD" + module.REDIS_CONN.store.pop(k_last, None) + monkeypatch.setattr(module, "send_email_html", _ok_send_email) + _set_request_json(monkeypatch, module, {"email": "ok@example.com", "captcha": "ABCD"}) + res = _run(module.forget_send_otp()) + assert res["code"] == module.RetCode.SUCCESS, res + assert res["data"] is True, res + assert module.REDIS_CONN.get(k_code), module.REDIS_CONN.store + assert module.REDIS_CONN.get(k_attempts) == 0, module.REDIS_CONN.store + assert module.REDIS_CONN.get(k_lock) is None, module.REDIS_CONN.store + + +@pytest.mark.p2 +def test_forget_verify_otp_matrix_unit(monkeypatch): + module = _load_user_app(monkeypatch) + email = "ok@example.com" + k_code, k_attempts, k_last, k_lock = module.otp_keys(email) + salt = b"\x01" * 16 + monkeypatch.setattr(module, "hash_code", lambda code, _salt: f"HASH_{code}") + + _set_request_json(monkeypatch, module, {}) + res = _run(module.forget_verify_otp()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR, res + + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: []) + _set_request_json(monkeypatch, module, {"email": email, "otp": "ABCDEF"}) + res = _run(module.forget_verify_otp()) + assert res["code"] == module.RetCode.DATA_ERROR, res + + monkeypatch.setattr(module.UserService, "query", lambda **_kwargs: [_DummyUser("u1", email)]) + module.REDIS_CONN.store[k_lock] = "1" + _set_request_json(monkeypatch, module, {"email": email, "otp": "ABCDEF"}) + res = _run(module.forget_verify_otp()) + assert res["code"] == module.RetCode.NOT_EFFECTIVE, res + module.REDIS_CONN.store.pop(k_lock, None) + + module.REDIS_CONN.store.pop(k_code, None) + _set_request_json(monkeypatch, module, {"email": email, "otp": "ABCDEF"}) + res = _run(module.forget_verify_otp()) + assert res["code"] == module.RetCode.NOT_EFFECTIVE, res + + module.REDIS_CONN.store[k_code] = "broken" + _set_request_json(monkeypatch, module, {"email": email, "otp": "ABCDEF"}) + res = _run(module.forget_verify_otp()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR, res + + module.REDIS_CONN.store[k_code] = f"HASH_CORRECT:{salt.hex()}" + module.REDIS_CONN.store[k_attempts] = "bad-int" + _set_request_json(monkeypatch, module, {"email": email, "otp": "wrong"}) + res = _run(module.forget_verify_otp()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + assert module.REDIS_CONN.get(k_attempts) == 1, module.REDIS_CONN.store + + module.REDIS_CONN.store[k_code] = f"HASH_CORRECT:{salt.hex()}" + module.REDIS_CONN.store[k_attempts] = str(module.ATTEMPT_LIMIT - 1) + _set_request_json(monkeypatch, module, {"email": email, "otp": "wrong"}) + res = _run(module.forget_verify_otp()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + assert module.REDIS_CONN.get(k_lock) is not None, module.REDIS_CONN.store + module.REDIS_CONN.store.pop(k_lock, None) + + module.REDIS_CONN.store[k_code] = f"HASH_ABCDEF:{salt.hex()}" + module.REDIS_CONN.store[k_attempts] = "0" + module.REDIS_CONN.store[k_last] = "1000" + + def _set_with_verified_fail(key, value, _ttl=None): + if key == module._verified_key(email): + raise RuntimeError("verified set boom") + module.REDIS_CONN.store[key] = value + + monkeypatch.setattr(module.REDIS_CONN, "set", _set_with_verified_fail) + _set_request_json(monkeypatch, module, {"email": email, "otp": "abcdef"}) + res = _run(module.forget_verify_otp()) + assert res["code"] == module.RetCode.SERVER_ERROR, res + + monkeypatch.setattr(module.REDIS_CONN, "set", lambda key, value, _ttl=None: module.REDIS_CONN.store.__setitem__(key, value)) + module.REDIS_CONN.store[k_code] = f"HASH_ABCDEF:{salt.hex()}" + module.REDIS_CONN.store[k_attempts] = "0" + module.REDIS_CONN.store[k_last] = "1000" + _set_request_json(monkeypatch, module, {"email": email, "otp": "abcdef"}) + res = _run(module.forget_verify_otp()) + assert res["code"] == module.RetCode.SUCCESS, res + assert module.REDIS_CONN.get(k_code) is None, module.REDIS_CONN.store + assert module.REDIS_CONN.get(k_attempts) is None, module.REDIS_CONN.store + assert module.REDIS_CONN.get(k_last) is None, module.REDIS_CONN.store + assert module.REDIS_CONN.get(k_lock) is None, module.REDIS_CONN.store + assert module.REDIS_CONN.get(module._verified_key(email)) == "1", module.REDIS_CONN.store + + +@pytest.mark.p2 +def test_forget_reset_password_matrix_unit(monkeypatch): + module = _load_user_app(monkeypatch) + email = "reset@example.com" + v_key = module._verified_key(email) + user = _DummyUser("u-reset", email, nickname="reset-user") + pwd_a = base64.b64encode(b"new-password").decode() + pwd_b = base64.b64encode(b"confirm-password").decode() + pwd_same = base64.b64encode(b"same-password").decode() + monkeypatch.setattr(module, "decrypt", lambda value: value) + + _set_request_json(monkeypatch, module, {"email": email, "new_password": pwd_same, "confirm_new_password": pwd_same}) + module.REDIS_CONN.store.pop(v_key, None) + res = _run(module.forget_reset_password()) + assert res["code"] == module.RetCode.AUTHENTICATION_ERROR, res + + module.REDIS_CONN.store[v_key] = "1" + monkeypatch.setattr(module, "decrypt", lambda _value: "") + _set_request_json(monkeypatch, module, {"email": email, "new_password": "", "confirm_new_password": ""}) + res = _run(module.forget_reset_password()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR, res + + monkeypatch.setattr(module, "decrypt", lambda value: value) + module.REDIS_CONN.store[v_key] = "1" + _set_request_json(monkeypatch, module, {"email": email, "new_password": pwd_a, "confirm_new_password": pwd_b}) + res = _run(module.forget_reset_password()) + assert res["code"] == module.RetCode.ARGUMENT_ERROR, res + assert "do not match" in res["message"], res + + module.REDIS_CONN.store[v_key] = "1" + monkeypatch.setattr(module.UserService, "query_user_by_email", lambda **_kwargs: []) + _set_request_json(monkeypatch, module, {"email": email, "new_password": pwd_same, "confirm_new_password": pwd_same}) + res = _run(module.forget_reset_password()) + assert res["code"] == module.RetCode.DATA_ERROR, res + + module.REDIS_CONN.store[v_key] = "1" + monkeypatch.setattr(module.UserService, "query_user_by_email", lambda **_kwargs: [user]) + + def _raise_update_password(_user_id, _new_pwd): + raise RuntimeError("reset boom") + + monkeypatch.setattr(module.UserService, "update_user_password", _raise_update_password) + _set_request_json(monkeypatch, module, {"email": email, "new_password": pwd_same, "confirm_new_password": pwd_same}) + res = _run(module.forget_reset_password()) + assert res["code"] == module.RetCode.EXCEPTION_ERROR, res + + module.REDIS_CONN.store[v_key] = "1" + monkeypatch.setattr(module.UserService, "update_user_password", lambda _user_id, _new_pwd: True) + monkeypatch.setattr(module.REDIS_CONN, "delete", lambda _key: (_ for _ in ()).throw(RuntimeError("delete boom"))) + _set_request_json(monkeypatch, module, {"email": email, "new_password": pwd_same, "confirm_new_password": pwd_same}) + res = _run(module.forget_reset_password()) + assert res["code"] == module.RetCode.SUCCESS, res + assert res["auth"] == user.get_id(), res + + monkeypatch.setattr(module.REDIS_CONN, "delete", lambda key: module.REDIS_CONN.store.pop(key, None)) + module.REDIS_CONN.store[v_key] = "1" + _set_request_json(monkeypatch, module, {"email": email, "new_password": pwd_same, "confirm_new_password": pwd_same}) + res = _run(module.forget_reset_password()) + assert res["code"] == module.RetCode.SUCCESS, res + assert res["auth"] == user.get_id(), res + assert module.REDIS_CONN.get(v_key) is None, module.REDIS_CONN.store