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();
+ });
+});