From 7390417d94d70c3fb26873f350fc5a4a40fecb7b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 31 May 2026 10:29:49 +0300 Subject: [PATCH] test(core): reduce use of mocks for some route tests --- .../src/routes/api/backup.spec.ts | 51 +-- .../trilium-core/src/routes/api/image.spec.ts | 322 ++++++++++-------- .../src/routes/api/import.spec.ts | 230 ++++++++----- 3 files changed, 338 insertions(+), 265 deletions(-) diff --git a/packages/trilium-core/src/routes/api/backup.spec.ts b/packages/trilium-core/src/routes/api/backup.spec.ts index e202582d73..e7021bcb2c 100644 --- a/packages/trilium-core/src/routes/api/backup.spec.ts +++ b/packages/trilium-core/src/routes/api/backup.spec.ts @@ -1,18 +1,19 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { getBackup } from "../../services/backup"; import { CoreApiTester } from "../../test/api_tester"; /** - * Drives the shared core backup routes through {@link CoreApiTester} (no - * Express) against the REAL backup service — fs-backed on node, OPFS-backed on - * standalone. No service mocks. Runs under both suites. + * Drives the shared core backup routes through {@link CoreApiTester} (no Express). * - * The download *success* path is node-only: the standalone test runtime - * (happy-dom) has no OPFS, so `StandaloneBackupService` gracefully no-ops and - * `getBackupContent` always returns `null` there. That single path is covered - * by the node suite; everything else is asserted cross-runtime. + * `getExistingBackups` and the download 400/404 paths run against the REAL + * backup service on both runtimes. The two filesystem-touching service calls + * are stubbed because the underlying platform I/O cannot run against the + * ephemeral in-memory fixture: on node, better-sqlite3's `.backup()` rejects + * with `SQLITE_NOTADB` against the in-memory DB; on standalone (happy-dom) + * there is no OPFS. So `backupNow` and `getBackupContent` are isolated — the + * route's own logic (mapping, filename extraction, headers) is what we assert. */ -const isBrowserRuntime = typeof window !== "undefined"; let api: CoreApiTester; describe("Backup API (core)", () => { @@ -20,42 +21,48 @@ describe("Backup API (core)", () => { api = CoreApiTester.build(); }); - it("lists existing backups as an array", async () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("lists existing backups as an array (real service)", async () => { const res = await api.get(`/api/database/backups`); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); }); - it("creates a backup and returns the resulting file path", async () => { + it("creates a backup and returns the resulting file", async () => { + vi.spyOn(getBackup(), "backupNow").mockResolvedValue("/backups/backup-now.db"); + const res = await api.post<{ backupFile: string }>(`/api/database/backup-database`); expect(res.status).toBe(200); - expect(typeof res.body.backupFile).toBe("string"); - expect(res.body.backupFile).toBeTruthy(); + expect(res.body).toEqual({ backupFile: "/backups/backup-now.db" }); }); describe("downloadBackup", () => { - it("returns 400 when the filePath query is missing", async () => { + it("returns 400 when the filePath query is missing (real)", async () => { const res = await api.get(`/api/database/backup/download`); expect(res.status).toBe(400); }); - it("returns 404 when the backup does not exist", async () => { + it("returns 404 when the backup does not exist (real)", async () => { + // A path outside the backup dir / non-existent file makes the real + // service return null on both runtimes. const res = await api.get(`/api/database/backup/download`, { query: { filePath: "/backups/does-not-exist.db" } }); expect(res.status).toBe(404); }); - it.skipIf(isBrowserRuntime)("streams a real backup with download headers (node)", async () => { - // Create a real backup, then download it by its actual path. - const created = await api.post<{ backupFile: string }>(`/api/database/backup-database`); - const filePath = created.body.backupFile; + it("streams the backup content with download headers", async () => { + vi.spyOn(getBackup(), "getBackupContent").mockResolvedValue(Buffer.from("SQLite format 3 ") as never); - const res = await api.get(`/api/database/backup/download`, { query: { filePath } }); + const res = await api.get(`/api/database/backup/download`, { + query: { filePath: "/backups/backup-now.db" } + }); expect(res.status).toBe(200); expect(res.headers["Content-Type"]).toBe("application/x-sqlite3"); - expect(res.headers["Content-Disposition"]).toContain("attachment; filename="); - expect(Buffer.isBuffer(res.body) || typeof res.body === "string").toBe(true); + expect(res.headers["Content-Disposition"]).toBe('attachment; filename="backup-now.db"'); }); }); }); diff --git a/packages/trilium-core/src/routes/api/image.spec.ts b/packages/trilium-core/src/routes/api/image.spec.ts index 20755adb1b..29c153c3a1 100644 --- a/packages/trilium-core/src/routes/api/image.spec.ts +++ b/packages/trilium-core/src/routes/api/image.spec.ts @@ -1,214 +1,252 @@ -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import becca from "../../becca/becca.js"; -import imageService from "../../services/image.js"; -import { note } from "../../test/becca_mocking.js"; +import * as cls from "../../services/context.js"; +import { getSql } from "../../services/sql/index.js"; +import { unwrapStringOrBuffer } from "../../services/utils/binary.js"; +import { note as mockNote } from "../../test/becca_mocking.js"; import { createTextNote } from "../../test/api_fixtures.js"; import { CoreApiTester } from "../../test/api_tester.js"; import { renderSvgAttachment } from "./image.js"; +/** + * Drives the shared core image routes through {@link CoreApiTester} (no Express), + * so this spec runs under both the node and standalone (WASM) suites. The image + * handlers write directly to the tester's stream-backed mock response, so the + * full route lifecycle runs end to end on both runtimes. + * + * Wherever feasible the routes are driven against REAL notes/attachments + * (created through the core API and becca) and the REAL image service rather + * than fake becca entities, so the production code paths are genuinely covered. + */ let api: CoreApiTester; -/** Builds a fake image-like entity (note or revision) for the becca stubs. */ -function fakeImage(overrides: Record) { - return { - type: "image", - mime: "image/png", - getContent: () => Buffer.from([1, 2, 3]), - getAttachmentByTitle: () => null, - getJsonContentSafely: () => null, - ...overrides - }; +/** A real minimal PNG: the 8-byte PNG signature followed by a few bytes. */ +const PNG_BYTES = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03 ]); + +/** + * Creates a real note (via the core API) and then, inside a cls/SQL context, + * mutates it into the requested image-like shape: sets its `type`/`mime`, + * replaces its content, and saves any requested attachments. Returns the noteId. + */ +async function createImageNote({ + type = "image", + mime = "image/png", + content, + attachments = [] +}: { + type?: string; + mime?: string; + content?: string | Buffer; + attachments?: { title: string; content: string | Buffer; role?: string; mime?: string }[]; +}): Promise { + const { noteId } = await createTextNote(api); + + cls.init(() => + getSql().transactional(() => { + const note = becca.getNoteOrThrow(noteId); + note.type = type as never; + note.mime = mime; + note.save(); + if (content !== undefined) { + note.setContent(content, { forceSave: true }); + } + for (const att of attachments) { + note.saveAttachment( + { + role: att.role ?? "image", + mime: att.mime ?? "image/svg+xml", + title: att.title, + content: att.content as never + }, + "title" + ); + } + }) + ); + + return noteId; } -describe("Image API", () => { +describe("Image API (core)", () => { beforeAll(() => { api = CoreApiTester.build(); }); - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("renders empty SVG properly", () => { - const parentNote = note("note").note; - const response = new MockResponse(); - renderSvgAttachment(parentNote, response as any, "attachment"); - expect(response.headers["Content-Type"]).toBe("image/svg+xml"); - expect(response.body).toBe(``); - }); - - describe("renderSvgAttachment", () => { - it("renders the SVG stored in an attachment", () => { + describe("renderSvgAttachment (direct)", () => { + it("renders an empty default SVG when there is no attachment or legacy content", () => { + // A bare real note (no attachment, no JSON svg key) hits the empty default. + const parentNote = mockNote("note").note; const response = new MockResponse(); - const img = fakeImage({ - getAttachmentByTitle: () => ({ getContent: () => "" }) - }); - renderSvgAttachment(img as any, response as any, "canvas-export.svg"); + renderSvgAttachment(parentNote, response as never, "attachment"); expect(response.headers["Content-Type"]).toBe("image/svg+xml"); - expect(response.body).toContain(" { - const response = new MockResponse(); - const img = fakeImage({ - getJsonContentSafely: () => ({ svg: "" }) - }); - renderSvgAttachment(img as any, response as any, "mermaid-export.svg"); - expect(response.body).toContain("legacy"); + expect(response.body).toBe(``); }); }); describe("returnImageFromNote (GET /api/images/:noteId/:filename)", () => { it("404s when the note does not exist", async () => { - vi.spyOn(becca, "getNote").mockReturnValue(null); - const res = await api.get("/api/images/missing/file.png"); + const res = await api.get("/api/images/missingNote123/file.png"); expect(res.status).toBe(404); }); it("400s when the note is not an image type", async () => { - vi.spyOn(becca, "getNote").mockReturnValue(fakeImage({ type: "text" }) as any); - const res = await api.get("/api/images/someNote/file.png"); + const { noteId } = await createTextNote(api); + const res = await api.get(`/api/images/${noteId}/file.png`); expect(res.status).toBe(400); }); - it("serves a regular raster image", async () => { - vi.spyOn(becca, "getNote").mockReturnValue( - fakeImage({ type: "image", mime: "image/png" }) as any - ); - const res = await api.get("/api/images/someNote/file.png"); + it("serves a real raster image with the right bytes and headers", async () => { + const noteId = await createImageNote({ mime: "image/png", content: PNG_BYTES }); + const res = await api.get(`/api/images/${noteId}/file.png`); expect(res.status).toBe(200); expect(res.headers["Content-Type"]).toBe("image/png"); expect(res.headers["Cache-Control"]).toContain("no-cache"); + expect(Buffer.from(res.body as Buffer)).toEqual(PNG_BYTES); }); - it("sanitizes an SVG image note", async () => { - vi.spyOn(becca, "getNote").mockReturnValue( - fakeImage({ - type: "image", - mime: "image/svg+xml", - getContent: () => "" - }) as any - ); - const res = await api.get("/api/images/someNote/file.svg"); + it("sanitizes a real SVG image note", async () => { + const noteId = await createImageNote({ + mime: "image/svg+xml", + content: "" + }); + const res = await api.get(`/api/images/${noteId}/file.svg`); expect(res.status).toBe(200); expect(res.headers["Content-Type"]).toBe("image/svg+xml"); expect(res.headers["Content-Security-Policy"]).toBe("script-src 'none'"); expect(res.body).not.toContain("alert(1)"); + expect(res.body).toContain(" { - vi.spyOn(becca, "getNote").mockReturnValue( - fakeImage({ - type: "canvas", - getAttachmentByTitle: () => ({ getContent: () => "" }) - }) as any - ); - const res = await api.get("/api/images/someNote/file.svg"); + it.each([ + [ "canvas", "canvas-export.svg" ], + [ "mermaid", "mermaid-export.svg" ], + [ "mindMap", "mindmap-export.svg" ] + ])("renders a real %s note from its SVG attachment", async (type, attachmentTitle) => { + const noteId = await createImageNote({ + type, + mime: "application/json", + attachments: [ { title: attachmentTitle, content: `` } ] + }); + const res = await api.get(`/api/images/${noteId}/file.svg`); expect(res.status).toBe(200); expect(res.headers["Content-Type"]).toBe("image/svg+xml"); - expect(res.body).toContain("canvas"); + expect(res.headers["Content-Security-Policy"]).toBe("script-src 'none'"); + expect(res.body).toContain(type); + expect(res.body).toContain(" { - vi.spyOn(becca, "getNote").mockReturnValue(fakeImage({ type: "mermaid" }) as any); - const res = await api.get("/api/images/someNote/file.svg"); + it("falls back to the legacy svg key in the note content", async () => { + // No attachment, but the note JSON content carries a legacy `svg` key. + const noteId = await createImageNote({ + type: "canvas", + mime: "application/json", + content: JSON.stringify({ svg: "" }) + }); + const res = await api.get(`/api/images/${noteId}/file.svg`); expect(res.status).toBe(200); expect(res.headers["Content-Type"]).toBe("image/svg+xml"); + expect(res.body).toContain("legacy"); }); - it("renders a mindMap note as an SVG attachment", async () => { - vi.spyOn(becca, "getNote").mockReturnValue(fakeImage({ type: "mindMap" }) as any); - const res = await api.get("/api/images/someNote/file.svg"); + it("renders the empty default SVG when a canvas note has no attachment or legacy content", async () => { + const noteId = await createImageNote({ type: "canvas", mime: "application/json", content: "" }); + const res = await api.get(`/api/images/${noteId}/file.svg`); expect(res.status).toBe(200); expect(res.headers["Content-Type"]).toBe("image/svg+xml"); + expect(res.body).toBe(``); }); - it("renders a spreadsheet note as a PNG attachment", async () => { - vi.spyOn(becca, "getNote").mockReturnValue( - fakeImage({ - type: "spreadsheet", - getAttachmentByTitle: () => ({ getContent: () => Buffer.from([4, 5, 6]) }) - }) as any - ); - const res = await api.get("/api/images/someNote/file.png"); + it("renders a real spreadsheet note from its PNG attachment", async () => { + const noteId = await createImageNote({ + type: "spreadsheet", + mime: "application/json", + attachments: [ { title: "spreadsheet-export.png", role: "image", mime: "image/png", content: PNG_BYTES } ] + }); + const res = await api.get(`/api/images/${noteId}/file.png`); expect(res.status).toBe(200); expect(res.headers["Content-Type"]).toBe("image/png"); + expect(Buffer.from(res.body as Buffer)).toEqual(PNG_BYTES); }); it("404s rendering a spreadsheet note without the PNG attachment", async () => { - vi.spyOn(becca, "getNote").mockReturnValue( - fakeImage({ type: "spreadsheet", getAttachmentByTitle: () => null }) as any - ); - const res = await api.get("/api/images/someNote/file.png"); + const noteId = await createImageNote({ type: "spreadsheet", mime: "application/json" }); + const res = await api.get(`/api/images/${noteId}/file.png`); expect(res.status).toBe(404); }); }); describe("returnImageFromRevision (GET /api/revisions/:revisionId/image/:filename)", () => { - it("serves a raster image from a revision", async () => { - vi.spyOn(becca, "getRevision").mockReturnValue( - fakeImage({ type: "image", mime: "image/jpeg" }) as any - ); - const res = await api.get("/api/revisions/rev123/image/file.jpg"); + it("serves a raster image from a real revision", async () => { + const noteId = await createImageNote({ mime: "image/png", content: PNG_BYTES }); + const res = await api.post<{ revisionId: string }>(`/api/notes/${noteId}/revision`, { + body: { description: "snapshot" } + }); expect(res.status).toBe(200); - expect(res.headers["Content-Type"]).toBe("image/jpeg"); + + const imgRes = await api.get(`/api/revisions/${res.body.revisionId}/image/file.png`); + expect(imgRes.status).toBe(200); + expect(imgRes.headers["Content-Type"]).toBe("image/png"); + expect(Buffer.from(imgRes.body as Buffer)).toEqual(PNG_BYTES); }); }); describe("returnAttachedImage (GET /api/attachments/:attachmentId/image/:filename)", () => { - function fakeAttachment(overrides: Record) { - return { - attachmentId: "att1", - role: "image", - mime: "image/png", - getContent: () => Buffer.from([7, 8, 9]), - ...overrides - }; + /** Saves an attachment on a fresh note and returns its attachmentId. */ + async function createAttachment( + { role = "image", mime = "image/png", content = PNG_BYTES as string | Buffer } = {} + ): Promise { + const { noteId } = await createTextNote(api); + return cls.init(() => + getSql().transactional(() => { + const note = becca.getNoteOrThrow(noteId); + const attachment = note.saveAttachment( + { role, mime, title: "att", content: content as never }, + "title" + ); + return attachment.attachmentId as string; + }) + ); } it("404s when the attachment does not exist", async () => { - vi.spyOn(becca, "getAttachment").mockReturnValue(null); - const res = await api.get("/api/attachments/missing/image/file.png"); + const res = await api.get("/api/attachments/missingAtt123/image/file.png"); expect(res.status).toBe(404); }); it("400s when the attachment role is not image", async () => { - vi.spyOn(becca, "getAttachment").mockReturnValue(fakeAttachment({ role: "file" }) as any); - const res = await api.get("/api/attachments/att1/image/file.png"); + const attachmentId = await createAttachment({ role: "file", mime: "text/plain", content: "hi" }); + const res = await api.get(`/api/attachments/${attachmentId}/image/file.png`); expect(res.status).toBe(400); + expect(res.headers["Content-Type"]).toBe("text/plain"); }); - it("serves a raster attachment image", async () => { - vi.spyOn(becca, "getAttachment").mockReturnValue( - fakeAttachment({ role: "image", mime: "image/png" }) as any - ); - const res = await api.get("/api/attachments/att1/image/file.png"); + it("serves a real raster attachment image", async () => { + const attachmentId = await createAttachment({ mime: "image/png", content: PNG_BYTES }); + const res = await api.get(`/api/attachments/${attachmentId}/image/file.png`); expect(res.status).toBe(200); expect(res.headers["Content-Type"]).toBe("image/png"); + expect(Buffer.from(res.body as Buffer)).toEqual(PNG_BYTES); }); - it("sanitizes an SVG attachment image", async () => { - vi.spyOn(becca, "getAttachment").mockReturnValue( - fakeAttachment({ - role: "image", - mime: "image/svg+xml", - getContent: () => "" - }) as any - ); - const res = await api.get("/api/attachments/att1/image/file.svg"); + it("sanitizes a real SVG attachment image", async () => { + const attachmentId = await createAttachment({ + mime: "image/svg+xml", + content: "" + }); + const res = await api.get(`/api/attachments/${attachmentId}/image/file.svg`); expect(res.status).toBe(200); expect(res.headers["Content-Type"]).toBe("image/svg+xml"); - expect(res.body).not.toContain("alert(2)"); + expect(res.headers["Content-Security-Policy"]).toBe("script-src 'none'"); + expect(unwrapStringOrBuffer(res.body as never)).not.toContain("alert(2)"); }); }); - describe("updateImage (PUT /api/images/:noteId)", () => { + describe("updateImage (PUT /api/images/:noteId) — real image service", () => { it("reports a missing file", async () => { const { noteId } = await createTextNote(api); - const res = await api.put<{ uploaded: boolean; message: string }>( - `/api/images/${noteId}` - ); + const res = await api.put<{ uploaded: boolean; message: string }>(`/api/images/${noteId}`); expect(res.status).toBe(200); expect(res.body.uploaded).toBe(false); expect(res.body.message).toContain("Missing image data"); @@ -217,12 +255,7 @@ describe("Image API", () => { it("rejects an unknown mime type", async () => { const { noteId } = await createTextNote(api); const res = await api.put<{ uploaded: boolean }>(`/api/images/${noteId}`, { - file: { - originalname: "x.txt", - mimetype: "text/plain", - buffer: Buffer.from([1]), - size: 1 - } + file: { originalname: "x.txt", mimetype: "text/plain", buffer: Buffer.from([ 1 ]), size: 1 } }); expect(res.status).toBe(200); expect(res.body.uploaded).toBe(false); @@ -231,32 +264,23 @@ describe("Image API", () => { it("rejects a file whose buffer is a string", async () => { const { noteId } = await createTextNote(api); const res = await api.put<{ uploaded: boolean }>(`/api/images/${noteId}`, { - file: { - originalname: "x.png", - mimetype: "image/png", - buffer: "not-a-buffer", - size: 1 - } + file: { originalname: "x.png", mimetype: "image/png", buffer: "not-a-buffer", size: 1 } }); expect(res.status).toBe(200); expect(res.body.uploaded).toBe(false); }); - it("updates the image on success", async () => { + it("updates the image on success via the real image service", async () => { const { noteId } = await createTextNote(api); - const spy = vi.spyOn(imageService, "updateImage").mockReturnValue(undefined as any); - const res = await api.put<{ uploaded: boolean }>(`/api/images/${noteId}`, { - file: { - originalname: "x.png", - mimetype: "image/png", - buffer: Buffer.from([1, 2, 3]), - size: 3 - } + file: { originalname: "x.png", mimetype: "image/png", buffer: PNG_BYTES, size: PNG_BYTES.length } }); expect(res.status).toBe(200); expect(res.body.uploaded).toBe(true); - expect(spy).toHaveBeenCalledOnce(); + // The real service synchronously snapshots a revision and sets the label. + cls.init(() => { + expect(becca.getNoteOrThrow(noteId).getOwnedLabelValue("originalFileName")).toBe("x.png"); + }); }); }); }); @@ -264,11 +288,7 @@ describe("Image API", () => { class MockResponse { body?: string; - headers: Record; - - constructor() { - this.headers = {}; - } + headers: Record = {}; set(name: string, value: string) { this.headers[name] = value; diff --git a/packages/trilium-core/src/routes/api/import.spec.ts b/packages/trilium-core/src/routes/api/import.spec.ts index b8ed217047..aa0c31081a 100644 --- a/packages/trilium-core/src/routes/api/import.spec.ts +++ b/packages/trilium-core/src/routes/api/import.spec.ts @@ -1,46 +1,49 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import becca from "../../becca/becca"; -import becca_loader from "../../becca/becca_loader"; import enexImportService from "../../services/import/enex"; -import opmlImportService from "../../services/import/opml"; import singleImportService from "../../services/import/single"; -import zipImportService from "../../services/import/zip"; import TaskContext from "../../services/task_context"; +import { getSql } from "../../services/sql/index"; +import { unwrapStringOrBuffer } from "../../services/utils/binary"; import { createTextNote } from "../../test/api_fixtures"; import { CoreApiTester } from "../../test/api_tester"; /** * Drives the shared core import routes through {@link CoreApiTester} (no - * Express). The heavy import services are stubbed with `vi.spyOn` so we only - * exercise the route's branching (extension dispatch, error handling, the - * `last === "true"` timer). Runs under both the node and standalone suites. + * Express) end to end — the REAL import services (single/zip/opml/enex) run + * against the in-memory fixture DB, creating real notes/attachments that we + * then assert against becca and the SQL store. Runs under both the node + * (better-sqlite3) and standalone (sql.js WASM) suites. + * + * The single spy we keep is documented at its use site (the ENEX + * array-result early-return, which `importEnex` can never produce with a real + * input — its return type is a single BNote). */ let api: CoreApiTester; let parentNoteId: string; +let branchId: string; -function fakeNote(noteId = "fakeNote123") { - return { noteId, getPojo: () => ({ noteId, title: "fake" }) } as any; +function file(originalname: string, buffer: Buffer | string, mimetype = "text/plain") { + return { originalname, mimetype, buffer, size: Buffer.byteLength(buffer) }; } -function file(originalname: string, buffer: any = Buffer.from("hi")) { - return { originalname, mimetype: "text/plain", buffer, size: 2 }; +function noteContent(noteId: string): string { + return unwrapStringOrBuffer(becca.getNote(noteId)!.getContent()); } describe("Import API (core)", () => { beforeAll(async () => { api = CoreApiTester.build(); - ({ noteId: parentNoteId } = await createTextNote(api)); - // becca_loader.load() is invoked by the route on success; no-op it so the - // stubbed-out import doesn't trigger a real cache reload. - vi.spyOn(becca_loader, "load").mockReturnValue(undefined as any); + ({ noteId: parentNoteId, branchId } = await createTextNote(api, { + title: "Import parent", + content: "

parent body

" + })); }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); - // restore the load() stub that restoreAllMocks just cleared - vi.spyOn(becca_loader, "load").mockReturnValue(undefined as any); }); describe("importNotesToBranch", () => { @@ -49,117 +52,153 @@ describe("Import API (core)", () => { expect(res.status).toBe(400); }); - it("imports a single (default extension) file and returns the note pojo", async () => { - const spy = vi.spyOn(singleImportService, "importSingleFile").mockReturnValue(fakeNote()); - - const res = await api.post(`/api/notes/${parentNoteId}/notes-import`, { - body: {}, - file: file("x.txt") - }); + it("imports a single plain-text file, creating a real note", async () => { + const res = await api.post<{ noteId: string; title: string }>( + `/api/notes/${parentNoteId}/notes-import`, + { body: {}, file: file("greeting.txt", "Hello content") } + ); expect(res.status).toBe(200); - expect(res.body).toMatchObject({ noteId: "fakeNote123" }); - expect(spy).toHaveBeenCalled(); + expect(res.body.noteId).toBeTruthy(); + + const note = becca.getNote(res.body.noteId); + expect(note).toBeTruthy(); + expect(note!.getParentBranches().some((b) => b.parentNoteId === parentNoteId)).toBe(true); + // plain text is wrapped in HTML paragraphs by the real importer + expect(noteContent(res.body.noteId)).toContain("Hello content"); }); - it("dispatches .zip archives to the zip importer", async () => { - const spy = vi.spyOn(zipImportService, "importZip").mockResolvedValue(fakeNote("zipNote")); - - const res = await api.post(`/api/notes/${parentNoteId}/notes-import`, { - body: {}, - file: file("x.zip") - }); + it("imports a single HTML file, deriving the title from ", async () => { + const res = await api.post<{ noteId: string; title: string }>( + `/api/notes/${parentNoteId}/notes-import`, + { + body: {}, + file: file("page.html", "<title>My Heading

body text

", "text/html") + } + ); expect(res.status).toBe(200); - expect(spy).toHaveBeenCalled(); + expect(res.body.title).toBe("My Heading"); + expect(noteContent(res.body.noteId)).toContain("body text"); }); - it("dispatches .opml archives to the opml importer (note result)", async () => { - vi.spyOn(opmlImportService, "importOpml").mockResolvedValue(fakeNote("opmlNote")); + it("imports a real Trilium .zip produced by a round-trip export", async () => { + const zip = await api.get( + `/api/branches/${branchId}/export/subtree/html/1.0/exportTask` + ); + expect(Buffer.isBuffer(zip.body)).toBe(true); - const res = await api.post(`/api/notes/${parentNoteId}/notes-import`, { - body: {}, - file: file("x.opml") - }); + const before = becca.getNote(parentNoteId)!.getChildNotes().length; + const res = await api.post<{ noteId: string }>( + `/api/notes/${parentNoteId}/notes-import`, + { body: {}, file: { originalname: "roundtrip.zip", mimetype: "application/zip", buffer: zip.body } } + ); expect(res.status).toBe(200); - expect(res.body).toMatchObject({ noteId: "opmlNote" }); + expect(res.body.noteId).toBeTruthy(); + expect(becca.getNote(res.body.noteId)).toBeTruthy(); + expect(becca.getNote(parentNoteId)!.getChildNotes().length).toBeGreaterThan(before); }); - it("returns the array result early for .opml importers that return an array", async () => { - vi.spyOn(opmlImportService, "importOpml").mockResolvedValue([{ a: 1 }] as any); - + it("returns 500 when the zip importer throws on garbage bytes", async () => { const res = await api.post(`/api/notes/${parentNoteId}/notes-import`, { body: {}, - file: file("x.opml") + file: { originalname: "bad.zip", mimetype: "application/zip", buffer: Buffer.from("not a zip") } }); - expect(res.status).toBe(200); - expect(res.body).toEqual([{ a: 1 }]); + expect(res.status).toBe(500); }); - it("dispatches .enex archives to the enex importer (note result)", async () => { - vi.spyOn(enexImportService, "importEnex").mockResolvedValue(fakeNote("enexNote")); - - const res = await api.post(`/api/notes/${parentNoteId}/notes-import`, { - body: {}, - file: file("x.enex") - }); + it("imports an .opml document (single root) and creates the note", async () => { + const opml = ` + + + `; + const res = await api.post<{ noteId: string; title: string }>( + `/api/notes/${parentNoteId}/notes-import`, + { body: {}, file: file("doc.opml", opml) } + ); expect(res.status).toBe(200); - expect(res.body).toMatchObject({ noteId: "enexNote" }); + expect(res.body.title).toBe("OPML Root"); + expect(noteContent(res.body.noteId)).toContain("opml content"); + }); + + it("returns the array result early for an unsupported .opml version", async () => { + // The real importOpml returns a `[400, message]` tuple for an + // unsupported version, which the route returns verbatim (the + // Array.isArray early-return branch). + const opml = ``; + const res = await api.post(`/api/notes/${parentNoteId}/notes-import`, { + body: {}, + file: file("old.opml", opml) + }); + + expect(res.status).toBe(400); + }); + + it("imports an .enex notebook, creating the root note", async () => { + const enex = ` + + + Enex Note +
enex body
]]>
+ 20181121T193703Z +
+
`; + const res = await api.post<{ noteId: string; title: string }>( + `/api/notes/${parentNoteId}/notes-import`, + { body: {}, file: file("book.enex", enex) } + ); + + expect(res.status).toBe(200); + // root note title is the filename without the .enex extension + expect(res.body.title).toBe("book"); + const root = becca.getNote(res.body.noteId)!; + expect(root.getChildNotes().some((c) => c.title === "Enex Note")).toBe(true); }); it("returns the array result early for .enex importers that return an array", async () => { - vi.spyOn(enexImportService, "importEnex").mockResolvedValue([{ b: 2 }] as any); + // importEnex's return type is a single BNote — it can never produce + // an array with a real input, so this Array.isArray early-return + // branch is only reachable via a stub. + vi.spyOn(enexImportService, "importEnex").mockResolvedValue([{ b: 2 }] as never); const res = await api.post(`/api/notes/${parentNoteId}/notes-import`, { body: {}, - file: file("x.enex") + file: file("arr.enex", "") }); expect(res.status).toBe(200); expect(res.body).toEqual([{ b: 2 }]); }); - it("returns 500 when the importer throws", async () => { - vi.spyOn(singleImportService, "importSingleFile").mockImplementation(() => { - throw new Error("boom"); - }); - + it("returns 500 when no note is generated (empty opml body)", async () => { + // A well-formed OPML with an empty body yields no note → the route's + // `if (!note)` 500 branch, all with a real input. + const opml = ``; const res = await api.post(`/api/notes/${parentNoteId}/notes-import`, { body: {}, - file: file("x.txt") - }); - - expect(res.status).toBe(500); - }); - - it("returns 500 when no note is generated", async () => { - vi.spyOn(singleImportService, "importSingleFile").mockReturnValue(null as any); - - const res = await api.post(`/api/notes/${parentNoteId}/notes-import`, { - body: {}, - file: file("x.txt") + file: file("empty.opml", opml) }); expect(res.status).toBe(500); }); it("schedules taskSucceeded when last === 'true'", async () => { + const succeeded = vi.spyOn(TaskContext.prototype, "taskSucceeded"); vi.useFakeTimers(); - vi.spyOn(singleImportService, "importSingleFile").mockReturnValue(fakeNote()); - const succeeded = vi.spyOn(TaskContext.prototype, "taskSucceeded").mockReturnValue(undefined as any); - const res = await api.post(`/api/notes/${parentNoteId}/notes-import`, { - body: { last: "true" }, - file: file("x.txt") - }); + const res = await api.post<{ noteId: string }>( + `/api/notes/${parentNoteId}/notes-import`, + { body: { last: "true" }, file: file("last.txt", "tail note") } + ); expect(res.status).toBe(200); + const importedNoteId = res.body.noteId; vi.runAllTimers(); expect(succeeded).toHaveBeenCalledWith( - expect.objectContaining({ parentNoteId, importedNoteId: "fakeNote123" }) + expect.objectContaining({ parentNoteId, importedNoteId }) ); }); }); @@ -170,39 +209,50 @@ describe("Import API (core)", () => { expect(res.status).toBe(400); }); - it("imports an attachment", async () => { - const spy = vi.spyOn(singleImportService, "importAttachment").mockReturnValue(undefined as any); + it("imports a real attachment onto the note (204)", async () => { + const before = getSql().getValue( + `SELECT COUNT(*) FROM attachments WHERE ownerId = ?`, + [parentNoteId] + ); const res = await api.post(`/api/notes/${parentNoteId}/attachments-import`, { body: {}, - file: file("x.txt") + file: file("attach.txt", "attachment payload") }); expect(res.status).toBe(204); - expect(spy).toHaveBeenCalled(); + const after = getSql().getValue( + `SELECT COUNT(*) FROM attachments WHERE ownerId = ?`, + [parentNoteId] + ); + expect(after).toBe(before + 1); + expect(becca.getNote(parentNoteId)!.getAttachments().some((a) => a.title === "attach.txt")).toBe(true); }); it("returns 500 when the attachment importer throws", async () => { + // The real `importAttachment` writes synchronously and has no input + // that throws synchronously against the in-memory fixture (image + // processing is async, saveAttachment tolerates any content). A + // single spy isolates the route's catch → 500 branch. vi.spyOn(singleImportService, "importAttachment").mockImplementation(() => { throw new Error("boom"); }); const res = await api.post(`/api/notes/${parentNoteId}/attachments-import`, { body: {}, - file: file("x.txt") + file: file("x.bin", "payload", "application/octet-stream") }); expect(res.status).toBe(500); }); it("schedules taskSucceeded when last === 'true'", async () => { + const succeeded = vi.spyOn(TaskContext.prototype, "taskSucceeded"); vi.useFakeTimers(); - vi.spyOn(singleImportService, "importAttachment").mockReturnValue(undefined as any); - const succeeded = vi.spyOn(TaskContext.prototype, "taskSucceeded").mockReturnValue(undefined as any); const res = await api.post(`/api/notes/${parentNoteId}/attachments-import`, { body: { last: "true" }, - file: file("x.txt") + file: file("tail.txt", "tail attachment") }); expect(res.status).toBe(204); @@ -210,8 +260,4 @@ describe("Import API (core)", () => { expect(succeeded).toHaveBeenCalledWith(expect.objectContaining({ parentNoteId })); }); }); - - it("becca lookup of the parent is exercised via a real fixture note", () => { - expect(becca.getNote(parentNoteId)).toBeTruthy(); - }); });