learning_ai_notes/web/src/lib/notes-client.test.ts

224 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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] });
// 26. 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();
});
});