diff --git a/web/src/app/(app)/search/page.test.tsx b/web/src/app/(app)/search/page.test.tsx index 9248bfb..a029b4d 100644 --- a/web/src/app/(app)/search/page.test.tsx +++ b/web/src/app/(app)/search/page.test.tsx @@ -2,6 +2,8 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import SearchPage from "./page"; +const listNoteSummariesMock = vi.fn(); + vi.mock("next/link", () => ({ default: ({ href, children, ...props }: React.ComponentProps<"a"> & { href: string }) => ( @@ -10,15 +12,32 @@ vi.mock("next/link", () => ({ ), })); +vi.mock("@/lib/notes-client", () => ({ + listNoteSummaries: () => listNoteSummariesMock(), +})); + describe("SearchPage", () => { - it("renders an accessible search field, saved searches, and note links", () => { + it("renders an accessible search field, saved searches, and note links", async () => { + listNoteSummariesMock.mockResolvedValue([ + { + id: "note-prd-cutline", + workspaceId: "workspace-product", + title: "MVP cut line for agentic notes launch", + excerpt: "Define which note, task, search, and approval flows must exist before wider rollout.", + status: "active", + tags: ["mvp", "launch", "scope"], + updatedAt: "2026-03-10T14:30:00.000Z", + updatedBy: "Product Lead", + }, + ]); + render(); expect(screen.getByRole("heading", { level: 1, name: "Search" })).toBeInTheDocument(); expect(screen.getByRole("textbox", { name: "Search notes" })).toBeInTheDocument(); expect(screen.getByText("Saved searches")).toBeInTheDocument(); expect(screen.getByText("Launch readiness")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: /MVP cut line for agentic notes launch/i })).toHaveAttribute( + expect(await screen.findByRole("link", { name: /MVP cut line for agentic notes launch/i })).toHaveAttribute( "href", "/notes/note-prd-cutline" ); diff --git a/web/src/app/(app)/workspaces/page.test.tsx b/web/src/app/(app)/workspaces/page.test.tsx index 1d677f3..52c044b 100644 --- a/web/src/app/(app)/workspaces/page.test.tsx +++ b/web/src/app/(app)/workspaces/page.test.tsx @@ -2,6 +2,9 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import WorkspacesPage from "./page"; +const listNoteSummariesMock = vi.fn(); +const listWorkspaceSummariesMock = vi.fn(); + vi.mock("next/link", () => ({ default: ({ href, children, ...props }: React.ComponentProps<"a"> & { href: string }) => ( @@ -10,15 +13,45 @@ vi.mock("next/link", () => ({ ), })); +vi.mock("@/lib/notes-client", () => ({ + listNoteSummaries: () => listNoteSummariesMock(), + listWorkspaceSummaries: () => listWorkspaceSummariesMock(), +})); + describe("WorkspacesPage", () => { - it("renders an accessible workspace filter and saved workspace views", () => { + it("renders an accessible workspace filter and saved workspace views", async () => { + listNoteSummariesMock.mockResolvedValue([ + { + id: "note-prd-cutline", + workspaceId: "workspace-product", + title: "MVP cut line for agentic notes launch", + excerpt: "Define which note, task, search, and approval flows must exist before wider rollout.", + status: "active", + tags: ["mvp", "launch", "scope"], + updatedAt: "2026-03-10T14:30:00.000Z", + updatedBy: "Product Lead", + }, + ]); + listWorkspaceSummariesMock.mockResolvedValue([ + { + id: "workspace-product", + name: "Product Strategy", + description: "PRDs, roadmap cuts, launch tradeoffs, and operating decisions.", + owner: "Product Lead", + noteCount: 1, + visibility: "shared", + updatedAt: "2026-03-10T14:35:00.000Z", + tags: ["strategy", "launch", "roadmap"], + }, + ]); + render(); expect(screen.getByRole("heading", { level: 1, name: "Workspaces" })).toBeInTheDocument(); expect(screen.getByRole("textbox", { name: "Filter workspaces" })).toBeInTheDocument(); expect(screen.getByText("Saved views")).toBeInTheDocument(); expect(screen.getByText("All workspaces")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: /MVP cut line for agentic notes launch/i })).toHaveAttribute( + expect(await screen.findByRole("link", { name: /MVP cut line for agentic notes launch/i })).toHaveAttribute( "href", "/notes/note-prd-cutline" ); diff --git a/web/src/lib/notes-client.test.ts b/web/src/lib/notes-client.test.ts new file mode 100644 index 0000000..2d1ca0e --- /dev/null +++ b/web/src/lib/notes-client.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const fetchMock = vi.fn(); +const extractSuggestedTasksMock = vi.fn(); + +vi.mock("@bytelyst/api-client", () => ({ + createApiClient: () => ({ + fetch: fetchMock, + }), +})); + +vi.mock("@/lib/extraction-client", () => ({ + extractSuggestedTasks: (...args: unknown[]) => extractSuggestedTasksMock(...args), +})); + +import { getNoteDetail } from "@/lib/notes-client"; + +describe("getNoteDetail", () => { + beforeEach(() => { + fetchMock.mockReset(); + extractSuggestedTasksMock.mockReset(); + }); + + it("merges backend tasks with extracted suggestions, preserves artifact blob metadata, and normalizes review state", async () => { + fetchMock.mockResolvedValueOnce({ + items: [ + { + id: "workspace-1", + name: "Product", + members: [{ userId: "owner-1", role: "owner" }], + updatedAt: "2026-03-10T12:00:00.000Z", + updatedBy: "owner-1", + }, + ], + }); + fetchMock.mockResolvedValueOnce({ + items: [ + { + id: "note-1", + workspaceId: "workspace-1", + title: "Launch note", + body: "Sarah agreed to handle the testing by Friday.", + status: "active", + tags: ["launch"], + updatedAt: "2026-03-10T12:01:00.000Z", + updatedBy: "editor-1", + createdBy: "editor-1", + sourceType: "manual", + }, + ], + }); + fetchMock.mockResolvedValueOnce({ + items: [ + { + id: "task-1", + noteId: "note-1", + title: "Review approval UX cut line", + status: "open", + source: "manual", + }, + ], + }); + fetchMock.mockResolvedValueOnce({ + items: [ + { + id: "artifact-1", + noteId: "note-1", + artifactType: "file", + title: "Launch brief.pdf", + description: "Ready for review", + blobPath: "bytelyst-notes/user-1/launch-brief.pdf", + contentType: "application/pdf", + sizeBytes: 2048, + }, + ], + }); + fetchMock.mockResolvedValueOnce({ + items: [ + { + id: "action-1", + noteId: "note-1", + actorId: "agent-1", + actorType: "agent", + actionType: "summarize", + state: "draft", + afterSummary: "Drafted a summary update.", + updatedAt: "2026-03-10T12:03:00.000Z", + }, + { + id: "action-2", + noteId: "note-1", + actorId: "agent-2", + actorType: "agent", + actionType: "extract_tasks", + state: "approved", + afterSummary: "Approved task extraction.", + updatedAt: "2026-03-10T12:02:00.000Z", + }, + ], + }); + + extractSuggestedTasksMock.mockResolvedValue([ + { + id: "extract-review-0", + title: "Review approval UX cut line", + status: "todo", + source: "agent", + }, + { + id: "extract-test-1", + title: "Sarah agreed to handle the testing", + status: "todo", + source: "agent", + }, + ]); + + const note = await getNoteDetail("note-1"); + + expect(note).not.toBeNull(); + expect(extractSuggestedTasksMock).toHaveBeenCalledWith( + "Sarah agreed to handle the testing by Friday." + ); + expect(note?.metadata.reviewState).toBe("none"); + expect(note?.tasks).toEqual([ + { + id: "task-1", + title: "Review approval UX cut line", + status: "todo", + source: "manual", + }, + { + id: "extract-test-1", + title: "Sarah agreed to handle the testing", + status: "todo", + source: "agent", + }, + ]); + expect(note?.artifacts).toEqual([ + { + id: "artifact-1", + name: "Launch brief.pdf", + type: "file", + status: "ready", + blobPath: "bytelyst-notes/user-1/launch-brief.pdf", + contentType: "application/pdf", + sizeBytes: 2048, + }, + ]); + expect(note?.timeline[0]?.status).toBe("draft"); + }); + + it("returns null when the note is missing", async () => { + fetchMock.mockResolvedValueOnce({ items: [] }); + fetchMock.mockResolvedValueOnce({ items: [] }); + + const note = await getNoteDetail("missing-note"); + + expect(note).toBeNull(); + expect(extractSuggestedTasksMock).not.toHaveBeenCalled(); + }); +});