feat(FN-2576): merge fusion/fn-2576 (auto-resolved)

- test(FN-2576): complete Step 4 — add todo route coverage
- feat(FN-2576): complete Step 3 — add todo client API functions
- feat(FN-2576): complete Step 2 — register todo router
- feat(FN-2576): complete Step 1 — add todo routes module
This commit is contained in:
Fusion
2026-04-26 13:55:25 -07:00
committed by gsxdsm
parent 2f5d84c1ba
commit 651ae34307
5 changed files with 710 additions and 0 deletions

View File

@@ -54,6 +54,13 @@ import type {
RoadmapExportBundle,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
TodoList,
TodoItem,
TodoListWithItems,
TodoListCreateInput,
TodoListUpdateInput,
TodoItemCreateInput,
TodoItemUpdateInput,
Insight,
InsightCategory,
InsightStatus,
@@ -6269,6 +6276,75 @@ export function generateFeatureSuggestions(
);
}
// ── Todo API ─────────────────────────────────────────────────────────────────
/** Fetch all todo lists with their items */
export function fetchTodoLists(projectId?: string): Promise<TodoListWithItems[]> {
return api<TodoListWithItems[]>(withProjectId("/todos", projectId));
}
/** Create a new todo list */
export function createTodoList(title: string, projectId?: string): Promise<TodoList> {
const input: TodoListCreateInput = { title };
return api<TodoList>(withProjectId("/todos", projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update a todo list title */
export function updateTodoList(id: string, title: string, projectId?: string): Promise<TodoList> {
const updates: TodoListUpdateInput = { title };
return api<TodoList>(withProjectId(`/todos/${encodeURIComponent(id)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete a todo list and all its items */
export function deleteTodoList(id: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/todos/${encodeURIComponent(id)}`, projectId), {
method: "DELETE",
});
}
/** Create a new item in a todo list */
export function createTodoItem(listId: string, text: string, projectId?: string): Promise<TodoItem> {
const input: TodoItemCreateInput = { text };
return api<TodoItem>(withProjectId(`/todos/${encodeURIComponent(listId)}/items`, projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update a todo item (text and/or completed) */
export function updateTodoItem(
id: string,
data: { text?: string; completed?: boolean },
projectId?: string
): Promise<TodoItem> {
const updates: TodoItemUpdateInput = data;
return api<TodoItem>(withProjectId(`/todos/items/${encodeURIComponent(id)}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete a todo item */
export function deleteTodoItem(id: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/todos/items/${encodeURIComponent(id)}`, projectId), {
method: "DELETE",
});
}
/** Reorder items within a todo list */
export function reorderTodoItems(listId: string, itemIds: string[], projectId?: string): Promise<void> {
return api<void>(withProjectId(`/todos/${encodeURIComponent(listId)}/items/reorder`, projectId), {
method: "POST",
body: JSON.stringify({ itemIds }),
});
}
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────
export interface AiSessionSummary {

View File

@@ -0,0 +1,422 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import type { TodoItem, TodoList, TodoListWithItems } from "@fusion/core";
import { get as performGet, request as performRequest } from "../test-request.js";
import { createTodoRouter } from "../todo-routes.js";
import { ApiError } from "../api-error.js";
const mockGetOrCreateProjectStore = vi.fn();
vi.mock("../project-store-resolver.js", () => ({
getOrCreateProjectStore: (...args: unknown[]) => mockGetOrCreateProjectStore(...args),
}));
function createMockTodoStore() {
const lists = new Map<string, TodoList>();
const items = new Map<string, TodoItem>();
const listItemsForList = (listId: string) =>
Array.from(items.values())
.filter((item) => item.listId === listId)
.sort((a, b) => a.sortOrder - b.sortOrder);
return {
createList: vi.fn((projectId: string, input: { title: string }) => {
const id = `TDL-${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
const now = new Date().toISOString();
const list: TodoList = {
id,
projectId,
title: input.title,
createdAt: now,
updatedAt: now,
};
lists.set(id, list);
return list;
}),
updateList: vi.fn((id: string, updates: { title?: string }) => {
const existing = lists.get(id);
if (!existing) return undefined;
const updated: TodoList = {
...existing,
...updates,
updatedAt: new Date().toISOString(),
};
lists.set(id, updated);
return updated;
}),
deleteList: vi.fn((id: string) => {
const existed = lists.delete(id);
for (const item of Array.from(items.values())) {
if (item.listId === id) {
items.delete(item.id);
}
}
return existed;
}),
listLists: vi.fn((projectId: string) =>
Array.from(lists.values()).filter((list) => list.projectId === projectId)
),
getListsWithItems: vi.fn((projectId: string): TodoListWithItems[] =>
Array.from(lists.values())
.filter((list) => list.projectId === projectId)
.map((list) => ({
...list,
items: listItemsForList(list.id),
}))
),
createItem: vi.fn((listId: string, input: { text: string }) => {
if (!lists.has(listId)) {
throw new Error(`Todo list ${listId} not found`);
}
const id = `TDI-${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
const now = new Date().toISOString();
const item: TodoItem = {
id,
listId,
text: input.text,
completed: false,
completedAt: null,
sortOrder: listItemsForList(listId).length,
createdAt: now,
updatedAt: now,
};
items.set(id, item);
return item;
}),
updateItem: vi.fn((id: string, updates: { text?: string; completed?: boolean }) => {
const existing = items.get(id);
if (!existing) return undefined;
const now = new Date().toISOString();
const updated: TodoItem = {
...existing,
...updates,
completedAt:
updates.completed === undefined
? existing.completedAt
: updates.completed
? now
: null,
updatedAt: now,
};
items.set(id, updated);
return updated;
}),
deleteItem: vi.fn((id: string) => items.delete(id)),
reorderItems: vi.fn((listId: string, itemIds: string[]) => {
const listItems = listItemsForList(listId);
const existingIds = listItems.map((item) => item.id);
if (new Set(itemIds).size !== itemIds.length) {
throw new Error("Cannot reorder items: duplicate item IDs provided");
}
if (existingIds.length !== itemIds.length) {
throw new Error("Cannot reorder items: provided IDs must include all items in the list");
}
for (let index = 0; index < itemIds.length; index += 1) {
const item = items.get(itemIds[index]);
if (item) {
items.set(item.id, {
...item,
sortOrder: index,
updatedAt: new Date().toISOString(),
});
}
}
return listItemsForList(listId);
}),
};
}
describe("Todo Routes", () => {
let app: express.Express;
let mockTodoStore: ReturnType<typeof createMockTodoStore>;
let mockStore: { getTodoStore: ReturnType<typeof vi.fn>; getRootDir: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockTodoStore = createMockTodoStore();
mockStore = {
getTodoStore: vi.fn(() => mockTodoStore),
getRootDir: vi.fn(() => "/test/root"),
};
mockGetOrCreateProjectStore.mockResolvedValue(mockStore);
app = express();
app.use(express.json());
app.use("/api/todos", createTodoRouter(mockStore as never));
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (err instanceof ApiError) {
res.status(err.statusCode).json({ error: err.message });
return;
}
if (err instanceof Error) {
res.status(500).json({ error: err.message });
return;
}
res.status(500).json({ error: "Internal server error" });
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe("list endpoints", () => {
it("GET / returns all lists with items", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
mockTodoStore.createItem(list.id, { text: "Ship it" });
const response = await performGet(app, "/api/todos?projectId=proj-a");
expect(response.status).toBe(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].title).toBe("Inbox");
expect(response.body[0].items).toHaveLength(1);
});
it("GET /?projectId=X passes projectId to store", async () => {
const response = await performGet(app, "/api/todos?projectId=proj-scope");
expect(response.status).toBe(200);
expect(mockTodoStore.getListsWithItems).toHaveBeenCalledWith("proj-scope");
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj-scope");
});
it("POST / creates a list with valid title", async () => {
const response = await performRequest(
app,
"POST",
"/api/todos",
JSON.stringify({ title: " Today " }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(201);
expect(response.body.title).toBe("Today");
});
it.each([
[{}, "title is required"],
[{ title: "" }, "title is required"],
[{ title: " " }, "title is required"],
[{ title: "A".repeat(201) }, "200 characters"],
])("POST / rejects invalid title %#", async (body, message) => {
const response = await performRequest(app, "POST", "/api/todos", JSON.stringify(body), {
"Content-Type": "application/json",
});
expect(response.status).toBe(400);
expect(response.body.error).toContain(message);
});
it("PATCH /:id updates list title", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Old" });
const response = await performRequest(
app,
"PATCH",
`/api/todos/${list.id}`,
JSON.stringify({ title: "New" }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(200);
expect(response.body.title).toBe("New");
});
it("PATCH /:id returns 404 for nonexistent list", async () => {
const response = await performRequest(
app,
"PATCH",
"/api/todos/TDL-MISSING",
JSON.stringify({ title: "Nope" }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(404);
expect(response.body.error).toContain("not found");
});
it("DELETE /:id deletes list", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Delete me" });
const response = await performRequest(app, "DELETE", `/api/todos/${list.id}`);
expect(response.status).toBe(204);
expect(mockTodoStore.deleteList).toHaveBeenCalledWith(list.id);
});
});
describe("item endpoints", () => {
it("POST /:id/items creates item with valid text", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const response = await performRequest(
app,
"POST",
`/api/todos/${list.id}/items`,
JSON.stringify({ text: " Buy milk " }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(201);
expect(response.body.text).toBe("Buy milk");
});
it.each([
[{}, "text is required"],
[{ text: "" }, "text is required"],
[{ text: " ".repeat(3) }, "text is required"],
[{ text: "A".repeat(2001) }, "2000 characters"],
])("POST /:id/items rejects invalid text %#", async (payload, message) => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const response = await performRequest(
app,
"POST",
`/api/todos/${list.id}/items`,
JSON.stringify(payload),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(400);
expect(response.body.error).toContain(message);
});
it("PATCH /items/:id updates item text", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const item = mockTodoStore.createItem(list.id, { text: "Old text" });
const response = await performRequest(
app,
"PATCH",
`/api/todos/items/${item.id}`,
JSON.stringify({ text: "New text" }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(200);
expect(response.body.text).toBe("New text");
});
it("PATCH /items/:id updates item completed status", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const item = mockTodoStore.createItem(list.id, { text: "Task" });
const response = await performRequest(
app,
"PATCH",
`/api/todos/items/${item.id}`,
JSON.stringify({ completed: true }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(200);
expect(response.body.completed).toBe(true);
});
it("PATCH /items/:id rejects invalid completed type", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const item = mockTodoStore.createItem(list.id, { text: "Task" });
const response = await performRequest(
app,
"PATCH",
`/api/todos/items/${item.id}`,
JSON.stringify({ completed: "true" }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(400);
expect(response.body.error).toContain("completed must be a boolean");
});
it("PATCH /items/:id returns 404 for nonexistent item", async () => {
const response = await performRequest(
app,
"PATCH",
"/api/todos/items/TDI-MISSING",
JSON.stringify({ text: "Nope" }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(404);
expect(response.body.error).toContain("not found");
});
it("DELETE /items/:id deletes item", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const item = mockTodoStore.createItem(list.id, { text: "Task" });
const response = await performRequest(app, "DELETE", `/api/todos/items/${item.id}`);
expect(response.status).toBe(204);
expect(mockTodoStore.deleteItem).toHaveBeenCalledWith(item.id);
});
});
describe("reorder endpoint", () => {
it("POST /:id/items/reorder reorders items", async () => {
const list = mockTodoStore.createList("proj-a", { title: "Inbox" });
const item1 = mockTodoStore.createItem(list.id, { text: "One" });
const item2 = mockTodoStore.createItem(list.id, { text: "Two" });
const response = await performRequest(
app,
"POST",
`/api/todos/${list.id}/items/reorder`,
JSON.stringify({ itemIds: [item2.id, item1.id] }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(204);
expect(mockTodoStore.reorderItems).toHaveBeenCalledWith(list.id, [item2.id, item1.id]);
});
it.each([
[{}, "itemIds must be an array"],
[{ itemIds: "not-array" }, "itemIds must be an array"],
[{ itemIds: ["ok", 123] }, "itemIds must be an array of strings"],
])("POST /:id/items/reorder rejects invalid itemIds %#", async (body, message) => {
const response = await performRequest(
app,
"POST",
"/api/todos/TDL-1/items/reorder",
JSON.stringify(body),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(400);
expect(response.body.error).toContain(message);
});
});
describe("project scoping", () => {
it("passes projectId from query param to list endpoint and resolver", async () => {
const response = await performGet(app, "/api/todos?projectId=project-query");
expect(response.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("project-query");
expect(mockTodoStore.getListsWithItems).toHaveBeenCalledWith("project-query");
});
it("passes projectId from body to create endpoint and resolver", async () => {
const response = await performRequest(
app,
"POST",
"/api/todos",
JSON.stringify({ title: "List", projectId: "project-body" }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(201);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("project-body");
expect(mockTodoStore.createList).toHaveBeenCalledWith("project-body", { title: "List" });
});
it("passes projectId from body to item update route via scoped resolver", async () => {
const list = mockTodoStore.createList("project-body", { title: "Inbox" });
const item = mockTodoStore.createItem(list.id, { text: "Initial" });
const response = await performRequest(
app,
"PATCH",
`/api/todos/items/${item.id}`,
JSON.stringify({ text: "Updated", projectId: "project-body" }),
{ "Content-Type": "application/json" }
);
expect(response.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("project-body");
});
});
});

View File

@@ -2788,6 +2788,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// - /missions
// - /roadmaps
// - /insights
// - /todos
registerIntegratedRouters({
router,
store,

View File

@@ -4,6 +4,7 @@ import type { ServerOptions } from "../server.js";
import { createMissionRouter } from "../mission-routes.js";
import { createRoadmapRouter } from "../roadmap-routes.js";
import { createInsightsRouter } from "../insights-routes.js";
import { createTodoRouter } from "../todo-routes.js";
import { createDevServerRouter } from "../dev-server-routes.js";
import type { AiSessionStore } from "../ai-session-store.js";
@@ -32,6 +33,7 @@ export function registerIntegratedRouters({
router.use("/roadmaps", createRoadmapRouter(store));
router.use("/insights", createInsightsRouter(store));
router.use("/todos", createTodoRouter(store));
}
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {

View File

@@ -0,0 +1,209 @@
import { Router, type Request, type Response } from "express";
import { AsyncLocalStorage } from "node:async_hooks";
import { TaskStore } from "@fusion/core";
import {
ApiError,
badRequest,
notFound,
internalError,
} from "./api-error.js";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server error"): never {
if (error instanceof ApiError) {
throw error;
}
if (error instanceof Error && error.message) {
throw internalError(error.message);
}
throw internalError(fallbackMessage);
}
function validateTitle(title: unknown): string {
if (!title || typeof title !== "string" || !title.trim()) {
throw badRequest("title is required");
}
if (title.length > 200) {
throw badRequest("title must not exceed 200 characters");
}
return title.trim();
}
function validateText(text: unknown): string {
if (!text || typeof text !== "string" || !text.trim()) {
throw badRequest("text is required");
}
if (text.length > 2000) {
throw badRequest("text must not exceed 2000 characters");
}
return text.trim();
}
function validateBoolean(value: unknown, field: string): boolean {
if (typeof value !== "boolean") {
throw badRequest(`${field} must be a boolean`);
}
return value;
}
function validateStringArray(arr: unknown, fieldName: string): string[] {
if (!Array.isArray(arr)) {
throw badRequest(`${fieldName} must be an array`);
}
if (!arr.every((item) => typeof item === "string")) {
throw badRequest(`${fieldName} must be an array of strings`);
}
return arr;
}
export function createTodoRouter(store: TaskStore): Router {
const router = Router();
const requestContext = new AsyncLocalStorage<TaskStore>();
function getProjectIdFromRequest(req: Request): string | undefined {
if (typeof req.query.projectId === "string" && req.query.projectId.trim()) {
return req.query.projectId;
}
if (req.body && typeof req.body === "object" && typeof req.body.projectId === "string" && req.body.projectId.trim()) {
return req.body.projectId;
}
return undefined;
}
function getScopedStore(): TaskStore {
const scoped = requestContext.getStore();
return scoped ?? store;
}
router.use(async (req: Request, _res: Response, next) => {
try {
const projectId = getProjectIdFromRequest(req);
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : store;
requestContext.run(scopedStore, next);
} catch (error) {
next(error);
}
});
router.get("/", async (req, res) => {
try {
const projectId = getProjectIdFromRequest(req) ?? "";
const todoStore = getScopedStore().getTodoStore();
const lists = todoStore.getListsWithItems(projectId);
res.json(lists);
} catch (error) {
rethrowAsApiError(error, "Failed to list todo lists");
}
});
router.post("/", async (req, res) => {
try {
const projectId = getProjectIdFromRequest(req) ?? "";
const title = validateTitle((req.body as { title?: unknown }).title);
const todoStore = getScopedStore().getTodoStore();
const list = todoStore.createList(projectId, { title });
res.status(201).json(list);
} catch (error) {
rethrowAsApiError(error, "Failed to create todo list");
}
});
router.patch("/:id", async (req, res) => {
try {
const { id } = req.params;
const input = req.body as { title?: unknown };
if (input.title === undefined) {
throw badRequest("At least one field must be provided");
}
const title = validateTitle(input.title);
const todoStore = getScopedStore().getTodoStore();
const updated = todoStore.updateList(id, { title });
if (!updated) {
throw notFound(`Todo list ${id} not found`);
}
res.json(updated);
} catch (error) {
rethrowAsApiError(error, "Failed to update todo list");
}
});
router.delete("/:id", async (req, res) => {
try {
const { id } = req.params;
const todoStore = getScopedStore().getTodoStore();
todoStore.deleteList(id);
res.status(204).send();
} catch (error) {
rethrowAsApiError(error, "Failed to delete todo list");
}
});
router.post("/:id/items", async (req, res) => {
try {
const { id: listId } = req.params;
const text = validateText((req.body as { text?: unknown }).text);
const todoStore = getScopedStore().getTodoStore();
const item = todoStore.createItem(listId, { text });
res.status(201).json(item);
} catch (error) {
rethrowAsApiError(error, "Failed to create todo item");
}
});
router.patch("/items/:id", async (req, res) => {
try {
const { id } = req.params;
const input = req.body as { text?: unknown; completed?: unknown };
const updates: { text?: string; completed?: boolean } = {};
if (input.text !== undefined) {
updates.text = validateText(input.text);
}
if (input.completed !== undefined) {
updates.completed = validateBoolean(input.completed, "completed");
}
if (Object.keys(updates).length === 0) {
throw badRequest("At least one field must be provided");
}
const todoStore = getScopedStore().getTodoStore();
const item = todoStore.updateItem(id, updates);
if (!item) {
throw notFound(`Todo item ${id} not found`);
}
res.json(item);
} catch (error) {
rethrowAsApiError(error, "Failed to update todo item");
}
});
router.delete("/items/:id", async (req, res) => {
try {
const { id } = req.params;
const todoStore = getScopedStore().getTodoStore();
todoStore.deleteItem(id);
res.status(204).send();
} catch (error) {
rethrowAsApiError(error, "Failed to delete todo item");
}
});
router.post("/:id/items/reorder", async (req, res) => {
try {
const { id: listId } = req.params;
const itemIds = validateStringArray((req.body as { itemIds?: unknown }).itemIds, "itemIds");
const todoStore = getScopedStore().getTodoStore();
todoStore.reorderItems(listId, itemIds);
res.status(204).send();
} catch (error) {
rethrowAsApiError(error, "Failed to reorder todo items");
}
});
return router;
}