Files
edgeever/tests/selected-markdown-export.test.ts
T
tianma-if a4a10a32b9 feat(web): export selected notes as a Markdown ZIP
Add a bulk Markdown ZIP action for selected notes, reuse the existing
readable export layout, and keep the desktop bulk action card visible
when a diagram note is open.
2026-09-10 17:07:46 +08:00

150 lines
4.9 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import type { MemoDetail, Notebook, Resource } from "@edgeever/shared";
import { unzipSync } from "fflate";
import {
exportSelectedMemosAsMarkdownZip,
MAX_SELECTED_MARKDOWN_EXPORT_MEMO_IDS,
partitionExportableMemoIds,
} from "../apps/web/src/lib/selected-markdown-export";
const notebook = (input: Partial<Notebook> & Pick<Notebook, "id" | "name">): Notebook => ({
id: input.id,
parentId: input.parentId ?? null,
name: input.name,
slug: null,
icon: null,
color: null,
sortOrder: 0,
memoCount: 0,
lastMemoUpdatedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const memo = (input: Partial<MemoDetail> & Pick<MemoDetail, "id" | "title">): MemoDetail => ({
id: input.id,
notebookId: "nb_root",
title: input.title,
excerpt: "",
tags: [],
isPinned: false,
isArchived: false,
isDeleted: false,
revision: 1,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
deletedAt: null,
contentJson: { type: "doc", content: [] },
contentMarkdown: input.contentMarkdown ?? "Hello",
contentText: "Hello",
contentHash: "hash",
sourceMemoIds: [],
mergeSourceCount: 0,
mergedIntoMemoId: null,
});
const resource: Resource = {
id: "res_1",
memoId: "memo_keep",
originalMemoId: null,
kind: "image",
mimeType: "image/png",
filename: "photo.png",
byteSize: 3,
sha256: null,
width: null,
height: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
url: "/api/v1/resources/res_1/blob",
};
describe("selected Markdown ZIP export", () => {
test("drops unsynced local ids before talking to the export API", () => {
expect(partitionExportableMemoIds(["memo_1", "local_draft", "memo_1", "local_other"])).toEqual({
exportable: ["memo_1"],
skippedLocal: ["local_draft", "local_other"],
});
});
test("refuses selections larger than the batch limit", async () => {
const memoIds = Array.from({ length: MAX_SELECTED_MARKDOWN_EXPORT_MEMO_IDS + 1 }, (_, index) => `memo_${index}`);
const result = await exportSelectedMemosAsMarkdownZip({
memoIds,
listNotebooks: async () => ({ notebooks: [] }),
getPage: async () => {
throw new Error("should not fetch pages for oversized selections");
},
getResourceBlob: async () => new Blob(),
save: () => {
throw new Error("should not download an oversized selection");
},
});
expect(result).toEqual({
status: "too-many",
selected: MAX_SELECTED_MARKDOWN_EXPORT_MEMO_IDS + 1,
max: MAX_SELECTED_MARKDOWN_EXPORT_MEMO_IDS,
});
});
test("skips download when every selected note is still local", async () => {
const result = await exportSelectedMemosAsMarkdownZip({
memoIds: ["local_one", "local_two"],
listNotebooks: async () => ({ notebooks: [] }),
getPage: async () => {
throw new Error("should not fetch pages for local-only selections");
},
getResourceBlob: async () => new Blob(),
save: () => {
throw new Error("should not download a local-only selection");
},
});
expect(result).toEqual({ status: "local-only", skippedLocal: 2 });
});
test("writes only the requested notes and reports skipped local drafts", async () => {
const keep = memo({ id: "memo_keep", title: "保留笔记" });
let requestedIds: string[] | undefined;
const saved: Blob[] = [];
const result = await exportSelectedMemosAsMarkdownZip({
memoIds: ["memo_keep", "local_draft", "memo_keep"],
listNotebooks: async () => ({ notebooks: [notebook({ id: "nb_root", name: "工作" })] }),
getPage: async (_offset, _limit, memoIds) => {
requestedIds = memoIds;
return {
memos: [keep],
resources: [resource],
totalCount: 1,
nextOffset: null,
};
},
getResourceBlob: async () => new Blob([new Uint8Array([1, 2, 3])]),
save: (blob) => saved.push(blob),
});
expect(requestedIds).toEqual(["memo_keep"]);
expect(result).toEqual({ status: "ok", exported: 1, skippedLocal: 1 });
expect(saved).toHaveLength(1);
expect(Object.keys(unzipSync(new Uint8Array(await saved[0].arrayBuffer()))).sort()).toEqual([
"工作/保留笔记.assets/photo.png",
"工作/保留笔记.md",
]);
});
test("does not download an empty archive when none of the ids exist", async () => {
const result = await exportSelectedMemosAsMarkdownZip({
memoIds: ["memo_missing"],
listNotebooks: async () => ({ notebooks: [] }),
getPage: async () => ({ memos: [], resources: [], totalCount: 0, nextOffset: null }),
getResourceBlob: async () => new Blob(),
save: () => {
throw new Error("should not download an empty archive");
},
});
expect(result).toEqual({ status: "empty" });
});
});