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[0]; type FetchInit = Parameters[1]; const fetchSpy = vi.fn<(...args: [FetchInput, FetchInit?]) => Promise>(); 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); }); });