agent-orchestrator/packages/plugins/tracker-linear/test/index.test.ts

867 lines
28 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { EventEmitter } from "node:events";
// ---------------------------------------------------------------------------
// Mock node:https
// ---------------------------------------------------------------------------
const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() }));
vi.mock("node:https", () => ({
request: requestMock,
}));
import { create, manifest } from "../src/index.js";
import type { ProjectConfig } from "@agent-orchestrator/core";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const project: ProjectConfig = {
name: "test",
repo: "acme/integrator",
path: "/tmp/repo",
defaultBranch: "main",
sessionPrefix: "test",
tracker: {
plugin: "linear",
teamId: "team-uuid-1",
workspaceSlug: "acme",
},
};
const projectNoSlug: ProjectConfig = {
...project,
tracker: { plugin: "linear", teamId: "team-uuid-1" },
};
const sampleIssueNode = {
id: "uuid-123",
identifier: "INT-123",
title: "Fix login bug",
description: "Users can't log in with SSO",
url: "https://linear.app/acme/issue/INT-123",
priority: 2,
state: { name: "In Progress", type: "started" },
labels: { nodes: [{ name: "bug" }, { name: "high-priority" }] },
assignee: { name: "Alice Smith", displayName: "Alice" },
team: { key: "INT" },
};
// ---------------------------------------------------------------------------
// Mock helpers
// ---------------------------------------------------------------------------
/**
* Queue a successful Linear API response.
* Each call to linearQuery() will consume the next queued response.
*/
function mockLinearAPI(responseData: unknown, statusCode = 200) {
const body = JSON.stringify({ data: responseData });
requestMock.mockImplementationOnce(
(_opts: Record<string, unknown>, callback: (res: EventEmitter & { statusCode: number }) => void) => {
const req = Object.assign(new EventEmitter(), {
write: vi.fn(),
end: vi.fn(() => {
const res = Object.assign(new EventEmitter(), { statusCode });
callback(res);
process.nextTick(() => {
res.emit("data", Buffer.from(body));
res.emit("end");
});
}),
destroy: vi.fn(),
setTimeout: vi.fn(),
});
return req;
},
);
}
/** Queue a Linear API error response (GraphQL errors array). */
function mockLinearError(message: string) {
const body = JSON.stringify({ errors: [{ message }] });
requestMock.mockImplementationOnce(
(_opts: Record<string, unknown>, callback: (res: EventEmitter & { statusCode: number }) => void) => {
const req = Object.assign(new EventEmitter(), {
write: vi.fn(),
end: vi.fn(() => {
const res = Object.assign(new EventEmitter(), { statusCode: 200 });
callback(res);
process.nextTick(() => {
res.emit("data", Buffer.from(body));
res.emit("end");
});
}),
destroy: vi.fn(),
setTimeout: vi.fn(),
});
return req;
},
);
}
/** Queue an HTTP-level error (non-200 status). */
function mockHTTPError(statusCode: number, body: string) {
requestMock.mockImplementationOnce(
(_opts: Record<string, unknown>, callback: (res: EventEmitter & { statusCode: number }) => void) => {
const req = Object.assign(new EventEmitter(), {
write: vi.fn(),
end: vi.fn(() => {
const res = Object.assign(new EventEmitter(), { statusCode });
callback(res);
process.nextTick(() => {
res.emit("data", Buffer.from(body));
res.emit("end");
});
}),
destroy: vi.fn(),
setTimeout: vi.fn(),
});
return req;
},
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("tracker-linear plugin", () => {
let tracker: ReturnType<typeof create>;
let savedApiKey: string | undefined;
beforeEach(() => {
vi.clearAllMocks();
savedApiKey = process.env["LINEAR_API_KEY"];
process.env["LINEAR_API_KEY"] = "lin_api_test_key";
tracker = create();
});
afterEach(() => {
if (savedApiKey === undefined) {
delete process.env["LINEAR_API_KEY"];
} else {
process.env["LINEAR_API_KEY"] = savedApiKey;
}
});
// ---- manifest ----------------------------------------------------------
describe("manifest", () => {
it("has correct metadata", () => {
expect(manifest.name).toBe("linear");
expect(manifest.slot).toBe("tracker");
expect(manifest.version).toBe("0.1.0");
});
});
describe("create()", () => {
it("returns a Tracker with correct name", () => {
expect(tracker.name).toBe("linear");
});
});
// ---- getIssue ----------------------------------------------------------
describe("getIssue", () => {
it("returns Issue with correct fields", async () => {
mockLinearAPI({ issue: sampleIssueNode });
const issue = await tracker.getIssue("INT-123", project);
expect(issue).toEqual({
id: "INT-123",
title: "Fix login bug",
description: "Users can't log in with SSO",
url: "https://linear.app/acme/issue/INT-123",
state: "in_progress",
labels: ["bug", "high-priority"],
assignee: "Alice",
priority: 2,
});
});
it("maps completed state to closed", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, state: { name: "Done", type: "completed" } },
});
const issue = await tracker.getIssue("INT-123", project);
expect(issue.state).toBe("closed");
});
it("maps canceled state to cancelled", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, state: { name: "Canceled", type: "canceled" } },
});
const issue = await tracker.getIssue("INT-123", project);
expect(issue.state).toBe("cancelled");
});
it("maps backlog/triage/unstarted to open", async () => {
for (const type of ["backlog", "triage", "unstarted"]) {
mockLinearAPI({
issue: { ...sampleIssueNode, state: { name: type, type } },
});
const issue = await tracker.getIssue("INT-123", project);
expect(issue.state).toBe("open");
}
});
it("handles null description", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, description: null },
});
const issue = await tracker.getIssue("INT-123", project);
expect(issue.description).toBe("");
});
it("handles null assignee", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, assignee: null },
});
const issue = await tracker.getIssue("INT-123", project);
expect(issue.assignee).toBeUndefined();
});
it("uses assignee name as fallback when displayName is missing", async () => {
mockLinearAPI({
issue: {
...sampleIssueNode,
assignee: { name: "Alice Smith", displayName: undefined },
},
});
const issue = await tracker.getIssue("INT-123", project);
// undefined displayName falls through to name via ??
expect(issue.assignee).toBe("Alice Smith");
});
it("handles empty labels", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, labels: { nodes: [] } },
});
const issue = await tracker.getIssue("INT-123", project);
expect(issue.labels).toEqual([]);
});
it("propagates API errors", async () => {
mockLinearError("Issue not found");
await expect(tracker.getIssue("INT-999", project)).rejects.toThrow(
"Linear API error: Issue not found",
);
});
it("throws when LINEAR_API_KEY is missing", async () => {
delete process.env["LINEAR_API_KEY"];
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"LINEAR_API_KEY environment variable is required",
);
});
it("throws on HTTP errors", async () => {
mockHTTPError(500, "Internal Server Error");
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"Linear API returned HTTP 500",
);
});
});
// ---- isCompleted -------------------------------------------------------
describe("isCompleted", () => {
it("returns true for completed state", async () => {
mockLinearAPI({ issue: { state: { type: "completed" } } });
expect(await tracker.isCompleted("INT-123", project)).toBe(true);
});
it("returns true for canceled state", async () => {
mockLinearAPI({ issue: { state: { type: "canceled" } } });
expect(await tracker.isCompleted("INT-123", project)).toBe(true);
});
it("returns false for started state", async () => {
mockLinearAPI({ issue: { state: { type: "started" } } });
expect(await tracker.isCompleted("INT-123", project)).toBe(false);
});
it("returns false for unstarted state", async () => {
mockLinearAPI({ issue: { state: { type: "unstarted" } } });
expect(await tracker.isCompleted("INT-123", project)).toBe(false);
});
});
// ---- issueUrl ----------------------------------------------------------
describe("issueUrl", () => {
it("generates correct URL with workspace slug", () => {
expect(tracker.issueUrl("INT-123", project)).toBe(
"https://linear.app/acme/issue/INT-123",
);
});
it("generates fallback URL without workspace slug", () => {
expect(tracker.issueUrl("INT-123", projectNoSlug)).toBe(
"https://linear.app/issue/INT-123",
);
});
it("generates fallback URL when no tracker config", () => {
const noTracker: ProjectConfig = { ...project, tracker: undefined };
expect(tracker.issueUrl("INT-123", noTracker)).toBe(
"https://linear.app/issue/INT-123",
);
});
});
// ---- branchName --------------------------------------------------------
describe("branchName", () => {
it("generates feat/ prefix branch name", () => {
expect(tracker.branchName("INT-123", project)).toBe("feat/INT-123");
});
});
// ---- generatePrompt ----------------------------------------------------
describe("generatePrompt", () => {
it("includes title, URL, and description", async () => {
mockLinearAPI({ issue: sampleIssueNode });
const prompt = await tracker.generatePrompt("INT-123", project);
expect(prompt).toContain("INT-123");
expect(prompt).toContain("Fix login bug");
expect(prompt).toContain("https://linear.app/acme/issue/INT-123");
expect(prompt).toContain("Users can't log in with SSO");
});
it("includes labels when present", async () => {
mockLinearAPI({ issue: sampleIssueNode });
const prompt = await tracker.generatePrompt("INT-123", project);
expect(prompt).toContain("bug, high-priority");
});
it("includes priority", async () => {
mockLinearAPI({ issue: sampleIssueNode });
const prompt = await tracker.generatePrompt("INT-123", project);
expect(prompt).toContain("High");
});
it("maps priority numbers to names", async () => {
const priorities: Record<number, string> = {
0: "No priority",
1: "Urgent",
2: "High",
3: "Normal",
4: "Low",
};
for (const [num, name] of Object.entries(priorities)) {
mockLinearAPI({
issue: { ...sampleIssueNode, priority: Number(num) },
});
const prompt = await tracker.generatePrompt("INT-123", project);
expect(prompt).toContain(name);
}
});
it("omits description section when empty", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, description: null },
});
const prompt = await tracker.generatePrompt("INT-123", project);
expect(prompt).not.toContain("## Description");
});
it("omits labels line when no labels", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, labels: { nodes: [] } },
});
const prompt = await tracker.generatePrompt("INT-123", project);
expect(prompt).not.toContain("Labels:");
});
});
// ---- listIssues --------------------------------------------------------
describe("listIssues", () => {
it("returns mapped issues", async () => {
mockLinearAPI({
issues: {
nodes: [
sampleIssueNode,
{ ...sampleIssueNode, identifier: "INT-456", title: "Another" },
],
},
});
const issues = await tracker.listIssues!({}, project);
expect(issues).toHaveLength(2);
expect(issues[0].id).toBe("INT-123");
expect(issues[1].id).toBe("INT-456");
});
it("passes state filter for open issues", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({ state: "open" }, project);
// Verify the request body contains the correct filter
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.filter.state).toEqual({
type: { nin: ["completed", "canceled"] },
});
});
it("passes state filter for closed issues", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({ state: "closed" }, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.filter.state).toEqual({
type: { in: ["completed", "canceled"] },
});
});
it("defaults to open state when no state specified", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({}, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.filter.state).toEqual({
type: { nin: ["completed", "canceled"] },
});
});
it("passes assignee filter", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({ assignee: "Alice" }, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.filter.assignee).toEqual({
displayName: { eq: "Alice" },
});
});
it("passes labels filter", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({ labels: ["bug", "urgent"] }, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.filter.labels).toEqual({
name: { in: ["bug", "urgent"] },
});
});
it("passes team filter from project config", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({}, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.filter.team).toEqual({
id: { eq: "team-uuid-1" },
});
});
it("respects custom limit", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({ limit: 5 }, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.first).toBe(5);
});
it("defaults limit to 30", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({}, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.first).toBe(30);
});
});
// ---- updateIssue -------------------------------------------------------
describe("updateIssue", () => {
const workflowStates = {
workflowStates: {
nodes: [
{ id: "state-1", name: "Todo", type: "unstarted" },
{ id: "state-2", name: "In Progress", type: "started" },
{ id: "state-3", name: "Done", type: "completed" },
],
},
};
it("changes state to closed (completed)", async () => {
// 1st: resolve identifier to UUID
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
// 2nd: fetch workflow states
mockLinearAPI(workflowStates);
// 3rd: issueUpdate mutation
mockLinearAPI({ issueUpdate: { success: true } });
await tracker.updateIssue!("INT-123", { state: "closed" }, project);
expect(requestMock).toHaveBeenCalledTimes(3);
});
it("changes state to open (unstarted)", async () => {
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
mockLinearAPI(workflowStates);
mockLinearAPI({ issueUpdate: { success: true } });
await tracker.updateIssue!("INT-123", { state: "open" }, project);
// Verify the mutation uses the unstarted state ID
const writeCall = requestMock.mock.results[2].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.stateId).toBe("state-1");
});
it("changes state to in_progress (started)", async () => {
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
mockLinearAPI(workflowStates);
mockLinearAPI({ issueUpdate: { success: true } });
await tracker.updateIssue!("INT-123", { state: "in_progress" }, project);
const writeCall = requestMock.mock.results[2].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.stateId).toBe("state-2");
});
it("throws when target workflow state is not found", async () => {
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
// Return states without "completed"
mockLinearAPI({
workflowStates: {
nodes: [{ id: "state-1", name: "Todo", type: "unstarted" }],
},
});
await expect(
tracker.updateIssue!("INT-123", { state: "closed" }, project),
).rejects.toThrow('No workflow state of type "completed"');
});
it("adds a comment", async () => {
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
mockLinearAPI({ commentCreate: { success: true } });
await tracker.updateIssue!(
"INT-123",
{ comment: "Working on this" },
project,
);
expect(requestMock).toHaveBeenCalledTimes(2);
// Verify comment body
const writeCall = requestMock.mock.results[1].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.body).toBe("Working on this");
});
it("handles state change + comment together", async () => {
// 1: resolve identifier
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
// 2: workflow states
mockLinearAPI(workflowStates);
// 3: issueUpdate (state)
mockLinearAPI({ issueUpdate: { success: true } });
// 4: commentCreate
mockLinearAPI({ commentCreate: { success: true } });
await tracker.updateIssue!(
"INT-123",
{ state: "closed", comment: "Done!" },
project,
);
expect(requestMock).toHaveBeenCalledTimes(4);
});
it("updates assignee by resolving display name to ID", async () => {
// 1: resolve identifier
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
// 2: user lookup
mockLinearAPI({
users: { nodes: [{ id: "user-1", displayName: "Alice", name: "Alice Smith" }] },
});
// 3: issueUpdate (assignee)
mockLinearAPI({ issueUpdate: { success: true } });
await tracker.updateIssue!("INT-123", { assignee: "Alice" }, project);
expect(requestMock).toHaveBeenCalledTimes(3);
const writeCall = requestMock.mock.results[2].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.assigneeId).toBe("user-1");
});
it("updates labels additively (merges with existing)", async () => {
// 1: resolve identifier
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
// 2: fetch existing labels on the issue
mockLinearAPI({ issue: { labels: { nodes: [{ id: "label-existing" }] } } });
// 3: team label lookup
mockLinearAPI({
issueLabels: {
nodes: [
{ id: "label-1", name: "bug" },
{ id: "label-2", name: "urgent" },
],
},
});
// 4: issueUpdate (labels)
mockLinearAPI({ issueUpdate: { success: true } });
await tracker.updateIssue!("INT-123", { labels: ["bug", "urgent"] }, project);
expect(requestMock).toHaveBeenCalledTimes(4);
const writeCall = requestMock.mock.results[3].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
// Should include existing + new labels
expect(body.variables.labelIds).toEqual(
expect.arrayContaining(["label-existing", "label-1", "label-2"]),
);
expect(body.variables.labelIds).toHaveLength(3);
});
});
// ---- createIssue -------------------------------------------------------
describe("createIssue", () => {
it("creates a basic issue", async () => {
mockLinearAPI({
issueCreate: { success: true, issue: sampleIssueNode },
});
const issue = await tracker.createIssue!(
{ title: "Fix login bug", description: "Desc" },
project,
);
expect(issue).toMatchObject({
id: "INT-123",
title: "Fix login bug",
state: "in_progress",
});
});
it("passes priority to mutation", async () => {
mockLinearAPI({
issueCreate: { success: true, issue: sampleIssueNode },
});
await tracker.createIssue!(
{ title: "Bug", description: "", priority: 1 },
project,
);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.priority).toBe(1);
});
it("resolves assignee by display name after creation", async () => {
// 1: create issue
mockLinearAPI({
issueCreate: {
success: true,
issue: { ...sampleIssueNode, assignee: null },
},
});
// 2: look up user by display name
mockLinearAPI({
users: { nodes: [{ id: "user-1", displayName: "Alice", name: "Alice Smith" }] },
});
// 3: issueUpdate to assign
mockLinearAPI({ issueUpdate: { success: true } });
const issue = await tracker.createIssue!(
{ title: "Bug", description: "", assignee: "Alice" },
project,
);
expect(issue.assignee).toBe("Alice");
expect(requestMock).toHaveBeenCalledTimes(3);
});
it("skips assignee when user not found", async () => {
mockLinearAPI({
issueCreate: {
success: true,
issue: { ...sampleIssueNode, assignee: null },
},
});
// User lookup returns empty
mockLinearAPI({ users: { nodes: [] } });
const issue = await tracker.createIssue!(
{ title: "Bug", description: "", assignee: "Unknown" },
project,
);
expect(issue.assignee).toBeUndefined();
// Only 2 calls: create + user lookup (no update since user not found)
expect(requestMock).toHaveBeenCalledTimes(2);
});
it("adds labels after creation", async () => {
mockLinearAPI({
issueCreate: {
success: true,
issue: { ...sampleIssueNode, labels: { nodes: [] } },
},
});
// Label lookup
mockLinearAPI({
issueLabels: {
nodes: [
{ id: "label-1", name: "bug" },
{ id: "label-2", name: "urgent" },
{ id: "label-3", name: "other" },
],
},
});
// issueUpdate to set labels
mockLinearAPI({ issueUpdate: { success: true } });
const issue = await tracker.createIssue!(
{ title: "Bug", description: "", labels: ["bug", "urgent"] },
project,
);
expect(issue.labels).toEqual(["bug", "urgent"]);
// Verify label IDs sent
const writeCall = requestMock.mock.results[2].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.labelIds).toEqual(["label-1", "label-2"]);
});
it("only reflects actually-applied labels when some don't exist", async () => {
mockLinearAPI({
issueCreate: {
success: true,
issue: { ...sampleIssueNode, labels: { nodes: [] } },
},
});
// Only "bug" exists in Linear; "nonexistent" does not
mockLinearAPI({
issueLabels: {
nodes: [
{ id: "label-1", name: "bug" },
],
},
});
mockLinearAPI({ issueUpdate: { success: true } });
const issue = await tracker.createIssue!(
{ title: "Bug", description: "", labels: ["bug", "nonexistent"] },
project,
);
// Should only include the label that was actually found and applied
expect(issue.labels).toEqual(["bug"]);
});
it("throws when teamId is missing from config", async () => {
const noTeam: ProjectConfig = {
...project,
tracker: { plugin: "linear" },
};
await expect(
tracker.createIssue!({ title: "Bug", description: "" }, noTeam),
).rejects.toThrow("teamId");
});
it("handles assignee error gracefully (best-effort)", async () => {
mockLinearAPI({
issueCreate: {
success: true,
issue: { ...sampleIssueNode, assignee: null },
},
});
// User lookup fails
mockLinearError("Internal error");
const issue = await tracker.createIssue!(
{ title: "Bug", description: "", assignee: "Alice" },
project,
);
// Should still return the issue without assignee
expect(issue).toMatchObject({ id: "INT-123" });
expect(issue.assignee).toBeUndefined();
});
it("handles label error gracefully (best-effort)", async () => {
mockLinearAPI({
issueCreate: {
success: true,
issue: { ...sampleIssueNode, labels: { nodes: [] } },
},
});
// Label lookup fails
mockLinearError("Internal error");
const issue = await tracker.createIssue!(
{ title: "Bug", description: "", labels: ["bug"] },
project,
);
// Should still return the issue without labels
expect(issue).toMatchObject({ id: "INT-123" });
expect(issue.labels).toEqual([]);
});
});
// ---- linearQuery error handling ----------------------------------------
describe("linearQuery error handling", () => {
it("throws on missing LINEAR_API_KEY", async () => {
delete process.env["LINEAR_API_KEY"];
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"LINEAR_API_KEY",
);
});
it("throws on GraphQL errors", async () => {
mockLinearError("You do not have access");
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"Linear API error: You do not have access",
);
});
it("throws on HTTP error status", async () => {
mockHTTPError(401, "Unauthorized");
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"Linear API returned HTTP 401",
);
});
it("throws on empty data response", async () => {
const body = JSON.stringify({ data: null });
requestMock.mockImplementationOnce(
(_opts: Record<string, unknown>, callback: (res: EventEmitter & { statusCode: number }) => void) => {
const req = Object.assign(new EventEmitter(), {
write: vi.fn(),
end: vi.fn(() => {
const res = Object.assign(new EventEmitter(), { statusCode: 200 });
callback(res);
process.nextTick(() => {
res.emit("data", Buffer.from(body));
res.emit("end");
});
}),
destroy: vi.fn(),
setTimeout: vi.fn(),
});
return req;
},
);
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"Linear API returned no data",
);
});
});
});