Files
kandev/apps/web/lib/api/domains/azure-devops-api.test.ts
T
2026-08-02 17:08:22 +01:00

254 lines
9.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }),
}));
import {
associateAzureDevOpsPullRequest,
associateAzureDevOpsWorkItem,
copyAzureDevOpsConfig,
deleteAzureDevOpsConfig,
getAzureDevOpsConfig,
getAzureDevOpsWorkspaceSettings,
getAzureDevOpsPullRequestFeedback,
listAzureDevOpsProjects,
listAzureDevOpsBranches,
listAzureDevOpsTeams,
listAzureDevOpsBoards,
getAzureDevOpsBoardSnapshot,
updateAzureDevOpsBoardWorkItem,
listAzureDevOpsPullRequests,
listAzureDevOpsRepositories,
listWorkspaceAzureDevOpsTaskPullRequests,
listWorkspaceAzureDevOpsTaskWorkItems,
searchAzureDevOpsWorkItems,
setAzureDevOpsConfig,
updateAzureDevOpsWorkspaceSettings,
syncAzureDevOpsTaskPullRequest,
testAzureDevOpsConnection,
} from "./azure-devops-api";
const BASE = "http://api.test/api/v1/azure-devops";
type FetchInput = Parameters<typeof fetch>[0];
type FetchInit = Parameters<typeof fetch>[1];
const fetchSpy = vi.fn<(...args: [FetchInput, FetchInit?]) => Promise<Response>>();
beforeEach(() => {
fetchSpy.mockReset();
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => vi.unstubAllGlobals());
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function lastCall(): { url: string; init: FetchInit | undefined } {
const call = fetchSpy.mock.calls.at(-1);
if (!call) throw new Error("expected fetch to have been called");
return { url: String(call[0]), init: call[1] };
}
describe("Azure DevOps config API", () => {
it("scopes config reads to the selected workspace and maps 204 to null", async () => {
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 }));
await expect(getAzureDevOpsConfig("ws one")).resolves.toBeNull();
expect(lastCall().url).toBe(`${BASE}/config?workspace_id=ws+one`);
});
it("posts config and connection tests without changing the PAT field", async () => {
const payload = {
organizationUrl: "https://dev.azure.com/acme",
defaultProjectId: "project-1",
defaultProjectName: "Platform",
authMethod: "pat" as const,
pat: "secret",
};
fetchSpy.mockImplementation(async () => jsonResponse({ ...payload, hasSecret: true }));
await setAzureDevOpsConfig("ws-1", payload);
expect(lastCall()).toMatchObject({
url: `${BASE}/config?workspace_id=ws-1`,
init: { method: "POST", body: JSON.stringify(payload) },
});
await testAzureDevOpsConnection("ws-1", payload);
expect(lastCall().url).toBe(`${BASE}/config/test?workspace_id=ws-1`);
});
it("copies and deletes workspace configuration", async () => {
fetchSpy.mockImplementation(async () => jsonResponse({ deleted: true }));
await copyAzureDevOpsConfig("source", "target");
expect(lastCall()).toMatchObject({
url: `${BASE}/config/copy?workspace_id=source`,
init: { method: "POST", body: JSON.stringify({ targetWorkspaceId: "target" }) },
});
await deleteAzureDevOpsConfig("source");
expect(lastCall().init?.method).toBe("DELETE");
});
});
describe("Azure DevOps workspace settings API", () => {
it("reads and patches workspace-scoped query and action presets", async () => {
fetchSpy.mockImplementation(async () => jsonResponse({ workspaceId: "ws-1" }));
await getAzureDevOpsWorkspaceSettings("ws / one");
expect(lastCall().url).toBe(`${BASE}/workspace-settings?workspace_id=ws+%2F+one`);
const payload = { workItemActions: [] };
await updateAzureDevOpsWorkspaceSettings("ws-1", payload);
expect(lastCall()).toMatchObject({
url: `${BASE}/workspace-settings?workspace_id=ws-1`,
init: { method: "PATCH", body: JSON.stringify(payload) },
});
});
});
describe("Azure DevOps read API", () => {
it("reads team boards and updates a board work item with workspace scope", async () => {
fetchSpy.mockResolvedValue(jsonResponse({ teams: [] }));
await listAzureDevOpsTeams("ws-1", "project / one");
expect(lastCall().url).toBe(`${BASE}/teams?project=project+%2F+one&workspace_id=ws-1`);
fetchSpy.mockResolvedValue(jsonResponse({ boards: [] }));
await listAzureDevOpsBoards("ws-1", "project-1", "team/one");
expect(lastCall().url).toBe(
`${BASE}/boards?project=project-1&team=team%2Fone&workspace_id=ws-1`,
);
fetchSpy.mockResolvedValue(jsonResponse({ board: {}, items: [] }));
await getAzureDevOpsBoardSnapshot("ws-1", "project-1", "team-1", "board/1");
expect(lastCall().url).toBe(
`${BASE}/boards/board%2F1?project=project-1&team=team-1&workspace_id=ws-1`,
);
fetchSpy.mockResolvedValue(jsonResponse({ id: 101 }));
await updateAzureDevOpsBoardWorkItem("ws-1", "project-1", "team-1", "board-1", 101, {
revision: 7,
assigneeAction: "assign_current_user",
});
expect(lastCall()).toMatchObject({
url: `${BASE}/boards/board-1/work-items/101?project=project-1&team=team-1&workspace_id=ws-1`,
init: {
method: "PATCH",
body: JSON.stringify({ revision: 7, assigneeAction: "assign_current_user" }),
},
});
});
it("encodes project and repository filters with workspace scope", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ projects: [] }));
await listAzureDevOpsProjects("ws-1");
expect(lastCall().url).toBe(`${BASE}/projects?workspace_id=ws-1`);
fetchSpy.mockResolvedValueOnce(jsonResponse({ repositories: [] }));
await listAzureDevOpsRepositories("ws-1", "project / one");
expect(lastCall().url).toBe(`${BASE}/repositories?project=project+%2F+one&workspace_id=ws-1`);
fetchSpy.mockResolvedValueOnce(jsonResponse({ branches: [] }));
await listAzureDevOpsBranches("ws-1", "acme", "project / one", "repo / one");
expect(lastCall().url).toBe(
`${BASE}/branches?organization=acme&project=project+%2F+one&repository=repo+%2F+one&workspace_id=ws-1`,
);
});
it("posts WIQL and builds pull request filters", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ items: [], count: 0 }));
await searchAzureDevOpsWorkItems("ws-1", {
project: "project-1",
wiql: "SELECT [System.Id] FROM WorkItems",
top: 50,
});
expect(lastCall()).toMatchObject({
url: `${BASE}/work-items/search?workspace_id=ws-1`,
init: {
method: "POST",
body: JSON.stringify({
project: "project-1",
wiql: "SELECT [System.Id] FROM WorkItems",
top: 50,
}),
},
});
fetchSpy.mockResolvedValueOnce(jsonResponse({ items: [], count: 0, skip: 0, top: 25 }));
await listAzureDevOpsPullRequests("ws-1", {
project: "project-1",
repository: "repo-1",
status: "active",
reviewer: "me",
top: 25,
});
expect(lastCall().url).toContain("project=project-1");
expect(lastCall().url).toContain("repository=repo-1");
expect(lastCall().url).toContain("reviewer=me");
expect(lastCall().url).toContain("workspace_id=ws-1");
});
it("encodes provider identifiers in feedback paths", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
pullRequest: {},
reviewers: [],
threads: [],
linkedWorkItems: [],
policies: [],
reviewState: "",
policyState: "",
}),
);
await getAzureDevOpsPullRequestFeedback("ws-1", "project/one", "repo one", 42);
expect(lastCall().url).toBe(
`${BASE}/pull-requests/project%2Fone/repo%20one/42/feedback?workspace_id=ws-1`,
);
});
});
describe("Azure DevOps task pull request API", () => {
it("lists workspace associations through the workspace path", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ taskPrs: {} }));
await listWorkspaceAzureDevOpsTaskPullRequests("ws / one");
expect(lastCall().url).toBe(`${BASE}/workspaces/ws%20%2F%20one/task-prs`);
});
it("associates and syncs pull requests with explicit workspace scope", async () => {
const payload = {
repositoryId: "local-repo",
pullRequestId: 42,
};
fetchSpy.mockImplementation(async () => jsonResponse({ id: "association-1" }));
await associateAzureDevOpsPullRequest("ws-1", "task/1", payload);
expect(lastCall()).toMatchObject({
url: `${BASE}/tasks/task%2F1/pull-requests?workspace_id=ws-1`,
init: { method: "POST", body: JSON.stringify(payload) },
});
await syncAzureDevOpsTaskPullRequest("ws-1", "task/1", payload);
expect(lastCall().url).toBe(`${BASE}/tasks/task%2F1/pull-requests/sync?workspace_id=ws-1`);
});
});
describe("Azure DevOps task work-item API", () => {
it("lists and associates work items with explicit workspace scope", async () => {
fetchSpy.mockImplementation(async () => jsonResponse({ taskWorkItems: {} }));
await listWorkspaceAzureDevOpsTaskWorkItems("ws / one");
expect(lastCall().url).toBe(`${BASE}/workspaces/ws%20%2F%20one/task-work-items`);
const payload = { projectId: "project-1", workItemId: 42 };
await associateAzureDevOpsWorkItem("ws-1", "task/1", payload);
expect(lastCall()).toMatchObject({
url: `${BASE}/tasks/task%2F1/work-items?workspace_id=ws-1`,
init: { method: "POST", body: JSON.stringify(payload) },
});
});
});