import { describe, expect, it, vi, beforeEach } from "vitest"; const fetchMock = vi.fn(); const enqueueMock = vi.fn(); vi.mock("@bytelyst/api-client", () => ({ createApiClient: () => ({ fetch: fetchMock, }), })); vi.mock("@/lib/offline-queue", () => ({ getOfflineQueue: () => ({ enqueue: enqueueMock }), })); import { createNote, downloadNotesExport, getNoteDetail, notesExportFilename, updateNoteDetail } from "@/lib/notes-client"; import { OFFLINE_QUEUE_MESSAGE } from "@/lib/mutation-retry"; function setOnline(value: boolean) { Object.defineProperty(navigator, "onLine", { configurable: true, value, }); } describe("getNoteDetail", () => { beforeEach(() => { fetchMock.mockReset(); enqueueMock.mockClear(); setOnline(true); }); it("returns persisted tasks only, preserves artifact blob metadata, and normalizes review state", async () => { const noteItem = { 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", }; // 1. GET /notes (fallback path — no knownWorkspaceId) fetchMock.mockResolvedValueOnce({ items: [noteItem] }); // 2–6. Parallel: /workspaces, /notes?workspaceId=..., /note-tasks, /note-artifacts, /note-agent-actions 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: [noteItem] }); 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: "notelett/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", }, ], }); // 7. GET /note-relationships (sequential after parallel batch) fetchMock.mockResolvedValueOnce({ items: [] }); const note = await getNoteDetail("note-1"); expect(note).not.toBeNull(); expect(note?.metadata.reviewState).toBe("none"); expect(note?.tasks).toEqual([ { id: "task-1", title: "Review approval UX cut line", status: "todo", source: "manual", }, ]); expect(note?.artifacts).toEqual([ { id: "artifact-1", name: "Launch brief.pdf", type: "file", status: "ready", blobPath: "notelett/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(); }); }); describe("retryable note mutations", () => { beforeEach(() => { fetchMock.mockReset(); enqueueMock.mockClear(); setOnline(true); }); it("queues note updates when the browser is offline", async () => { setOnline(false); fetchMock.mockRejectedValue(new Error("Failed to fetch")); await expect(updateNoteDetail("note-1", "ws-1", { title: "Offline edit" })) .rejects.toThrow(OFFLINE_QUEUE_MESSAGE); expect(enqueueMock).toHaveBeenCalledWith({ id: "note-1", action: "patch", path: "/notes/note-1?workspaceId=ws-1", payload: { title: "Offline edit" }, }); }); it("queues note creation when the browser is offline", async () => { setOnline(false); fetchMock.mockRejectedValue(new Error("Failed to fetch")); const input = { id: "note-new", workspaceId: "ws-1", title: "New", body: "Body" }; await expect(createNote(input)).rejects.toThrow(OFFLINE_QUEUE_MESSAGE); expect(enqueueMock).toHaveBeenCalledWith({ id: "note-new", action: "post", path: "/notes", payload: input, }); }); }); describe("notes export downloads", () => { beforeEach(() => { fetchMock.mockReset(); enqueueMock.mockClear(); setOnline(true); }); it("uses deterministic filenames for export downloads", () => { expect(notesExportFilename("json")).toBe("notelett-notes-all.json"); expect(notesExportFilename("markdown", "Workspace 1 / Launch")).toBe("notelett-notes-Workspace-1-Launch.md"); }); it("downloads markdown exports with the backend scoped query", async () => { fetchMock.mockResolvedValueOnce("# Export\n"); const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:export"); const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); const click = vi.fn(); const createElement = vi.spyOn(document, "createElement").mockReturnValue({ href: "", download: "", click, } as unknown as HTMLAnchorElement); await downloadNotesExport("markdown", "ws-1"); expect(fetchMock).toHaveBeenCalledWith("/notes/export?format=markdown&workspaceId=ws-1"); expect(createObjectURL).toHaveBeenCalledOnce(); expect(createElement.mock.results[0]?.value.download).toBe("notelett-notes-ws-1.md"); expect(click).toHaveBeenCalledOnce(); expect(revokeObjectURL).toHaveBeenCalledWith("blob:export"); createObjectURL.mockRestore(); revokeObjectURL.mockRestore(); createElement.mockRestore(); }); });