Files
2026-07-15 07:50:49 +01:00

284 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Pin the backend config to a deterministic base so URL assertions don't
// depend on whatever environment the tests inherit.
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }),
}));
import {
copyLinearConfig,
createLinearIssueWatch,
deleteLinearConfig,
deleteLinearIssueWatch,
getLinearConfig,
getLinearIssue,
listLinearIssueWatches,
listLinearStates,
listLinearTeams,
searchLinearIssues,
setLinearConfig,
setLinearIssueState,
testLinearConnection,
triggerLinearIssueWatch,
updateLinearIssueWatch,
} from "./linear-api";
const BASE = "http://api.test/api/v1/linear";
const CONFIG_URL = `${BASE}/config`;
const AUTH = "api_key" as const;
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, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
function noContent(): Response {
return new Response(null, { status: 204 });
}
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("getLinearConfig", () => {
it("returns null on 204 No Content (not configured yet)", async () => {
fetchSpy.mockResolvedValueOnce(noContent());
const cfg = await getLinearConfig();
expect(cfg).toBeNull();
});
it("hits the install-wide /config route with no query params", async () => {
fetchSpy.mockResolvedValueOnce(noContent());
await getLinearConfig();
expect(lastCall().url).toBe(CONFIG_URL);
});
it("scopes config reads to a workspace when provided", async () => {
fetchSpy.mockResolvedValueOnce(noContent());
await getLinearConfig({ workspaceId: "ws-123" });
expect(lastCall().url).toBe(`${CONFIG_URL}?workspace_id=ws-123`);
});
it("returns the parsed config on 200", async () => {
fetchSpy.mockResolvedValueOnce(
jsonResponse({
authMethod: AUTH,
defaultTeamKey: "ENG",
hasSecret: true,
lastOk: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
}),
);
const cfg = await getLinearConfig();
expect(cfg?.defaultTeamKey).toBe("ENG");
});
});
describe("setLinearConfig", () => {
it("POSTs the payload to /api/v1/linear/config", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ authMethod: AUTH }));
await setLinearConfig({ authMethod: AUTH, secret: "tok" });
const { url, init } = lastCall();
expect(url).toBe(CONFIG_URL);
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toMatchObject({
authMethod: AUTH,
secret: "tok",
});
});
});
describe("copyLinearConfig", () => {
it("POSTs targetWorkspaceId to /config/copy scoped to the source workspace", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ authMethod: AUTH }));
await copyLinearConfig("ws-dst", { workspaceId: "ws-src" });
const { url, init } = lastCall();
expect(url).toBe(`${CONFIG_URL}/copy?workspace_id=ws-src`);
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toEqual({ targetWorkspaceId: "ws-dst" });
});
});
describe("deleteLinearConfig", () => {
it("issues DELETE on /config", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ deleted: true }));
await deleteLinearConfig();
const { url, init } = lastCall();
expect(url).toBe(CONFIG_URL);
expect(init?.method).toBe("DELETE");
});
});
describe("testLinearConnection", () => {
it("POSTs to /api/v1/linear/config/test", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ ok: true }));
await testLinearConnection({ authMethod: AUTH, secret: "x" });
const { url, init } = lastCall();
expect(url).toBe(`${CONFIG_URL}/test`);
expect(init?.method).toBe("POST");
});
});
describe("listLinearTeams + listLinearStates", () => {
it("listLinearTeams targets /api/v1/linear/teams", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ teams: [] }));
await listLinearTeams();
expect(lastCall().url).toBe(`${BASE}/teams`);
});
it("listLinearStates includes the team_key", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ states: [] }));
await listLinearStates("ENG");
expect(lastCall().url).toBe(`${BASE}/states?team_key=ENG`);
});
it("keeps existing query params when appending workspace_id", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ states: [] }));
await listLinearStates("ENG", { workspaceId: "ws-123" });
expect(lastCall().url).toBe(`${BASE}/states?team_key=ENG&workspace_id=ws-123`);
});
});
describe("searchLinearIssues", () => {
it("joins stateIds as a CSV in the state_ids query param", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ issues: [], maxResults: 25, isLast: true }));
await searchLinearIssues({ stateIds: ["s1", "s2", "s3"] });
const { url } = lastCall();
expect(url).toContain("state_ids=s1%2Cs2%2Cs3");
});
it("omits empty optional filters from the URL", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ issues: [], maxResults: 25, isLast: true }));
await searchLinearIssues({});
const { url } = lastCall();
expect(url).toBe(`${BASE}/issues`);
});
it("encodes a multi-word query string", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ issues: [], maxResults: 25, isLast: true }));
await searchLinearIssues({ query: "fix login & signup" });
const { url } = lastCall();
// URLSearchParams uses + for spaces, %26 for & — both indicate proper encoding.
expect(url).toContain("query=fix+login+%26+signup");
});
});
describe("getLinearIssue", () => {
it("URL-encodes the identifier as a path segment", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "i1", identifier: "ENG/1" }));
await getLinearIssue("ENG/1");
expect(lastCall().url).toBe(`${BASE}/issues/ENG%2F1`);
});
});
describe("setLinearIssueState", () => {
it("POSTs { stateId } in the body to the issue's /state route", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ transitioned: true }));
await setLinearIssueState("ENG-1", "state-id-123");
const { url, init } = lastCall();
expect(url).toBe(`${BASE}/issues/ENG-1/state`);
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toEqual({ stateId: "state-id-123" });
});
});
// Issue watches: workspace_id is a query param on list/update/delete/trigger
// but absent on create — exactly the kind of subtle URL-construction
// difference that's worth catching at the API client boundary.
describe("listLinearIssueWatches", () => {
it("hits /watches/issue with no query param when workspaceId omitted", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ watches: [] }));
await listLinearIssueWatches();
expect(lastCall().url).toBe(`${BASE}/watches/issue`);
});
it("appends workspace_id query param when provided", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ watches: [] }));
await listLinearIssueWatches("ws-1");
expect(lastCall().url).toBe(`${BASE}/watches/issue?workspace_id=ws-1`);
});
it("returns an empty array when the response has no watches field", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({}));
await expect(listLinearIssueWatches()).resolves.toEqual([]);
});
});
describe("createLinearIssueWatch", () => {
it("POSTs to /watches/issue without workspace_id in the URL", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "w1" }));
await createLinearIssueWatch({
workspaceId: "ws-1",
workflowId: "wf-1",
workflowStepId: "step-1",
filter: { teamKey: "ENG" },
});
const { url, init } = lastCall();
expect(url).toBe(`${BASE}/watches/issue`);
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toMatchObject({
workspaceId: "ws-1",
filter: { teamKey: "ENG" },
});
});
});
describe("updateLinearIssueWatch", () => {
it("PATCHes /watches/issue/:id with workspace_id query param", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "w1" }));
await updateLinearIssueWatch("ws-1", "w1", { enabled: false });
const { url, init } = lastCall();
expect(url).toBe(`${BASE}/watches/issue/w1?workspace_id=ws-1`);
expect(init?.method).toBe("PATCH");
expect(JSON.parse(String(init?.body))).toEqual({ enabled: false });
});
it("URL-encodes both id and workspace_id", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ id: "w/1" }));
await updateLinearIssueWatch("ws/space", "w/1", {});
expect(lastCall().url).toBe(`${BASE}/watches/issue/w%2F1?workspace_id=ws%2Fspace`);
});
});
describe("deleteLinearIssueWatch", () => {
it("issues DELETE on /watches/issue/:id with workspace_id query param", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ deleted: true }));
await deleteLinearIssueWatch("ws-1", "w1");
const { url, init } = lastCall();
expect(url).toBe(`${BASE}/watches/issue/w1?workspace_id=ws-1`);
expect(init?.method).toBe("DELETE");
});
});
describe("triggerLinearIssueWatch", () => {
it("POSTs to /watches/issue/:id/trigger with workspace_id query param", async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({ newIssues: 3 }));
const res = await triggerLinearIssueWatch("ws-1", "w1");
const { url, init } = lastCall();
expect(url).toBe(`${BASE}/watches/issue/w1/trigger?workspace_id=ws-1`);
expect(init?.method).toBe("POST");
expect(res.newIssues).toBe(3);
});
});