diff --git a/src/tools/search.ts b/src/tools/search.ts index 773d19dc..fce0a070 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -209,7 +209,7 @@ async function fetchCombinedResults(topSearchResults: SearchResult[], gitApi: IG continue; } - const versionDescriptor = changeId ? { version: changeId, versionType: 2, versionOptions: 0 } : undefined; + const versionDescriptor = { version: changeId, versionType: 2, versionOptions: 0 }; const item = await gitApi.getItem( repositoryId, diff --git a/test/src/domains.test.ts b/test/src/domains.test.ts index edc02f9c..e5222415 100644 --- a/test/src/domains.test.ts +++ b/test/src/domains.test.ts @@ -251,6 +251,16 @@ describe("DomainsManager: backward compatibility and domain enabling", () => { expect(result).toEqual(["all"]); }); + it("returns 'all' when a whitespace-only string is provided", () => { + const result = DomainsManager.parseDomainsInput(" \n\t "); + expect(result).toEqual(["all"]); + }); + + it("returns 'all' when an empty array is provided", () => { + const result = DomainsManager.parseDomainsInput([]); + expect(result).toEqual(["all"]); + }); + it("parses comma-separated string input", () => { const result = DomainsManager.parseDomainsInput("repositories,pipelines,core"); expect(result).toEqual(["repositories", "pipelines", "core"]); diff --git a/test/src/tools/search.test.ts b/test/src/tools/search.test.ts index f3b5cce7..7665bf41 100644 --- a/test/src/tools/search.test.ts +++ b/test/src/tools/search.test.ts @@ -51,6 +51,13 @@ describe("search tools content spotlighting", () => { return handler; } + function getInputSchema(toolName: string): any { + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === toolName); + if (!call) throw new Error(`${toolName} tool not registered`); + const [, , schema] = call; + return schema; + } + function mockSuccessfulResponse(payload: string): jest.Mock { const fetchMock = jest.fn().mockResolvedValue({ ok: true, @@ -62,6 +69,16 @@ describe("search tools content spotlighting", () => { return fetchMock; } + function mockFailedResponse(status: number, statusText: string): jest.Mock { + const fetchMock = jest.fn().mockResolvedValue({ + ok: false, + status, + statusText, + }); + global.fetch = fetchMock; + return fetchMock; + } + it("spotlights code search results and fetched file data", async () => { const gitItem = { path: "/src/index.ts", @@ -95,6 +112,123 @@ describe("search tools content spotlighting", () => { expect(connectionProvider).toHaveBeenCalled(); }); + it("sends code search filters and request metadata", async () => { + const fetchMock = mockSuccessfulResponse(JSON.stringify({ results: [] })); + + await getHandler(SEARCH_TOOLS.search_code)({ + searchText: "authentication", + project: ["project"], + repository: ["repository"], + path: ["/src"], + branch: ["main"], + includeFacets: true, + skip: 5, + top: 20, + }); + + expect(fetchMock).toHaveBeenCalledWith("https://almsearch.dev.azure.com/test-org/_apis/search/codesearchresults?api-version=7.2-preview.1", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer fake-token", + "User-Agent": "Jest", + }, + body: JSON.stringify({ + searchText: "authentication", + includeFacets: true, + $skip: 5, + $top: 20, + filters: { + Project: ["project"], + Repository: ["repository"], + Path: ["/src"], + Branch: ["main"], + }, + }), + }); + }); + + it("accepts a single project in the code search schema", () => { + const schema = getInputSchema(SEARCH_TOOLS.search_code); + + expect(schema.project.parse("project")).toEqual(["project"]); + }); + + it("handles code search responses without results", async () => { + const payload = JSON.stringify({ count: 0 }); + mockSuccessfulResponse(payload); + + const result = await getHandler(SEARCH_TOOLS.search_code)({ + searchText: "authentication", + includeFacets: false, + skip: 0, + top: 5, + }); + + expect(unwrapSpotlightedContent(result.content[0].text, "code search results")).toBe(`${payload}[]`); + expect(mockGitApi.getItem).not.toHaveBeenCalled(); + }); + + it("reports malformed code search results without fetching files", async () => { + const results = [ + { repository: { id: "repository-id" }, path: "/src/index.ts", versions: [{ changeId: "abc123" }] }, + { project: { id: "project-id" }, path: "/src/index.ts", versions: [{ changeId: "abc123" }] }, + { project: { id: "project-id" }, repository: { id: "repository-id" }, versions: [{ changeId: "abc123" }] }, + { project: { id: "project-id" }, repository: { id: "repository-id" }, path: "/src/index.ts" }, + { project: { id: "project-id" }, repository: { id: "repository-id" }, path: "/src/index.ts", versions: [] }, + { project: { id: "project-id" }, repository: { id: "repository-id" }, path: "/src/index.ts", versions: [{}] }, + ]; + const payload = JSON.stringify({ results }); + mockSuccessfulResponse(payload); + + const result = await getHandler(SEARCH_TOOLS.search_code)({ + searchText: "authentication", + includeFacets: false, + skip: 0, + top: 5, + }); + + const content = unwrapSpotlightedContent(result.content[0].text, "code search results"); + const combinedResults = JSON.parse(content.slice(payload.length)); + expect(combinedResults).toHaveLength(results.length); + expect(combinedResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + error: expect.stringContaining("Missing projectId, repositoryId, filePath, or changeId"), + }), + ]) + ); + expect(mockGitApi.getItem).not.toHaveBeenCalled(); + }); + + it("includes Git API failures in code search results", async () => { + const searchResult = { + project: { id: "project-id" }, + repository: { id: "repository-id" }, + path: "/src/index.ts", + versions: [{ changeId: "abc123" }], + }; + const payload = JSON.stringify({ results: [searchResult, searchResult] }); + mockGitApi.getItem.mockRejectedValueOnce(new Error("Git API failed")).mockRejectedValueOnce("unknown failure"); + mockSuccessfulResponse(payload); + + const result = await getHandler(SEARCH_TOOLS.search_code)({ + searchText: "authentication", + includeFacets: false, + skip: 0, + top: 5, + }); + + const content = unwrapSpotlightedContent(result.content[0].text, "code search results"); + expect(JSON.parse(content.slice(payload.length))).toEqual([{ error: "Git API failed" }, { error: "unknown failure" }]); + }); + + it("throws when code search fails", async () => { + mockFailedResponse(400, "Bad Request"); + + await expect(getHandler(SEARCH_TOOLS.search_code)({ searchText: "authentication", includeFacets: false, skip: 0, top: 5 })).rejects.toThrow("Azure DevOps Code Search API error: 400 Bad Request"); + }); + it("spotlights wiki search results", async () => { const payload = JSON.stringify({ count: 1, @@ -114,6 +248,34 @@ describe("search tools content spotlighting", () => { expect(result.content[0].text).not.toBe(payload); }); + it("sends wiki search filters", async () => { + const fetchMock = mockSuccessfulResponse(JSON.stringify({ results: [] })); + + await getHandler(SEARCH_TOOLS.search_wiki)({ + searchText: "instructions", + project: ["project"], + wiki: ["wiki"], + includeFacets: true, + skip: 10, + top: 25, + }); + + const request = fetchMock.mock.calls[0][1]; + expect(JSON.parse(request.body)).toEqual({ + searchText: "instructions", + includeFacets: true, + $skip: 10, + $top: 25, + filters: { Project: ["project"], Wiki: ["wiki"] }, + }); + }); + + it("throws when wiki search fails", async () => { + mockFailedResponse(403, "Forbidden"); + + await expect(getHandler(SEARCH_TOOLS.search_wiki)({ searchText: "instructions", includeFacets: false, skip: 0, top: 10 })).rejects.toThrow("Azure DevOps Wiki Search API error: 403 Forbidden"); + }); + it("spotlights work item search results", async () => { const payload = JSON.stringify({ count: 1, @@ -132,4 +294,43 @@ describe("search tools content spotlighting", () => { expect(unwrapSpotlightedContent(result.content[0].text, "work item search results")).toBe(payload); expect(result.content[0].text).not.toBe(payload); }); + + it("sends work item search filters", async () => { + const fetchMock = mockSuccessfulResponse(JSON.stringify({ results: [] })); + + await getHandler(SEARCH_TOOLS.search_workitem)({ + searchText: "security", + project: ["project"], + areaPath: ["project\\area"], + workItemType: ["Bug"], + state: ["Active"], + assignedTo: ["user@example.com"], + includeFacets: true, + skip: 15, + top: 30, + }); + + const request = fetchMock.mock.calls[0][1]; + expect(JSON.parse(request.body)).toEqual({ + searchText: "security", + includeFacets: true, + $skip: 15, + $top: 30, + filters: { + "System.TeamProject": ["project"], + "System.AreaPath": ["project\\area"], + "System.WorkItemType": ["Bug"], + "System.State": ["Active"], + "System.AssignedTo": ["user@example.com"], + }, + }); + }); + + it("throws when work item search fails", async () => { + mockFailedResponse(500, "Internal Server Error"); + + await expect(getHandler(SEARCH_TOOLS.search_workitem)({ searchText: "security", includeFacets: false, skip: 0, top: 10 })).rejects.toThrow( + "Azure DevOps Work Item Search API error: 500 Internal Server Error" + ); + }); }); diff --git a/test/src/utils.test.ts b/test/src/utils.test.ts index ab6bfc7b..835bc977 100644 --- a/test/src/utils.test.ts +++ b/test/src/utils.test.ts @@ -27,6 +27,17 @@ describe("utils", () => { it("returns an empty array when only the runtime and script are present", () => { expect(getCliArgs(["node.exe", "dist/index.js"])).toEqual([]); }); + + it("uses process.argv when no argument is provided", () => { + const originalArgv = process.argv; + process.argv = ["node", "dist/index.js", "my_org-name", "--authentication", "azcli"]; + + try { + expect(getCliArgs()).toEqual(["my_org-name", "--authentication", "azcli"]); + } finally { + process.argv = originalArgv; + } + }); }); describe("createEnumMapping", () => { @@ -578,4 +589,8 @@ describe("getOrgFromUrl", () => { expect(getOrgFromUrl("https://dev.azure.com/")).toBeNull(); expect(getOrgFromUrl("https://dev.azure.com")).toBeNull(); }); + + it("returns null when the legacy host has no organization subdomain", () => { + expect(getOrgFromUrl("https://visualstudio.com")).toBeNull(); + }); });