Files
fusion/packages/dashboard/src/__tests__/todo-routes.test.ts
Fusion c7afa48c3f 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
2026-04-26 13:55:25 -07:00

423 lines
14 KiB
TypeScript

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