224 lines
6.5 KiB
TypeScript
224 lines
6.5 KiB
TypeScript
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();
|
||
});
|
||
});
|