Files
kandev/apps/web/lib/api/domains/gitlab-api.test.ts
T
Henry ffec4aade9 feat(gitlab): filter issues by milestone (#3278)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-09-02 16:55:31 +00:00

418 lines
15 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureGitLabHost,
configureGitLabToken,
clearGitLabToken,
createReviewWatch,
deleteIssueWatch,
previewResetReviewWatch,
resetIssueWatch,
triggerReviewWatch,
updateIssueWatch,
createTaskMR,
deleteTaskMR,
fetchGitLabStatus,
listTaskMRs,
listWorkspaceTaskMRs,
searchUserIssues,
searchUserMRs,
syncTaskMR,
getMRFeedback,
getMRFiles,
getMRCommits,
createMRDiscussionNote,
resolveMRDiscussion,
approveMR,
unapproveMR,
mergeMR,
setMRLabels,
setMRAssignees,
listProjectMembers,
setMRReviewers,
getMRSubscription,
setMRSubscription,
getIssueSubscription,
setIssueSubscription,
listMRWatches,
deleteMRWatch,
listUserProjects,
searchProjects,
listProjectBranches,
getProjectMergeMethods,
fetchGitLabStats,
updateActionPresets,
} from "./gitlab-api";
const originalFetch = global.fetch;
const SELF_MANAGED_HOST = "https://gitlab.acme.corp";
const WORKSPACE_WITH_SPACE = "workspace one";
const PROJECT_PATH = "group/project";
function mockResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("gitlab-api — auth", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
global.fetch = fetchSpy as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("fetchGitLabStatus scopes status to the selected workspace", async () => {
fetchSpy.mockResolvedValueOnce(
mockResponse({
authenticated: true,
username: "alice",
auth_method: "pat",
host: "https://gitlab.com",
token_configured: true,
required_scopes: ["api"],
}),
);
const status = await fetchGitLabStatus({ workspaceId: WORKSPACE_WITH_SPACE });
expect(fetchSpy).toHaveBeenCalledTimes(1);
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain("/api/v1/gitlab/status");
expect(url).toContain(`workspace_id=${encodeURIComponent(WORKSPACE_WITH_SPACE)}`);
expect(status.username).toBe("alice");
expect(status.auth_method).toBe("pat");
});
it("configureGitLabToken PUTs a workspace config with the token", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ configured: true }));
const result = await configureGitLabToken("glpat-123", { workspaceId: "workspace-1" });
expect(result.configured).toBe(true);
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect(init.method).toBe("PUT");
expect(JSON.parse(init.body as string)).toEqual({
host: "",
auth_method: "pat",
token: "glpat-123",
});
});
it("clearGitLabToken issues DELETE", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ cleared: true }));
await clearGitLabToken({ workspaceId: "workspace-1" });
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect(init.method).toBe("DELETE");
});
it("configureGitLabHost PUTs the workspace host", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ configured: true, host: SELF_MANAGED_HOST }));
const result = await configureGitLabHost(SELF_MANAGED_HOST, { workspaceId: "workspace-1" });
expect(result.host).toBe(SELF_MANAGED_HOST);
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect(init.method).toBe("PUT");
expect(JSON.parse(init.body as string)).toEqual({
host: SELF_MANAGED_HOST,
auth_method: "pat",
});
});
});
describe("gitlab-api — task MRs", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
global.fetch = fetchSpy as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("listWorkspaceTaskMRs encodes the workspace id and hits /workspaces/:id/task-mrs", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ task_mrs: {} }));
await listWorkspaceTaskMRs("ws id/with slash");
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain("/api/v1/gitlab/workspaces/");
expect(url).toContain(encodeURIComponent("ws id/with slash"));
expect(url).toContain("/task-mrs");
});
it("listTaskMRs encodes the task id and hits /tasks/:id/mrs", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ task_mrs: [] }));
await listTaskMRs("task/123");
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain(`/api/v1/gitlab/tasks/${encodeURIComponent("task/123")}/mrs`);
});
it("syncTaskMR POSTs the project/iid body to /tasks/:id/mrs/sync", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ id: "1", task_id: "t-1", mr_iid: 99 }));
await syncTaskMR("t-1", {
project_path: "acme/api",
iid: 99,
repository_id: "repo-a",
});
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain("/api/v1/gitlab/tasks/t-1/mrs/sync");
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({
project_path: "acme/api",
iid: 99,
repository_id: "repo-a",
});
});
it("createTaskMR scopes the explicit link to the workspace", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ id: "association-1", task_id: "task-1" }));
await createTaskMR(
{
task_id: "task-1",
repository_id: "repo-1",
mr_url: `${SELF_MANAGED_HOST}/group/project/-/merge_requests/7`,
},
"ws-1",
);
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain("/api/v1/gitlab/task-mrs?workspace_id=ws-1");
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect(init.method).toBe("POST");
});
it("deleteTaskMR scopes unlink to the workspace and association", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ deleted: true }));
await deleteTaskMR("association/1", "ws-1");
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain(`/api/v1/gitlab/task-mrs/${encodeURIComponent("association/1")}`);
expect(url).toContain("workspace_id=ws-1");
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect(init.method).toBe("DELETE");
});
});
describe("gitlab-api — user search", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn();
global.fetch = fetchSpy as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("searchUserMRs builds the query string and disables cache", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ mrs: [], total_count: 0 }));
await searchUserMRs({
workspaceId: "ws-1",
filter: "assigned_to_me",
customQuery: "labels=bug",
page: 2,
perPage: 25,
});
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain("/api/v1/gitlab/user/mrs");
expect(url).toContain("workspace_id=ws-1");
expect(url).toContain("filter=assigned_to_me");
// custom_query gets URL-encoded by URLSearchParams.
expect(url).toContain(`custom_query=${encodeURIComponent("labels=bug")}`);
expect(url).toContain("page=2");
expect(url).toContain("per_page=25");
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
// `cache` must be at the top level of fetch init; reading from
// options.init.cache (as an earlier revision did) silently no-ops.
expect(init.cache).toBe("no-store");
});
it("searchUserIssues builds the query string and disables cache", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ issues: [], total_count: 0 }));
await searchUserIssues({ workspaceId: "ws-2", filter: "created_by_me", perPage: 10 });
const url = fetchSpy.mock.calls[0]![0] as string;
expect(url).toContain("/api/v1/gitlab/user/issues");
expect(url).toContain("workspace_id=ws-2");
expect(url).toContain("filter=created_by_me");
expect(url).toContain("per_page=10");
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect(init.cache).toBe("no-store");
});
it("searchUserIssues includes the milestone param when set", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ issues: [], total_count: 0 }));
await searchUserIssues({ workspaceId: "ws-2", milestone: "Next" });
const url = fetchSpy.mock.calls[0]![0] as string;
expect(new URL(url).searchParams.get("milestone")).toBe("Next");
});
it("searchUserIssues omits the milestone param when empty, byte-identical to before", async () => {
fetchSpy.mockResolvedValueOnce(mockResponse({ issues: [], total_count: 0 }));
await searchUserIssues({ workspaceId: "ws-2", filter: "created_by_me", perPage: 10 });
const withoutMilestone = fetchSpy.mock.calls[0]![0] as string;
fetchSpy.mockResolvedValueOnce(mockResponse({ issues: [], total_count: 0 }));
await searchUserIssues({
workspaceId: "ws-2",
filter: "created_by_me",
perPage: 10,
milestone: "",
});
const withEmptyMilestone = fetchSpy.mock.calls[1]![0] as string;
expect(withEmptyMilestone).toBe(withoutMilestone);
expect(new URL(withoutMilestone).searchParams.has("milestone")).toBe(false);
});
});
describe("gitlab-api — workspace watch actions", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn().mockImplementation(() => Promise.resolve(mockResponse({})));
global.fetch = fetchSpy as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("scopes create and every review action to the workspace", async () => {
await createReviewWatch({
workspace_id: WORKSPACE_WITH_SPACE,
workflow_id: "workflow",
workflow_step_id: "step",
projects: [],
agent_profile_id: "",
executor_profile_id: "",
prompt: "Review {{mr_url}}",
repository_id: "repo",
base_branch: "main",
review_scope: "user",
custom_query: "state=opened",
poll_interval_seconds: 300,
cleanup_policy: "auto",
max_inflight_tasks: 3,
});
await triggerReviewWatch("watch/1", WORKSPACE_WITH_SPACE);
await previewResetReviewWatch("watch/1", WORKSPACE_WITH_SPACE);
for (const call of fetchSpy.mock.calls) {
expect(call[0]).toContain(`workspace_id=${encodeURIComponent(WORKSPACE_WITH_SPACE)}`);
}
});
it("scopes issue update, delete, and reset to the workspace", async () => {
await updateIssueWatch("watch/2", "ws-2", { enabled: false });
await deleteIssueWatch("watch/2", "ws-2");
await resetIssueWatch("watch/2", "ws-2");
for (const call of fetchSpy.mock.calls) {
expect(call[0]).toContain("workspace_id=ws-2");
}
});
it("scopes legacy MR watches, projects, branches, merge methods, stats, and presets", async () => {
await listMRWatches(WORKSPACE_WITH_SPACE, { taskId: "task-1" });
await deleteMRWatch("watch/1", WORKSPACE_WITH_SPACE);
await listUserProjects(WORKSPACE_WITH_SPACE);
await searchProjects(WORKSPACE_WITH_SPACE, "group project");
await listProjectBranches(WORKSPACE_WITH_SPACE, PROJECT_PATH);
await getProjectMergeMethods(WORKSPACE_WITH_SPACE, PROJECT_PATH);
await fetchGitLabStats(WORKSPACE_WITH_SPACE);
await updateActionPresets(WORKSPACE_WITH_SPACE, { mr: [] });
for (const call of fetchSpy.mock.calls) {
expect(new URL(call[0] as string).searchParams.get("workspace_id")).toBe(
WORKSPACE_WITH_SPACE,
);
}
});
it("encodes public and self-managed expected hosts when listing project branches", async () => {
const expectedHosts = ["https://gitlab.com", SELF_MANAGED_HOST];
await Promise.all(
expectedHosts.map((expectedHost) =>
listProjectBranches(WORKSPACE_WITH_SPACE, PROJECT_PATH, { expectedHost }),
),
);
expect(
fetchSpy.mock.calls.map((call) =>
new URL(call[0] as string).searchParams.get("expected_host"),
),
).toEqual(expectedHosts);
});
});
describe("gitlab-api — merge request review", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchSpy = vi.fn().mockImplementation(() => Promise.resolve(mockResponse({})));
global.fetch = fetchSpy as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("scopes every review read and write to the active workspace", async () => {
const identity = {
workspaceId: WORKSPACE_WITH_SPACE,
project: "group/sub/project",
iid: 17,
host: SELF_MANAGED_HOST,
};
await getMRFeedback(identity);
await getMRFiles(identity);
await getMRCommits(identity);
await createMRDiscussionNote({ ...identity, discussionId: "thread/1", body: "Fixed" });
await resolveMRDiscussion({ ...identity, discussionId: "thread/1" });
await approveMR(identity);
await unapproveMR(identity);
await mergeMR({ ...identity, squash: true, squashCommitMessage: "Squashed" });
await setMRLabels({ ...identity, labels: ["bug", "backend"] });
await setMRAssignees({ ...identity, assigneeIds: [12, 44] });
await listProjectMembers(identity.workspaceId, identity.project, "ali ce", identity.host);
await setMRReviewers({ ...identity, reviewerIds: [31, 45] });
await getMRSubscription(identity);
await setMRSubscription({ ...identity, subscribed: true });
await getIssueSubscription(identity);
await setIssueSubscription({ ...identity, subscribed: true });
for (const call of fetchSpy.mock.calls) {
expect(new URL(call[0] as string).searchParams.get("workspace_id")).toBe(
WORKSPACE_WITH_SPACE,
);
expect(new URL(call[0] as string).searchParams.get("expected_host")).toBe(SELF_MANAGED_HOST);
}
});
it("uses GitLab numeric IDs and discussion identity in mutation bodies", async () => {
const identity = { workspaceId: "ws-1", project: PROJECT_PATH, iid: 8 };
await createMRDiscussionNote({ ...identity, discussionId: "discussion-9", body: "Done" });
await setMRReviewers({ ...identity, reviewerIds: [101, 202] });
await setMRAssignees({ ...identity, assigneeIds: [303] });
expect(JSON.parse((fetchSpy.mock.calls[0]![1] as RequestInit).body as string)).toEqual({
project: PROJECT_PATH,
iid: 8,
discussion_id: "discussion-9",
body: "Done",
});
expect(JSON.parse((fetchSpy.mock.calls[1]![1] as RequestInit).body as string)).toMatchObject({
reviewer_ids: [101, 202],
});
expect(JSON.parse((fetchSpy.mock.calls[2]![1] as RequestInit).body as string)).toMatchObject({
assignee_ids: [303],
});
});
});