import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
} from "bun:test";
import { Command } from "@effect/cli";
import { NodeContext } from "@effect/platform-node";
import { Effect, Layer, Option } from "effect";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { _clearProjectCache } from "@/resolve";
const BASE = "http://issue-cmd-test.local";
const WS = "testws";
const PROJECTS = [
{ id: "proj-acme", identifier: "ACME", name: "Acme Project" },
];
const ISSUES = [
{
id: "i1",
sequence_id: 29,
name: "Migrate Button",
priority: "high",
state: "s1",
},
{
id: "i2",
sequence_id: 30,
name: "Migrate Input",
priority: "medium",
state: "s2",
},
];
const STATES = [
{ id: "s-done", name: "Done", group: "completed" },
{ id: "s-todo", name: "Todo", group: "unstarted" },
];
const MEMBERS = [
{
id: "m-alice",
display_name: "Alice",
email: "alice@example.com",
},
{ id: "m-bob", display_name: "Bob", email: "bob@example.com" },
];
const server = setupServer(
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/`, () =>
HttpResponse.json({ results: PROJECTS }),
),
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`, () =>
HttpResponse.json({ results: ISSUES }),
),
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/states/`, () =>
HttpResponse.json({ results: STATES }),
),
http.get(`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/labels/`, () =>
HttpResponse.json({
results: [{ id: "l-bug", name: "Bug", color: "#ff0000" }],
}),
),
http.get(`${BASE}/api/v1/workspaces/${WS}/members/`, () =>
HttpResponse.json(MEMBERS),
),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterAll(() => server.close());
beforeEach(() => {
_clearProjectCache();
process.env["PLANE_HOST"] = BASE;
process.env["PLANE_WORKSPACE"] = WS;
process.env["PLANE_API_TOKEN"] = "test-token";
});
afterEach(() => {
server.resetHandlers();
delete process.env["PLANE_HOST"];
delete process.env["PLANE_WORKSPACE"];
delete process.env["PLANE_API_TOKEN"];
});
describe("issueGet", () => {
it("prints full JSON for an issue", async () => {
const { issueGetHandler } = await import("@/commands/issue");
const logs: string[] = [];
const orig = console.log;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
try {
await Effect.runPromise(issueGetHandler({ ref: "ACME-29" }));
} finally {
console.log = orig;
}
const output = logs.join("\n");
const parsed = JSON.parse(output);
expect(parsed.id).toBe("i1");
expect(parsed.name).toBe("Migrate Button");
});
});
describe("issuesList", () => {
it("filters by state group", async () => {
server.use(
http.get(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`,
() =>
HttpResponse.json({
results: [
{
id: "i-state-1",
sequence_id: 1,
name: "Done issue",
priority: "medium",
state: { id: "s-done", name: "Done", group: "completed" },
assignees: ["m-alice"],
},
{
id: "i-state-2",
sequence_id: 2,
name: "Todo issue",
priority: "medium",
state: { id: "s-todo", name: "Todo", group: "unstarted" },
assignees: ["m-bob"],
},
],
}),
),
);
const { issuesListHandler } = await import("@/commands/issues");
const logs: string[] = [];
const orig = console.log;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
try {
await Effect.runPromise(
issuesListHandler({
project: "ACME",
state: Option.some("completed"),
assignee: Option.none(),
priority: Option.none(),
}),
);
} finally {
console.log = orig;
}
const output = logs.join("\n");
expect(output).toContain("Done issue");
expect(output).not.toContain("Todo issue");
});
it("filters by assignee (email)", async () => {
server.use(
http.get(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`,
() =>
HttpResponse.json({
results: [
{
id: "i-assignee-1",
sequence_id: 3,
name: "Alice issue",
priority: "medium",
state: { id: "s-done", name: "Done", group: "completed" },
assignees: ["m-alice"],
},
{
id: "i-assignee-2",
sequence_id: 4,
name: "Bob issue",
priority: "medium",
state: { id: "s-todo", name: "Todo", group: "unstarted" },
assignees: ["m-bob"],
},
],
}),
),
);
const { issuesListHandler } = await import("@/commands/issues");
const logs: string[] = [];
const orig = console.log;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
try {
await Effect.runPromise(
issuesListHandler({
project: "ACME",
state: Option.none(),
assignee: Option.some("alice@example.com"),
priority: Option.none(),
}),
);
} finally {
console.log = orig;
}
const output = logs.join("\n");
expect(output).toContain("Alice issue");
expect(output).not.toContain("Bob issue");
});
it("filters by priority", async () => {
server.use(
http.get(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/`,
() =>
HttpResponse.json({
results: [
{
id: "i-priority-1",
sequence_id: 5,
name: "Urgent fix",
priority: "urgent",
state: { id: "s-done", name: "Done", group: "completed" },
assignees: ["m-alice"],
},
{
id: "i-priority-2",
sequence_id: 6,
name: "Low cleanup",
priority: "low",
state: { id: "s-todo", name: "Todo", group: "unstarted" },
assignees: ["m-bob"],
},
],
}),
),
);
const { issuesListHandler } = await import("@/commands/issues");
const logs: string[] = [];
const orig = console.log;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
try {
await Effect.runPromise(
issuesListHandler({
project: "ACME",
state: Option.none(),
assignee: Option.none(),
priority: Option.some("urgent"),
}),
);
} finally {
console.log = orig;
}
const output = logs.join("\n");
expect(output).toContain("Urgent fix");
expect(output).not.toContain("Low cleanup");
});
});
describe("issueUpdate", () => {
it("updates state", async () => {
server.use(
http.patch(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`,
async ({ request }) => {
const body = (await request.json()) as { state?: string };
return HttpResponse.json({
id: "i1",
sequence_id: 29,
name: "Migrate Button",
priority: "high",
state: body.state ?? "s1",
});
},
),
);
const { issueUpdateHandler } = await import("@/commands/issue");
const logs: string[] = [];
const orig = console.log;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
try {
await Effect.runPromise(
issueUpdateHandler({
ref: "ACME-29",
state: Option.some("completed"),
priority: Option.none(),
title: Option.none(),
description: Option.none(),
assignee: Option.none(),
label: Option.none(),
noAssignee: false,
}),
);
} finally {
console.log = orig;
}
expect(logs.join("\n")).toContain("Updated ACME-29");
});
it("updates priority", async () => {
server.use(
http.patch(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`,
async ({ request }) => {
const body = (await request.json()) as { priority?: string };
return HttpResponse.json({
id: "i1",
sequence_id: 29,
name: "Migrate Button",
priority: body.priority,
state: "s1",
});
},
),
);
const { issueUpdateHandler } = await import("@/commands/issue");
const logs: string[] = [];
const orig = console.log;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
try {
await Effect.runPromise(
issueUpdateHandler({
ref: "ACME-29",
state: Option.none(),
priority: Option.some("urgent"),
title: Option.none(),
description: Option.none(),
assignee: Option.none(),
label: Option.none(),
noAssignee: false,
}),
);
} finally {
console.log = orig;
}
expect(logs.join("\n")).toContain("urgent");
});
it("fails when nothing to update", async () => {
const { issueUpdateHandler } = await import("@/commands/issue");
const result = await Effect.runPromise(
Effect.either(
issueUpdateHandler({
ref: "ACME-29",
state: Option.none(),
priority: Option.none(),
title: Option.none(),
description: Option.none(),
assignee: Option.none(),
label: Option.none(),
noAssignee: false,
}),
),
);
expect(result._tag).toBe("Left");
if (result._tag === "Left") {
expect((result.left as Error).message).toContain("Nothing to update");
}
});
it("updates title", async () => {
let patchedBody: unknown;
server.use(
http.patch(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/`,
async ({ request }) => {
patchedBody = await request.json();
return HttpResponse.json({
id: "i1",
sequence_id: 29,
name: "New title",
priority: "high",
state: "s1",
});
},
),
);
const { issueUpdateHandler } = await import("@/commands/issue");
await Effect.runPromise(
issueUpdateHandler({
ref: "ACME-29",
state: Option.none(),
priority: Option.none(),
title: Option.some("New title"),
description: Option.none(),
assignee: Option.none(),
label: Option.none(),
noAssignee: false,
}),
);
expect((patchedBody as { name?: string }).name).toBe("New title");
});
});
describe("issueComment", () => {
it("adds a comment", async () => {
let postedBody: unknown;
server.use(
http.post(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`,
async ({ request }) => {
postedBody = await request.json();
return HttpResponse.json({ id: "c1" }, { status: 201 });
},
),
);
const { issueCommentHandler } = await import("@/commands/issue");
const logs: string[] = [];
const orig = console.log;
console.log = (...args: unknown[]) => logs.push(args.join(" "));
try {
await Effect.runPromise(
issueCommentHandler({
ref: "ACME-29",
text: "Fixed in latest build",
}),
);
} finally {
console.log = orig;
}
expect((postedBody as { comment_html?: string }).comment_html).toContain(
"Fixed in latest build",
);
expect(logs.join("\n")).toContain("Comment added to ACME-29");
});
it("HTML-escapes angle brackets in comment text", async () => {
let postedBody: unknown;
server.use(
http.post(
`${BASE}/api/v1/workspaces/${WS}/projects/proj-acme/issues/i1/comments/`,
async ({ request }) => {
postedBody = await request.json();
return HttpResponse.json({ id: "c2" }, { status: 201 });
},
),
);
const { issueCommentHandler } = await import("@/commands/issue");
try {
await Effect.runPromise(
issueCommentHandler({
ref: "ACME-29",
text: "",
}),
);
} finally {
}
expect((postedBody as { comment_html?: string }).comment_html).toContain(
"<script>",
);
expect(
(postedBody as { comment_html?: string }).comment_html,
).not.toContain("