Files
edgeever/tests/json-backup.test.ts
T
tianma-if ccc73ab6af feat(markdown): preserve front matter on import and export
Support imported YAML properties without adding a toolbar entry. Keep ordinary single-note exports body-only and retain full metadata for ZIP backups.
2026-10-10 18:42:45 +08:00

436 lines
15 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import type { AiPromptTemplate, JsonBackupMemo, JsonBackupNotebook, MemoDetail, Notebook, Resource } from "@edgeever/shared";
import { splitMarkdownFrontMatter } from "@edgeever/shared";
import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
import {
createEdgeEverZip,
parseEdgeEverZip,
restoreEdgeEverZip,
restoreEdgeEverZipAndRefresh,
} from "../apps/web/src/lib/json-backup";
const notebook = (id: string, name: string, parentId: string | null = null): Notebook => ({
id,
parentId,
name,
slug: id,
icon: "notebook",
color: null,
sortOrder: parentId ? 20 : 10,
memoCount: 1,
lastMemoUpdatedAt: "2026-07-14T00:00:00.000Z",
createdAt: "2026-07-13T00:00:00.000Z",
updatedAt: "2026-07-14T00:00:00.000Z",
});
const memo: MemoDetail = {
id: "memo_backup",
notebookId: "nb_child",
title: "Backup note",
excerpt: "Hello",
tags: ["backup"],
isPinned: false,
isArchived: false,
isDeleted: false,
revision: 1,
createdAt: "2026-07-13T00:00:00.000Z",
updatedAt: "2026-07-14T00:00:00.000Z",
deletedAt: null,
contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }] },
contentMarkdown: "Hello\n\n![asset](/api/v1/resources/res_backup/blob)",
contentText: "Hello",
contentHash: "hash",
sourceMemoIds: [],
mergeSourceCount: 0,
mergedIntoMemoId: null,
};
const resource: Resource = {
id: "res_backup",
memoId: memo.id,
originalMemoId: null,
kind: "image",
mimeType: "image/png",
filename: "asset.png",
byteSize: 3,
sha256: "sha",
width: 10,
height: 10,
createdAt: memo.createdAt,
updatedAt: memo.updatedAt,
url: "/api/v1/resources/res_backup/blob",
};
const prompt: AiPromptTemplate = {
id: "aiprompt_backup",
origin: "custom",
seedKey: null,
action: "custom",
parameterKind: "tone",
resultMode: "replace",
nameCustomized: true,
descriptionCustomized: true,
instructionCustomized: true,
name: "Polish",
description: "Polish selected text",
instruction: "Polish this text.",
createdAt: memo.createdAt,
updatedAt: memo.updatedAt,
};
describe("EdgeEver ZIP", () => {
test("reports actionable archive validation errors", async () => {
await expect(parseEdgeEverZip(new Blob(["not a zip"]))).rejects.toEqual(
expect.objectContaining({ code: "invalidZip" })
);
const localHeaderOnly = new Uint8Array(64);
localHeaderOnly.set([0x50, 0x4b, 0x03, 0x04]);
await expect(parseEdgeEverZip(new Blob([localHeaderOnly]))).rejects.toEqual(
expect.objectContaining({ code: "invalidZip" })
);
await expect(parseEdgeEverZip(new Blob([zipSync({ "notebooks.json": strToU8("[]") })]))).rejects.toEqual(
expect.objectContaining({ code: "missingManifest" })
);
const manifest = {
format: "another-app",
formatVersion: 1,
schemaVersion: 1,
edgeeverVersion: "0.1.13",
buildId: "test-build",
exportedAt: "2026-07-14T00:00:00.000Z",
includesTrash: false,
counts: { notebooks: 0, memos: 0, revisions: 0, resources: 0 },
};
await expect(parseEdgeEverZip(new Blob([zipSync({
"manifest.json": strToU8(JSON.stringify(manifest)),
})]))).rejects.toEqual(expect.objectContaining({ code: "unsupportedFormat" }));
await expect(parseEdgeEverZip(new Blob([zipSync({
"manifest.json": strToU8(JSON.stringify({ ...manifest, format: "edgeever-zip", formatVersion: 99 })),
})]))).rejects.toEqual(expect.objectContaining({ code: "unsupportedVersion" }));
});
test("combines readable Markdown with versioned recovery data", async () => {
const resourceChunks: number[] = [];
const blob = await createEdgeEverZip(
{
listNotebooks: async () => ({ notebooks: [notebook("nb_child", "Child", "nb_root"), notebook("nb_root", "Root")] }),
listPrompts: async () => ({ prompts: [prompt] }),
getPage: async () => ({
memos: [memo],
resources: [resource],
revisions: [{
id: "rev_backup",
memoId: memo.id,
revision: 0,
title: memo.title,
tags: memo.tags,
contentJson: memo.contentJson,
contentMarkdown: "Hello",
contentText: "Hello",
contentHash: "old-hash",
createdBy: "user",
createdAt: memo.createdAt,
}],
totalCount: 1,
nextOffset: null,
}),
getResourceResponse: async () => new Response(new ReadableStream({
start(controller) {
for (const chunk of [new Uint8Array([1]), new Uint8Array([2, 3])]) {
resourceChunks.push(chunk.byteLength);
controller.enqueue(chunk);
}
controller.close();
},
}), { headers: { "Content-Type": "image/png" } }),
},
{ edgeeverVersion: "0.1.13", buildId: "test-build" }
);
const backup = await parseEdgeEverZip(blob);
expect(backup.manifest.format).toBe("edgeever-zip");
expect(backup.manifest.formatVersion).toBe(2);
expect(backup.manifest.counts).toEqual({ notebooks: 2, memos: 1, revisions: 1, resources: 1, prompts: 1 });
expect(backup.prompts).toEqual([prompt]);
expect(backup.notebooks.map((item) => item.id)).toEqual(["nb_root", "nb_child"]);
expect(backup.memos[0].memo.contentMarkdown).toBe(memo.contentMarkdown);
expect(backup.archive).toBe(blob);
expect(resourceChunks).toEqual([1, 2]);
const files = unzipSync(new Uint8Array(await blob.arrayBuffer()));
expect(Array.from(files[backup.memos[0].resources[0].archivePath])).toEqual([1, 2, 3]);
const markdown = strFromU8(files["notes/Root/Child/Backup note.md"]);
expect(splitMarkdownFrontMatter(markdown)?.values?.edgeever_id).toBe("memo_backup");
expect(markdown).toContain("![asset](Backup%20note.assets/asset.png)");
expect(backup.memos[0].resources[0].archivePath).toBe("notes/Root/Child/Backup note.assets/asset.png");
});
test("keeps version 1 archives importable without prompt data", async () => {
const legacyManifest = {
format: "edgeever-zip",
formatVersion: 1,
schemaVersion: 1,
edgeeverVersion: "1.13.3",
buildId: "legacy-build",
exportedAt: "2026-08-09T00:00:00.000Z",
includesTrash: false,
counts: { notebooks: 0, memos: 0, revisions: 0, resources: 0 },
};
const backup = await parseEdgeEverZip(new Blob([zipSync({
"manifest.json": strToU8(JSON.stringify(legacyManifest)),
"notebooks.json": strToU8("[]"),
})]));
expect(backup.manifest.formatVersion).toBe(1);
expect(backup.prompts).toEqual([]);
});
test("reads large archives by bounded ranges instead of buffering the ZIP", async () => {
const manifest = {
format: "edgeever-zip",
formatVersion: 1,
schemaVersion: 1,
edgeeverVersion: "1.13.3",
buildId: "large-stream-test",
exportedAt: "2026-08-31T00:00:00.000Z",
includesTrash: false,
counts: { notebooks: 0, memos: 0, revisions: 0, resources: 0 },
};
const storedPayload = new Uint8Array(1024 * 1024 + 10);
const actualArchive = new Blob([zipSync({
"manifest.json": strToU8(JSON.stringify(manifest)),
"notebooks.json": strToU8("[]"),
"pad.bin": storedPayload,
}, { level: 0 })]);
const rangeLengths: number[] = [];
let wholeArchiveBuffered = false;
const largeStreamingArchive = {
size: actualArchive.size,
type: "application/zip",
slice(start?: number, end?: number) {
const from = start ?? 0;
const to = end ?? actualArchive.size;
const length = to - from;
rangeLengths.push(length);
if (length > 1024 * 1024) throw new Error(`Buffered ${length} bytes from the archive`);
return actualArchive.slice(from, to);
},
stream() {
throw new Error("The whole archive must not be streamed");
},
arrayBuffer: async () => {
wholeArchiveBuffered = true;
throw new Error("The whole archive must not be buffered");
},
} as Blob;
const backup = await parseEdgeEverZip(largeStreamingArchive);
expect(backup.manifest.buildId).toBe("large-stream-test");
expect(backup.archive).toBe(largeStreamingArchive);
expect(wholeArchiveBuffered).toBe(false);
expect(rangeLengths).toContain(1024 * 1024);
expect(Math.max(...rangeLengths)).toBeLessThanOrEqual(1024 * 1024);
});
test("restores stored Office attachments that contain their own ZIP entries", async () => {
const docx = zipSync({
"[Content_Types].xml": strToU8("<Types></Types>"),
"word/document.xml": strToU8("<document>hello</document>"),
});
const xlsx = zipSync({
"[Content_Types].xml": strToU8("<Types></Types>"),
"xl/workbook.xml": strToU8("<workbook/>"),
});
const officeNotebook = notebook("nb_office", "资料");
const officeMemo: MemoDetail = {
...memo,
id: "memo_office",
notebookId: officeNotebook.id,
title: "Office",
contentMarkdown: "office",
};
const officeResource = (
id: string,
filename: string,
mimeType: string,
bytes: Uint8Array,
): Resource => ({
...resource,
id,
memoId: officeMemo.id,
kind: "attachment",
mimeType,
filename,
byteSize: bytes.byteLength,
sha256: null,
width: null,
height: null,
url: `/api/v1/resources/${id}/blob`,
});
const docxResource = officeResource(
"res_docx",
"brief.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
docx,
);
const xlsxResource = officeResource(
"res_xlsx",
"sheet.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
xlsx,
);
const files = new Map<string, Uint8Array>([
[docxResource.url, docx],
[xlsxResource.url, xlsx],
]);
const blob = await createEdgeEverZip({
listNotebooks: async () => ({ notebooks: [officeNotebook] }),
listPrompts: async () => ({ prompts: [] }),
getPage: async () => ({
memos: [officeMemo],
resources: [docxResource, xlsxResource],
revisions: [],
totalCount: 1,
nextOffset: null,
}),
getResourceResponse: async (url) => new Response(files.get(url)),
}, { edgeeverVersion: "1.95.0", buildId: "office-zip" });
const backup = await parseEdgeEverZip(blob);
const restored = new Map<string, Uint8Array>();
await restoreEdgeEverZip(backup, {
restoreNotebooks: async () => {},
restoreMemos: async () => {},
restorePrompts: async () => {},
createResourceRestoreSink: async (resourceId) => {
const chunks: Uint8Array[] = [];
return {
write: async (chunk) => {
chunks.push(new Uint8Array(chunk));
},
close: async () => {
const bytes = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.byteLength, 0));
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
restored.set(resourceId, bytes);
},
abort: async () => {},
};
},
});
expect(backup.manifest.counts.resources).toBe(2);
expect(backup.memos[0].resources.map((item) => item.archivePath)).toEqual([
"notes/资料/Office.assets/brief.docx",
"notes/资料/Office.assets/sheet.xlsx",
]);
expect(restored.get("res_docx")).toEqual(docx);
expect(restored.get("res_xlsx")).toEqual(xlsx);
});
test("restores notebooks before memos and binary resources", async () => {
const calls: string[] = [];
const { url: _url, ...resourceMetadata } = resource;
const backupMemo: JsonBackupMemo = {
memo,
revisions: [],
resources: [{ ...resourceMetadata, archivePath: "resources/res_backup/asset.png" }],
};
const backup = {
manifest: {
format: "edgeever-zip" as const,
formatVersion: 2 as const,
schemaVersion: 1,
edgeeverVersion: "0.1.13",
buildId: "test-build",
exportedAt: "2026-07-14T00:00:00.000Z",
includesTrash: false as const,
counts: { notebooks: 1, memos: 1, revisions: 0, resources: 1, prompts: 1 },
},
notebooks: [{
id: "nb_child",
parentId: null,
name: "Child",
slug: null,
icon: null,
color: null,
sortOrder: 0,
createdAt: memo.createdAt,
updatedAt: memo.updatedAt,
}] satisfies JsonBackupNotebook[],
memos: [backupMemo],
prompts: [prompt],
archive: new Blob([zipSync({ "resources/res_backup/asset.png": new Uint8Array([1, 2, 3]) })]),
};
await restoreEdgeEverZip(backup, {
restoreNotebooks: async () => { calls.push("notebooks"); },
restoreMemos: async () => { calls.push("memos"); },
restorePrompts: async () => { calls.push("prompts"); },
createResourceRestoreSink: async () => {
let byteSize = 0;
return {
write: async (chunk) => { byteSize += chunk.byteLength; },
close: async () => { calls.push(`resource:${byteSize}`); },
abort: async () => {},
};
},
});
expect(calls).toEqual(["prompts", "notebooks", "memos", "resource:3"]);
});
test("refreshes the workspace after all imported data is restored", async () => {
const calls: string[] = [];
const backup = {
manifest: {
format: "edgeever-zip" as const,
formatVersion: 1 as const,
schemaVersion: 1,
edgeeverVersion: "1.13.3",
buildId: "test-build",
exportedAt: "2026-08-09T00:00:00.000Z",
includesTrash: false as const,
counts: { notebooks: 1, memos: 1, revisions: 0, resources: 0 },
},
notebooks: [{
id: "nb_child",
parentId: null,
name: "Child",
slug: null,
icon: null,
color: null,
sortOrder: 0,
createdAt: memo.createdAt,
updatedAt: memo.updatedAt,
}] satisfies JsonBackupNotebook[],
memos: [{ memo, revisions: [], resources: [] }] satisfies JsonBackupMemo[],
prompts: [],
archive: new Blob([zipSync({})]),
};
await restoreEdgeEverZipAndRefresh(
backup,
{
restoreNotebooks: async () => { calls.push("notebooks"); },
restoreMemos: async () => { calls.push("memos"); },
restorePrompts: async () => { calls.push("prompts"); },
createResourceRestoreSink: async () => ({
write: async () => {},
close: async () => { calls.push("resource"); },
abort: async () => {},
}),
},
async () => { calls.push("refresh"); },
);
expect(calls).toEqual(["notebooks", "memos", "refresh"]);
});
});