mirror of
https://github.com/zadam/trilium.git
synced 2026-09-12 19:50:20 +05:00
test(core): reduce use of mocks for some route tests
This commit is contained in:
parent
8dbdc47089
commit
7390417d94
@ -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"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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="<p>opml content</p>"/>
|
||||
</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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user