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:
Fusion
2026-05-08 12:45:58 -07:00
committed by gsxdsm
parent 71e5360630
commit 7582a32cda
15 changed files with 635 additions and 0 deletions

View 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 });
});
});

View File

@@ -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"]);
});
});

View 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");
});
});