Files
edgeever/tests/json-backup.test.ts
T
tianma-if 12af0581d2 fix(web): import backups that contain Office attachments
Streaming unzip treated ZIP signatures inside stored .docx, .xlsx, and .pptx files as archive entries. Read each entry from the central directory so existing exports restore.
2026-10-01 22:32:19 +08:00

435 lines
15 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import type { AiPromptTemplate, JsonBackupMemo, JsonBackupNotebook, MemoDetail, Notebook, Resource } 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(markdown).toContain("edgeever_id: \"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"]);
});
});