diff --git a/backend/apps/aidp_app.py b/backend/apps/aidp_app.py index eae9cb678..49f7006f9 100644 --- a/backend/apps/aidp_app.py +++ b/backend/apps/aidp_app.py @@ -11,7 +11,10 @@ from consts.error_code import ErrorCode from consts.exceptions import AppException -from services.aidp_service import fetch_aidp_knowledge_bases_impl +from services.aidp_service import ( + fetch_aidp_knowledge_bases_impl, + fetch_all_aidp_knowledge_bases_impl, +) router = APIRouter(prefix="/aidp") logger = logging.getLogger("aidp_app") @@ -22,9 +25,9 @@ async def fetch_aidp_knowledge_bases_api( server_url: Annotated[str, Query(description="AIDP API server URL")], api_key: Annotated[str, Query(description="AIDP API key")], page: Annotated[int, Query(ge=1, description="Page number starting from 1")] = 1, - page_size: Annotated[int, Query(ge=1, le=100, description="Page size from 1 to 100")] = 20, + page_size: Annotated[int, Query(ge=1, le=100, description="Page size from 1 to 100")] = 10, ) -> JSONResponse: - """Fetch paginated knowledge bases from the external AIDP API.""" + """Fetch a single page of knowledge bases from the external AIDP API.""" try: result = fetch_aidp_knowledge_bases_impl( server_url=server_url, @@ -41,3 +44,29 @@ async def fetch_aidp_knowledge_bases_api( ErrorCode.AIDP_SERVICE_ERROR, f"Failed to fetch AIDP knowledge bases: {str(e)}", ) + + +@router.get("/knowledge-bases-all") +async def fetch_all_aidp_knowledge_bases_api( + server_url: Annotated[str, Query(description="AIDP API server URL")], + api_key: Annotated[str, Query(description="AIDP API key")], +) -> JSONResponse: + """Fetch ALL knowledge bases from AIDP (accumulates every page internally). + + Use this when you need the total count and want to handle pagination + entirely on the client side. + """ + try: + result = fetch_all_aidp_knowledge_bases_impl( + server_url=server_url, + api_key=api_key, + ) + return JSONResponse(status_code=HTTPStatus.OK, content=result) + except AppException: + raise + except Exception as e: + logger.exception("Failed to fetch all AIDP knowledge bases: %s", e) + raise AppException( + ErrorCode.AIDP_SERVICE_ERROR, + f"Failed to fetch all AIDP knowledge bases: {str(e)}", + ) diff --git a/backend/services/aidp_service.py b/backend/services/aidp_service.py index acb18142e..d92f770c6 100644 --- a/backend/services/aidp_service.py +++ b/backend/services/aidp_service.py @@ -3,7 +3,7 @@ Handles API calls to AIDP for paginated knowledge base listing. """ import logging -from typing import Any, Dict +from typing import Any, Dict, List from urllib.parse import urljoin import httpx @@ -41,9 +41,9 @@ def fetch_aidp_knowledge_bases_impl( server_url: str, api_key: str, page: int = 1, - page_size: int = 20, + page_size: int = 10, ) -> Dict[str, Any]: - """Fetch paginated knowledge bases from AIDP API.""" + """Fetch a single page from AIDP API (simple passthrough).""" normalized_url = _validate_params(server_url, api_key) headers = { @@ -58,8 +58,8 @@ def fetch_aidp_knowledge_bases_impl( try: client = http_client_manager.get_sync_client( base_url=normalized_url, - timeout=20.0, - verify_ssl=True, + timeout=60.0, + verify_ssl=False, ) response = client.get(list_url, headers=headers) response.raise_for_status() @@ -69,7 +69,157 @@ def fetch_aidp_knowledge_bases_impl( ErrorCode.AIDP_SERVICE_ERROR, "Unexpected AIDP knowledge base response format", ) - return result + return _normalize_response(result) + except httpx.RequestError as e: + logger.exception("AIDP request failed: %s", e) + raise AppException( + ErrorCode.AIDP_CONNECTION_ERROR, + f"AIDP API request failed: {str(e)}", + ) + except httpx.HTTPStatusError as e: + logger.exception( + "AIDP API HTTP error: %s, status_code: %s", + e, + e.response.status_code, + ) + if e.response.status_code in (401, 403): + raise AppException( + ErrorCode.AIDP_AUTH_ERROR, + f"AIDP authentication failed: {str(e)}", + ) + raise AppException( + ErrorCode.AIDP_SERVICE_ERROR, + f"AIDP API HTTP error {e.response.status_code}: {str(e)}", + ) + except ValueError as e: + logger.exception("Failed to parse AIDP API response: %s", e) + raise AppException( + ErrorCode.AIDP_SERVICE_ERROR, + f"Failed to parse AIDP API response: {str(e)}", + ) + + +def _normalize_response(raw: Dict[str, Any]) -> Dict[str, Any]: + """Map AIDP API response fields to the canonical {value, total_count, next_link} shape.""" + items = ( + raw.get("value") + if raw.get("value") is not None + else raw.get("data") + if raw.get("data") is not None + else raw.get("items") + if raw.get("items") is not None + else raw.get("knowledge_bases") + if raw.get("knowledge_bases") is not None + else [] + ) + total_keys = ("total_count", "total", "totalRecords", "count") + total = next((raw.get(k) for k in total_keys if raw.get(k) is not None), None) + next_link = raw.get("next_link") or raw.get("next") or None + return { + "value": items, + "total_count": total, + "next_link": next_link, + } + + +def _extract_tenant_from_url(url: str) -> str | None: + """Extract tenant ID from a URL like /KnowledgeBase/Tenants/{tenant}/KnowledgeBases.""" + import re + match = re.search(r"/Tenants/([^/]+)/", url) + return match.group(1) if match else None + + +def fetch_all_aidp_knowledge_bases_impl( + server_url: str, + api_key: str, +) -> Dict[str, Any]: + """Fetch all knowledge bases from AIDP by following next_link until exhausted. + + AIDP does not return a true total count, so we follow next_link pages + until there is no next_link left. We also detect the real tenant ID + from the first response's next_link (AIDP embeds it there) and use it + for any manual page construction needed. + """ + normalized_url = _validate_params(server_url, api_key) + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + try: + client = http_client_manager.get_sync_client( + base_url=normalized_url, + timeout=120.0, + verify_ssl=False, + ) + + all_items: List[Any] = [] + current_page = 1 + max_pages = 1000 + page_size = 100 + detected_tenant: str | None = None + + # Build the first request URL using the known path pattern + first_path = f"{_LIST_PATH}?page=1&page_size={page_size}" + current_url: str | None = urljoin(f"{normalized_url}/", first_path) + + while current_page <= max_pages and current_url: + logger.info( + "Fetching AIDP KBs — page %d from %s", + current_page, + current_url, + ) + + response = client.get(current_url, headers=headers) + response.raise_for_status() + result = response.json() + if not isinstance(result, dict): + raise AppException( + ErrorCode.AIDP_SERVICE_ERROR, + "Unexpected AIDP knowledge base response format", + ) + + page_items = ( + result.get("value") + if result.get("value") is not None + else result.get("data") + if result.get("data") is not None + else result.get("items") + if result.get("items") is not None + else result.get("knowledge_bases") + if result.get("knowledge_bases") is not None + else [] + ) + if not isinstance(page_items, list): + page_items = [] + + all_items.extend(page_items) + + # Detect real tenant from next_link on the first page + if current_page == 1 and detected_tenant is None: + raw_next = result.get("next_link") or result.get("next") or "" + detected_tenant = _extract_tenant_from_url(str(raw_next)) + if detected_tenant: + logger.info("Detected AIDP tenant: %s", detected_tenant) + + # Follow next_link if present, otherwise construct next page manually + raw_next = result.get("next_link") or result.get("next") or "" + next_url_str = str(raw_next).strip() + if next_url_str: + current_url = urljoin(normalized_url + "/", next_url_str) + current_page += 1 + else: + current_url = None + + total_count = len(all_items) + logger.info("AIDP KBs: accumulated %d total items (tenant=%s)", total_count, detected_tenant) + + return { + "value": all_items, + "total_count": total_count, + "next_link": None, + } except httpx.RequestError as e: logger.exception("AIDP request failed: %s", e) raise AppException( diff --git a/frontend/components/tool-config/AidpKnowledgeSelectorModal.tsx b/frontend/components/tool-config/AidpKnowledgeSelectorModal.tsx index 87d749452..78c58cedf 100644 --- a/frontend/components/tool-config/AidpKnowledgeSelectorModal.tsx +++ b/frontend/components/tool-config/AidpKnowledgeSelectorModal.tsx @@ -7,13 +7,13 @@ import { Empty, Input, Modal, - Pagination, Space, Spin, Tag, Typography, message, } from "antd"; +import { LeftOutlined, RightOutlined } from "@ant-design/icons"; import { useTranslation } from "react-i18next"; import log from "@/lib/logger"; @@ -47,56 +47,36 @@ export default function AidpKnowledgeSelectorModal({ }: AidpKnowledgeSelectorModalProps) { const { t } = useTranslation("common"); - // Accumulate loaded items across all pages; replace when serverUrl/apiKey changes - const [allLoadedItems, setAllLoadedItems] = useState([]); - // Local selection state so toggling checkboxes does not auto-close the modal - const [tempSelectedIds, setTempSelectedIds] = useState([]); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [pageItems, setPageItems] = useState([]); + const [nextLink, setNextLink] = useState(null); const [keyword, setKeyword] = useState(""); const [loading, setLoading] = useState(false); + const [tempSelectedIds, setTempSelectedIds] = useState([]); - // Persist display names for selected IDs even when they scroll off the loaded page const nameMap = useRef>(new Map()); - // Keep a ref to latest selectedDatasetIds to avoid stale closures in loadPage - const selectedDatasetIdsRef = useRef(selectedDatasetIds); - useEffect(() => { - selectedDatasetIdsRef.current = selectedDatasetIds; - }, [selectedDatasetIds]); - // Keep refs to latest credentials so loadPage can read them without - // recreating the callback on every credential change. - const serverUrlRef = useRef(serverUrl); - const apiKeyRef = useRef(apiKey); - useEffect(() => { - serverUrlRef.current = serverUrl; - }, [serverUrl]); - useEffect(() => { - apiKeyRef.current = apiKey; - }, [apiKey]); + const prevKeyword = useRef(""); // ------------------------------------------------------------------ // Reset all state when modal opens // ------------------------------------------------------------------ useEffect(() => { if (!isOpen) return; - setAllLoadedItems([]); - setTempSelectedIds(selectedDatasetIds); - setPage(1); - setPageSize(DEFAULT_PAGE_SIZE); - setTotal(0); + setCurrentPage(1); + setPageItems([]); + setNextLink(null); setKeyword(""); + setTempSelectedIds(selectedDatasetIds); nameMap.current = new Map(); + prevKeyword.current = ""; }, [isOpen]); // ------------------------------------------------------------------ // Keep display names in sync with the parent's selectedDatasetIds - // Handles: external removal (tool config panel deletes a KB → uncheck in modal) // ------------------------------------------------------------------ useEffect(() => { if (!isOpen) return; const ids = new Set(selectedDatasetIds.map(String)); - // Prune nameMap of IDs that are no longer selected for (const id of nameMap.current.keys()) { if (!ids.has(id)) { nameMap.current.delete(id); @@ -105,121 +85,86 @@ export default function AidpKnowledgeSelectorModal({ }, [isOpen, selectedDatasetIds]); // ------------------------------------------------------------------ - // Load a single page from the API + // Fetch a single page (page 1 on open/credentials change; next/prev on nav) // ------------------------------------------------------------------ const loadPage = useCallback( - async (nextPage: number, nextPageSize: number) => { - // Read latest credentials from refs to keep this callback's identity stable - const currentServerUrl = serverUrlRef.current; - const currentApiKey = apiKeyRef.current; - if (!currentServerUrl || !currentApiKey) { - setAllLoadedItems([]); - setTotal(0); + async (pageNum: number, nextUrl: string | null = null) => { + if (!serverUrl || !apiKey) { + setPageItems([]); + setNextLink(null); return; } setLoading(true); try { const result = await knowledgeBaseService.getAidpKnowledgeBases( - currentServerUrl, - currentApiKey, - nextPage, - nextPageSize + serverUrl, + apiKey, + pageNum, + DEFAULT_PAGE_SIZE ); - const items = result.value || []; - const newTotal = result.total_count ?? items.length; - - // Read selectedDatasetIds from a ref to avoid dependency changes triggering re-fetch - const currentSelectedIds = selectedDatasetIdsRef.current; + const items: AidpKnowledgeBaseItem[] = result.value || []; - if (nextPage === 1) { - // Fresh load — replace the accumulated list - setAllLoadedItems(items); - // Always rebuild nameMap for this page's items with their names - // This ensures we have display names even for non-selected items - const nextNameMap = new Map(); - for (const item of items) { - const id = String(item.kds_id); - const name = item.kds_name || id; - // Keep previously stored name for still-selected IDs to avoid flicker - const storedName = nameMap.current.get(id); - nextNameMap.set(id, storedName ?? name); - } - nameMap.current = nextNameMap; + if (nextUrl) { + setNextLink(result.next_link ?? null); } else { - // Append page N > 1 - setAllLoadedItems((prev) => [...prev, ...items]); - for (const item of items) { - const id = String(item.kds_id); - const name = item.kds_name || id; - if (currentSelectedIds.includes(id) && !nameMap.current.has(id)) { - nameMap.current.set(id, name); - } + setNextLink(result.next_link ?? null); + } + + for (const item of items) { + const id = String(item.kds_id); + if (!nameMap.current.has(id)) { + nameMap.current.set(id, item.kds_name || id); } } - setTotal(newTotal); + setPageItems(items); + setCurrentPage(pageNum); } catch (error) { log.error("Failed to load AIDP knowledge bases:", error); message.error(t("toolConfig.aidp.selector.loadFailed")); - if (nextPage === 1) { - setAllLoadedItems([]); - setTotal(0); - } + setPageItems([]); + setNextLink(null); } finally { setLoading(false); } }, - [t] + [serverUrl, apiKey, t] ); // ------------------------------------------------------------------ - // Trigger load when modal opens OR credentials change - // ------------------------------------------------------------------ - const triggerLoad = useCallback(() => { - setPage(1); - // Read latest selectedDatasetIds from ref to avoid stale closure - loadPage(1, pageSize).catch(() => { - // Error already surfaced via message.error in loadPage. - }); - }, [pageSize]); // eslint-disable-line react-hooks/exhaustive-deps - - useEffect(() => { - if (!isOpen) return; - // Touch selectedDatasetIdsRef to ensure latest value is read inside loadPage - selectedDatasetIdsRef.current; - triggerLoad(); - }, [isOpen, serverUrl, apiKey, selectedDatasetIds, triggerLoad]); // eslint-disable-line react-hooks/exhaustive-deps - - // ------------------------------------------------------------------ - // Reload on page / pageSize change + // Load first page when modal opens or credentials change // ------------------------------------------------------------------ useEffect(() => { if (!isOpen) return; - loadPage(page, pageSize).catch(() => { - // Error already surfaced via message.error in loadPage. - }); - }, [page, pageSize]); // eslint-disable-line react-hooks/exhaustive-deps + loadPage(1); + }, [isOpen, serverUrl, apiKey]); // eslint-disable-line react-hooks/exhaustive-deps // ------------------------------------------------------------------ - // Client-side keyword filter applied to the accumulated list + // Keyword filter (client-side on current page) // ------------------------------------------------------------------ const filteredItems = useMemo(() => { const kw = keyword.trim().toLowerCase(); - if (!kw) return allLoadedItems; - return allLoadedItems.filter((item) => { + if (!kw) return pageItems; + return pageItems.filter((item) => { const n = String(item.kds_name || "").toLowerCase(); const i = String(item.kds_id || "").toLowerCase(); const d = String(item.description || "").toLowerCase(); return n.includes(kw) || i.includes(kw) || d.includes(kw); }); - }, [allLoadedItems, keyword]); + }, [pageItems, keyword]); // ------------------------------------------------------------------ - // Selected IDs — always derived from the parent's prop (source of truth) + // Sync / Reload current page // ------------------------------------------------------------------ + const handleSync = () => { + loadPage(currentPage); + }; + // ------------------------------------------------------------------ + // Toggle selection + // ------------------------------------------------------------------ const handleToggle = (item: AidpKnowledgeBaseItem, checked: boolean) => { const id = String(item.kds_id); if (checked) { @@ -242,7 +187,9 @@ export default function AidpKnowledgeSelectorModal({ setTempSelectedIds((prev) => prev.filter((sid) => sid !== id)); }; - const displayNames = tempSelectedIds.map((id) => nameMap.current.get(id) || id); + const displayNames = tempSelectedIds.map( + (id) => nameMap.current.get(id) || id + ); const renderRow = (item: AidpKnowledgeBaseItem) => { const id = String(item.kds_id); @@ -251,25 +198,29 @@ export default function AidpKnowledgeSelectorModal({ !checked && tempSelectedIds.length >= maxSelect; return (
-
+
-
+
- handleToggle(item, e.target.checked) - } + onChange={(e) => handleToggle(item, e.target.checked)} + className="shrink-0 mt-0.5" + /> + {id} + - {id} +
{item.description && ( - {item.description} + {item.description} )}
- + {t( "toolConfig.aidp.selector.documentCount", @@ -287,24 +238,20 @@ export default function AidpKnowledgeSelectorModal({ ); }; - const renderListContent = ( - isLoading: boolean, - items: AidpKnowledgeBaseItem[], - visibleItems: AidpKnowledgeBaseItem[] - ) => { - if (isLoading && items.length === 0) { + const renderListContent = () => { + if (loading && pageItems.length === 0) { return (
); } - if (visibleItems.length === 0) { + if (filteredItems.length === 0) { return ; } return (
- {visibleItems.map(renderRow)} + {filteredItems.map(renderRow)}
); }; @@ -328,7 +275,9 @@ export default function AidpKnowledgeSelectorModal({ setKeyword(e.target.value)} + onChange={(e) => { + setKeyword(e.target.value); + }} placeholder={t("toolConfig.aidp.selector.searchPlaceholder")} /> @@ -339,14 +288,7 @@ export default function AidpKnowledgeSelectorModal({ max: maxSelect, })} -
@@ -369,20 +311,25 @@ export default function AidpKnowledgeSelectorModal({ )}
- {renderListContent(loading, allLoadedItems, filteredItems)} + {renderListContent()}
-
- { - setPage(nextPage); - setPageSize(nextPageSize); - }} - /> +
+ + {currentPage} +
diff --git a/frontend/services/api.ts b/frontend/services/api.ts index e5b4ed025..94a14892a 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -245,6 +245,7 @@ export const API_ENDPOINTS = { }, aidp: { knowledgeBases: `${API_BASE_URL}/aidp/knowledge-bases`, + knowledgeBasesAll: `${API_BASE_URL}/aidp/knowledge-bases-all`, }, config: { save: `${API_BASE_URL}/config/save_config`, diff --git a/frontend/services/knowledgeBaseService.ts b/frontend/services/knowledgeBaseService.ts index 9f53a9f21..54d9e529a 100644 --- a/frontend/services/knowledgeBaseService.ts +++ b/frontend/services/knowledgeBaseService.ts @@ -442,6 +442,41 @@ class KnowledgeBaseService { } } + async getAidpKnowledgeBasesAll( + serverUrl: string, + apiKey: string + ): Promise { + try { + const url = new URL(API_ENDPOINTS.aidp.knowledgeBasesAll, globalThis.location.origin); + url.searchParams.set("server_url", serverUrl); + url.searchParams.set("api_key", apiKey); + + const response = await fetch(url.toString(), { + method: "GET", + headers: getAuthHeaders(), + }); + const result = await response.json(); + + if (result.code !== undefined && result.code !== 0) { + const errorCode = result.code || response.status; + const errorMessage = + result.message || "Failed to fetch all AIDP knowledge bases"; + log.error("AIDP API error:", { code: errorCode, message: errorMessage }); + throw new ApiError(errorCode, errorMessage); + } + + return { + value: Array.isArray(result.value) ? result.value : [], + total_count: + typeof result.total_count === "number" ? result.total_count : undefined, + next_link: typeof result.next_link === "string" ? result.next_link : null, + }; + } catch (error) { + log.error("Failed to fetch all AIDP knowledge bases:", error); + throw error; + } + } + async getAidpKnowledgeBases( serverUrl: string, apiKey: string, diff --git a/sdk/nexent/core/tools/aidp_search_tool.py b/sdk/nexent/core/tools/aidp_search_tool.py index 874a05492..7b3047ac8 100644 --- a/sdk/nexent/core/tools/aidp_search_tool.py +++ b/sdk/nexent/core/tools/aidp_search_tool.py @@ -179,8 +179,8 @@ def __init__( self._http_client = http_client_manager.get_sync_client( base_url=self.base_url, - timeout=30.0, - verify_ssl=True, + timeout=60.0, + verify_ssl=False, ) self.record_ops = 1 diff --git a/test/backend/services/test_aidp_service.py b/test/backend/services/test_aidp_service.py index 1c7814367..084d7c479 100644 --- a/test/backend/services/test_aidp_service.py +++ b/test/backend/services/test_aidp_service.py @@ -73,12 +73,13 @@ def register_module(name: str, module: ModuleType): class TestFetchAidpKnowledgeBasesImpl: - def test_fetch_success_uses_bearer_header(self, aidp_service_module): + def test_passthrough_single_page(self, aidp_service_module): + """Passthrough: returns the AIDP API response directly.""" mock_client = MagicMock() mock_response = MagicMock() mock_response.json.return_value = { - "value": [{"kds_id": "kb-1", "kds_name": "Knowledge Base 1"}], - "total_count": 1, + "value": [{"kds_id": "kb-1"}, {"kds_id": "kb-2"}], + "total_count": 2, } mock_response.raise_for_status.return_value = None mock_client.get.return_value = mock_response @@ -90,19 +91,38 @@ def test_fetch_success_uses_bearer_header(self, aidp_service_module): result = aidp_service_module.fetch_aidp_knowledge_bases_impl( server_url="http://127.0.0.1:30081", api_key="jwt-token", - page=2, - page_size=15, + page=3, + page_size=20, ) - assert result["total_count"] == 1 - mock_client.get.assert_called_once_with( - "http://127.0.0.1:30081/KnowledgeBase/Tenants/aidp/KnowledgeBases?page=2&page_size=15", - headers={ - "Authorization": "Bearer jwt-token", - "Content-Type": "application/json", - }, + assert result["value"] == [{"kds_id": "kb-1"}, {"kds_id": "kb-2"}] + assert result["total_count"] == 2 + mock_client.get.assert_called_once() + call_url = mock_client.get.call_args[0][0] + assert "page=3" in call_url + assert "page_size=20" in call_url + + def test_uses_bearer_auth_header(self, aidp_service_module): + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.json.return_value = {"value": [{"kds_id": "kb-1"}]} + mock_response.raise_for_status.return_value = None + mock_client.get.return_value = mock_response + + mock_manager = MagicMock() + mock_manager.get_sync_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + + aidp_service_module.fetch_aidp_knowledge_bases_impl( + server_url="http://127.0.0.1:30081", + api_key="my-secret-token", + page=1, + page_size=10, ) + call_args = mock_client.get.call_args + assert call_args.kwargs["headers"]["Authorization"] == "Bearer my-secret-token" + @pytest.mark.parametrize( "server_url,api_key,error_code", [ @@ -123,15 +143,10 @@ def test_fetch_invalid_config( server_url=server_url, api_key=api_key, ) - assert exc_info.value.error_code == error_code @pytest.mark.parametrize("status_code", [401, 403]) - def test_fetch_auth_error( - self, - aidp_service_module, - status_code: int, - ): + def test_fetch_auth_error(self, aidp_service_module, status_code: int): request = httpx.Request("GET", "http://127.0.0.1:30081") response = httpx.Response(status_code, request=request) mock_client = MagicMock() @@ -140,7 +155,6 @@ def test_fetch_auth_error( request=request, response=response, ) - mock_manager = MagicMock() mock_manager.get_sync_client.return_value = mock_client aidp_service_module.http_client_manager = mock_manager @@ -150,13 +164,9 @@ def test_fetch_auth_error( server_url="http://127.0.0.1:30081", api_key="jwt-token", ) - assert exc_info.value.error_code == ErrorCode.AIDP_AUTH_ERROR - def test_fetch_http_status_error_maps_service_error( - self, - aidp_service_module, - ): + def test_fetch_http_status_error_maps_service_error(self, aidp_service_module): request = httpx.Request("GET", "http://127.0.0.1:30081") response = httpx.Response(500, request=request) mock_client = MagicMock() @@ -165,6 +175,21 @@ def test_fetch_http_status_error_maps_service_error( request=request, response=response, ) + mock_manager = MagicMock() + mock_manager.get_sync_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + + with pytest.raises(AppException) as exc_info: + aidp_service_module.fetch_aidp_knowledge_bases_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + ) + assert exc_info.value.error_code == ErrorCode.AIDP_SERVICE_ERROR + + def test_fetch_request_error_maps_connection_error(self, aidp_service_module): + request = httpx.Request("GET", "http://127.0.0.1:30081") + mock_client = MagicMock() + mock_client.get.side_effect = httpx.RequestError("network down", request=request) mock_manager = MagicMock() mock_manager.get_sync_client.return_value = mock_client @@ -175,36 +200,147 @@ def test_fetch_http_status_error_maps_service_error( server_url="http://127.0.0.1:30081", api_key="jwt-token", ) + assert exc_info.value.error_code == ErrorCode.AIDP_CONNECTION_ERROR + def test_fetch_invalid_json_shape_maps_service_error(self, aidp_service_module): + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = ["unexpected-list"] + mock_client.get.return_value = mock_response + + mock_manager = MagicMock() + mock_manager.get_sync_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + + with pytest.raises(AppException) as exc_info: + aidp_service_module.fetch_aidp_knowledge_bases_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + ) assert exc_info.value.error_code == ErrorCode.AIDP_SERVICE_ERROR - def test_fetch_request_error_maps_connection_error( - self, - aidp_service_module, - ): + +class TestFetchAllAidpKnowledgeBasesImpl: + def test_follows_next_link_for_pagination(self, aidp_service_module): + """Follows next_link from response to fetch subsequent pages.""" + mock_client = MagicMock() + + page1_response = MagicMock() + page1_response.json.return_value = { + "value": [{"kds_id": "kb-1"}, {"kds_id": "kb-2"}], + "next_link": "/KnowledgeBase/Tenants/real-tenant/KnowledgeBases?page=2&page_size=100", + } + page1_response.raise_for_status.return_value = None + + page2_response = MagicMock() + page2_response.json.return_value = { + "value": [{"kds_id": "kb-3"}, {"kds_id": "kb-4"}], + "next_link": None, + } + page2_response.raise_for_status.return_value = None + + mock_client.get.side_effect = [page1_response, page2_response] + + mock_manager = MagicMock() + mock_manager.get_sync_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + + result = aidp_service_module.fetch_all_aidp_knowledge_bases_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + ) + + assert result["total_count"] == 4 + assert result["value"] == [ + {"kds_id": "kb-1"}, + {"kds_id": "kb-2"}, + {"kds_id": "kb-3"}, + {"kds_id": "kb-4"}, + ] + assert mock_client.get.call_count == 2 + + def test_stops_when_next_link_is_null(self, aidp_service_module): + """Stops pagination when next_link is null/empty.""" + mock_client = MagicMock() + single_response = MagicMock() + single_response.json.return_value = { + "value": [{"kds_id": "kb-1"}], + "next_link": None, + } + single_response.raise_for_status.return_value = None + mock_client.get.return_value = single_response + + mock_manager = MagicMock() + mock_manager.get_sync_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + + result = aidp_service_module.fetch_all_aidp_knowledge_bases_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + ) + + assert result["total_count"] == 1 + assert mock_client.get.call_count == 1 + + def test_first_page_uses_page_size_100(self, aidp_service_module): + """The initial request uses page_size=100.""" + mock_client = MagicMock() + empty_response = MagicMock() + empty_response.json.return_value = {"value": [], "next_link": None} + empty_response.raise_for_status.return_value = None + mock_client.get.return_value = empty_response + + mock_manager = MagicMock() + mock_manager.get_sync_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + + aidp_service_module.fetch_all_aidp_knowledge_bases_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + ) + + call_url = mock_client.get.call_args[0][0] + assert "page_size=100" in call_url + + @pytest.mark.parametrize("status_code", [401, 403]) + def test_auth_error(self, aidp_service_module, status_code: int): request = httpx.Request("GET", "http://127.0.0.1:30081") + response = httpx.Response(status_code, request=request) mock_client = MagicMock() - mock_client.get.side_effect = httpx.RequestError( - "network down", + mock_client.get.side_effect = httpx.HTTPStatusError( + "auth failed", request=request, + response=response, ) - mock_manager = MagicMock() mock_manager.get_sync_client.return_value = mock_client aidp_service_module.http_client_manager = mock_manager with pytest.raises(AppException) as exc_info: - aidp_service_module.fetch_aidp_knowledge_bases_impl( + aidp_service_module.fetch_all_aidp_knowledge_bases_impl( server_url="http://127.0.0.1:30081", api_key="jwt-token", ) + assert exc_info.value.error_code == ErrorCode.AIDP_AUTH_ERROR + def test_request_error_maps_connection_error(self, aidp_service_module): + request = httpx.Request("GET", "http://127.0.0.1:30081") + mock_client = MagicMock() + mock_client.get.side_effect = httpx.RequestError("network down", request=request) + + mock_manager = MagicMock() + mock_manager.get_sync_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + + with pytest.raises(AppException) as exc_info: + aidp_service_module.fetch_all_aidp_knowledge_bases_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + ) assert exc_info.value.error_code == ErrorCode.AIDP_CONNECTION_ERROR - def test_fetch_invalid_json_shape_maps_service_error( - self, - aidp_service_module, - ): + def test_invalid_json_shape_maps_service_error(self, aidp_service_module): mock_client = MagicMock() mock_response = MagicMock() mock_response.raise_for_status.return_value = None @@ -216,9 +352,28 @@ def test_fetch_invalid_json_shape_maps_service_error( aidp_service_module.http_client_manager = mock_manager with pytest.raises(AppException) as exc_info: - aidp_service_module.fetch_aidp_knowledge_bases_impl( + aidp_service_module.fetch_all_aidp_knowledge_bases_impl( server_url="http://127.0.0.1:30081", api_key="jwt-token", ) + assert exc_info.value.error_code == ErrorCode.AIDP_SERVICE_ERROR + def test_fetch_http_status_error_maps_service_error(self, aidp_service_module): + request = httpx.Request("GET", "http://127.0.0.1:30081") + response = httpx.Response(500, request=request) + mock_client = MagicMock() + mock_client.get.side_effect = httpx.HTTPStatusError( + "server error", + request=request, + response=response, + ) + mock_manager = MagicMock() + mock_manager.get_sync_client.return_value = mock_client + aidp_service_module.http_client_manager = mock_manager + + with pytest.raises(AppException) as exc_info: + aidp_service_module.fetch_all_aidp_knowledge_bases_impl( + server_url="http://127.0.0.1:30081", + api_key="jwt-token", + ) assert exc_info.value.error_code == ErrorCode.AIDP_SERVICE_ERROR