test(core): reduce use of mocks for some route tests

This commit is contained in:
Elian Doran 2026-05-31 10:29:49 +03:00
parent 8dbdc47089
commit 7390417d94
No known key found for this signature in database
3 changed files with 338 additions and 265 deletions

View File

@ -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<string>(`/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<string>(`/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"');
});
});
});

View File

@ -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<string, unknown>) {
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<string> {
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(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`);
});
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: () => "<svg><rect/></svg>" })
});
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("<svg");
});
it("falls back to the legacy svg key in the note content", () => {
const response = new MockResponse();
const img = fakeImage({
getJsonContentSafely: () => ({ svg: "<svg id='legacy'></svg>" })
});
renderSvgAttachment(img as any, response as any, "mermaid-export.svg");
expect(response.body).toContain("legacy");
expect(response.body).toBe(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`);
});
});
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: () => "<svg><script>alert(1)</script></svg>"
}) as any
);
const res = await api.get<string>("/api/images/someNote/file.svg");
it("sanitizes a real SVG image note", async () => {
const noteId = await createImageNote({
mime: "image/svg+xml",
content: "<svg><script>alert(1)</script><rect/></svg>"
});
const res = await api.get<string>(`/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("<svg");
});
it("renders a canvas note as an SVG attachment", async () => {
vi.spyOn(becca, "getNote").mockReturnValue(
fakeImage({
type: "canvas",
getAttachmentByTitle: () => ({ getContent: () => "<svg id='canvas'></svg>" })
}) as any
);
const res = await api.get<string>("/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: `<svg id='${type}'><script>alert(9)</script></svg>` } ]
});
const res = await api.get<string>(`/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("<svg");
expect(res.body).not.toContain("alert(9)");
});
it("renders a mermaid note as an SVG attachment", async () => {
vi.spyOn(becca, "getNote").mockReturnValue(fakeImage({ type: "mermaid" }) as any);
const res = await api.get<string>("/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: "<svg id='legacy'></svg>" })
});
const res = await api.get<string>(`/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<string>("/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<string>(`/api/images/${noteId}/file.svg`);
expect(res.status).toBe(200);
expect(res.headers["Content-Type"]).toBe("image/svg+xml");
expect(res.body).toBe(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`);
});
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<string, unknown>) {
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<string> {
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<string>(`/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: () => "<svg><script>alert(2)</script></svg>"
}) as any
);
const res = await api.get<string>("/api/attachments/att1/image/file.svg");
it("sanitizes a real SVG attachment image", async () => {
const attachmentId = await createAttachment({
mime: "image/svg+xml",
content: "<svg><script>alert(2)</script></svg>"
});
const res = await api.get<string>(`/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<string, string>;
constructor() {
this.headers = {};
}
headers: Record<string, string> = {};
set(name: string, value: string) {
this.headers[name] = value;

View File

@ -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: "<p>parent body</p>"
}));
});
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 <title>", async () => {
const res = await api.post<{ noteId: string; title: string }>(
`/api/notes/${parentNoteId}/notes-import`,
{
body: {},
file: file("page.html", "<title>My Heading</title><p>body text</p>", "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<Buffer>(
`/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 = `<?xml version="1.0"?>
<opml version="2.0"><body>
<outline text="OPML Root" _note="&lt;p&gt;opml content&lt;/p&gt;"/>
</body></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 = `<?xml version="1.0"?><opml version="9.9"><body></body></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 = `<?xml version="1.0" encoding="UTF-8"?>
<en-export>
<note>
<title>Enex Note</title>
<content><![CDATA[<en-note><div>enex body</div></en-note>]]></content>
<created>20181121T193703Z</created>
</note>
</en-export>`;
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", "<en-export></en-export>")
});
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 = `<?xml version="1.0"?><opml version="2.0"><body></body></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<number>(
`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<number>(
`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();
});
});