feat(FN-3739): add fusion-plugin-even-cards workspace plugin
- Add a new fusion-plugin-even-cards package with typed card formatting utilities and board/task card generation - Implement authenticated plugin routes for board card endpoints and plugin registration wiring - Add unit tests for auth handling, board routes, and card formatting behavior - Document plugin in PLUGIN_AUTHORING guide and register it in pnpm workspace Fusion-Task-Id: FN-3739
This commit is contained in:
26
plugins/fusion-plugin-even-cards/src/__tests__/auth.test.ts
Normal file
26
plugins/fusion-plugin-even-cards/src/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { requireApiKey } from "../routes/auth.js";
|
||||
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
|
||||
describe("requireApiKey", () => {
|
||||
it("returns 503 when api key setting is missing", () => {
|
||||
const result = requireApiKey({ settings: {}, logger } as any, { headers: {} });
|
||||
expect(result).toEqual({ ok: false, response: { status: 503, body: { error: "plugin not configured" } } });
|
||||
});
|
||||
|
||||
it("returns 401 when header missing", () => {
|
||||
const result = requireApiKey({ settings: { apiKey: "secret" }, logger } as any, { headers: {} });
|
||||
expect(result).toEqual({ ok: false, response: { status: 401, body: { error: "unauthorized" } } });
|
||||
});
|
||||
|
||||
it("returns 401 when key does not match", () => {
|
||||
const result = requireApiKey({ settings: { apiKey: "secret" }, logger } as any, { headers: { authorization: "Bearer nope" } });
|
||||
expect(result).toEqual({ ok: false, response: { status: 401, body: { error: "unauthorized" } } });
|
||||
});
|
||||
|
||||
it("returns ok on matching key", () => {
|
||||
const result = requireApiKey({ settings: { apiKey: "secret" }, logger } as any, { headers: { authorization: "Bearer secret" } });
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import plugin from "../index.js";
|
||||
import type { FusionTask } from "../cards/types.js";
|
||||
|
||||
function makeTask(id: string, column: FusionTask["column"], updatedAt: string): FusionTask {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
description: id,
|
||||
column,
|
||||
status: "pending",
|
||||
priority: "normal",
|
||||
createdAt: "2026-05-08T10:00:00.000Z",
|
||||
updatedAt,
|
||||
currentStep: 0,
|
||||
steps: [],
|
||||
dependencies: [],
|
||||
} as FusionTask;
|
||||
}
|
||||
|
||||
function createContext(tasks: FusionTask[], apiKey = "secret") {
|
||||
return {
|
||||
settings: { apiKey },
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
taskStore: {
|
||||
listTasks: vi.fn(async () => tasks),
|
||||
getTask: vi.fn(async (id: string) => tasks.find((task) => task.id === id)),
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function route(path: string) {
|
||||
return plugin.routes!.find((entry) => entry.path === path)!;
|
||||
}
|
||||
|
||||
describe("even cards routes", () => {
|
||||
it("returns 401 when auth header missing", async () => {
|
||||
const response = (await route("/board/cards").handler({ headers: {} }, createContext([]))) as any;
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 503 when plugin is not configured", async () => {
|
||||
const response = (await route("/board/cards").handler({ headers: { authorization: "Bearer secret" } }, createContext([], ""))) as any;
|
||||
expect(response.status).toBe(503);
|
||||
});
|
||||
|
||||
it("returns deck on happy path", async () => {
|
||||
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z"), makeTask("FN-2", "in-progress", "2026-05-08T12:00:00.000Z")];
|
||||
const response = (await route("/board/cards").handler({ headers: { authorization: "Bearer secret" } }, createContext(tasks))) as any;
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.deck.cards[0].id).toBe("summary");
|
||||
expect(response.body.deck.cards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("filters columns in memory", async () => {
|
||||
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z"), makeTask("FN-2", "triage", "2026-05-08T12:00:00.000Z")];
|
||||
const response = (await route("/board/cards").handler(
|
||||
{ headers: { authorization: "Bearer secret" }, query: { columns: "triage" } },
|
||||
createContext(tasks),
|
||||
)) as any;
|
||||
expect(response.body.deck.summary.counts.triage).toBe(1);
|
||||
expect(response.body.deck.summary.counts.todo).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps max bounds", async () => {
|
||||
const tasks = Array.from({ length: 30 }, (_, idx) => makeTask(`FN-${idx + 1}`, "todo", `2026-05-08T12:${String(idx).padStart(2, "0")}:00.000Z`));
|
||||
const low = (await route("/board/cards").handler(
|
||||
{ headers: { authorization: "Bearer secret" }, query: { max: "0" } },
|
||||
createContext(tasks),
|
||||
)) as any;
|
||||
const high = (await route("/board/cards").handler(
|
||||
{ headers: { authorization: "Bearer secret" }, query: { max: "99" } },
|
||||
createContext(tasks),
|
||||
)) as any;
|
||||
|
||||
expect(low.body.deck.cards).toHaveLength(1);
|
||||
expect(high.body.deck.cards).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("returns board summary", async () => {
|
||||
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z")];
|
||||
const response = (await route("/board").handler({ headers: { authorization: "Bearer secret" } }, createContext(tasks))) as any;
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.summary.counts.todo).toBe(1);
|
||||
});
|
||||
|
||||
it("returns task deck for known id", async () => {
|
||||
const tasks = [makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z")];
|
||||
const response = (await route("/tasks/:id/cards").handler(
|
||||
{ headers: { authorization: "Bearer secret" }, params: { id: "FN-1" } },
|
||||
createContext(tasks),
|
||||
)) as any;
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.deck.cards[0].id).toBe("FN-1");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown task id", async () => {
|
||||
const response = (await route("/tasks/:id/cards").handler(
|
||||
{ headers: { authorization: "Bearer secret" }, params: { id: "FN-404" } },
|
||||
createContext([]),
|
||||
)) as any;
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it("excludes archived tasks from deck cards", async () => {
|
||||
const tasks = [makeTask("FN-1", "archived", "2026-05-08T12:00:00.000Z"), makeTask("FN-2", "todo", "2026-05-08T11:00:00.000Z")];
|
||||
const response = (await route("/board/cards").handler({ headers: { authorization: "Bearer secret" } }, createContext(tasks))) as any;
|
||||
const ids = response.body.deck.cards.map((card: any) => card.id);
|
||||
expect(ids).toEqual(["summary", "FN-2"]);
|
||||
});
|
||||
});
|
||||
75
plugins/fusion-plugin-even-cards/src/__tests__/cards.test.ts
Normal file
75
plugins/fusion-plugin-even-cards/src/__tests__/cards.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { boardToDeck } from "../cards/board-cards.js";
|
||||
import { formatRelativeAge, formatTaskId, statusBadge, truncateLine, wrapLines } from "../cards/format.js";
|
||||
import { taskToCard } from "../cards/task-cards.js";
|
||||
import type { FusionTask } from "../cards/types.js";
|
||||
|
||||
function makeTask(id: string, column: FusionTask["column"], updatedAt: string): FusionTask {
|
||||
return {
|
||||
id,
|
||||
description: `Task ${id}`,
|
||||
title: `Title ${id}`,
|
||||
column,
|
||||
status: "pending",
|
||||
priority: "normal",
|
||||
currentStep: 0,
|
||||
steps: [],
|
||||
dependencies: [],
|
||||
createdAt: "2026-05-08T10:00:00.000Z",
|
||||
updatedAt,
|
||||
} as FusionTask;
|
||||
}
|
||||
|
||||
describe("cards format", () => {
|
||||
it("truncates at boundary", () => {
|
||||
expect(truncateLine("12345", 5)).toBe("12345");
|
||||
expect(truncateLine("123456", 5)).toBe("1234…");
|
||||
});
|
||||
|
||||
it("wraps across lines", () => {
|
||||
expect(wrapLines("one two three four", 7, 3)).toEqual(["one two", "three", "four"]);
|
||||
});
|
||||
|
||||
it("formats task id and relative age", () => {
|
||||
expect(formatTaskId("fn-42")).toBe("FN-42");
|
||||
expect(formatRelativeAge("2026-05-08T11:58:00.000Z", "2026-05-08T12:00:00.000Z")).toBe("2m");
|
||||
});
|
||||
|
||||
it("maps all task columns to badges", () => {
|
||||
expect(statusBadge("triage").tone).toBe("triage");
|
||||
expect(statusBadge("todo").tone).toBe("todo");
|
||||
expect(statusBadge("in-progress").tone).toBe("in-progress");
|
||||
expect(statusBadge("in-review").tone).toBe("in-review");
|
||||
expect(statusBadge("done").tone).toBe("done");
|
||||
expect(statusBadge("archived").tone).toBe("neutral");
|
||||
});
|
||||
});
|
||||
|
||||
describe("board deck", () => {
|
||||
it("summarizes counts and handles empty board", () => {
|
||||
const deck = boardToDeck([], { now: "2026-05-08T12:00:00.000Z" });
|
||||
expect(deck.cards).toHaveLength(1);
|
||||
expect(deck.summary.counts.todo).toBe(0);
|
||||
});
|
||||
|
||||
it("creates a task card", () => {
|
||||
const card = taskToCard(makeTask("FN-7", "in-review", "2026-05-08T12:00:00.000Z"), { now: "2026-05-08T12:10:00.000Z" });
|
||||
expect(card.id).toBe("FN-7");
|
||||
expect(card.badge.tone).toBe("in-review");
|
||||
expect(card.lines.join(" ")).toContain("Assignee unassigned");
|
||||
});
|
||||
|
||||
it("caps deck and sorts deterministically", () => {
|
||||
const tasks: FusionTask[] = [
|
||||
makeTask("FN-1", "todo", "2026-05-08T11:00:00.000Z"),
|
||||
makeTask("FN-9", "in-progress", "2026-05-08T11:00:00.000Z"),
|
||||
makeTask("FN-2", "done", "2026-05-08T12:00:00.000Z"),
|
||||
makeTask("FN-3", "archived", "2026-05-08T12:00:00.000Z"),
|
||||
];
|
||||
const deck = boardToDeck(tasks, { maxCards: 3, now: "2026-05-08T12:00:00.000Z" });
|
||||
|
||||
expect(deck.cards).toHaveLength(3);
|
||||
expect(deck.cards[1].id).toBe("FN-9");
|
||||
expect(deck.cards[2].id).toBe("FN-1");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user