import { createRequire } from "node:module"; import path from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; const require = createRequire( path.resolve(__dirname, "../../../packages/image-engine/src/index.ts"), ); const sharp = require("sharp") as typeof import("sharp").default; const exifReader = require( path.resolve(__dirname, "../../../packages/image-engine/node_modules/exif-reader"), ) as typeof import("exif-reader").default; import { editMetadata } from "@snapotter/image-engine"; import { fixtures, readFixture } from "../../fixtures/index.js"; let jpgWithExif: Buffer; let png200x150: Buffer; beforeAll(() => { jpgWithExif = readFixture(fixtures.image.exifGps); png200x150 = readFixture(fixtures.image.base.png200); }); async function getExif(img: sharp.Sharp) { const buf = await img.toBuffer(); const meta = await sharp(buf).metadata(); if (!meta.exif) return null; return exifReader(meta.exif); } describe("editMetadata", () => { // -- No-op cases ----------------------------------------------------------- it("returns image unchanged when no edits or removals are specified", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img, {}); const buf = await result.toBuffer(); expect(buf.length).toBeGreaterThan(0); }); it("returns image unchanged with empty options object", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img); const buf = await result.toBuffer(); expect(buf.length).toBeGreaterThan(0); }); // -- Writing fields -------------------------------------------------------- it("writes artist field to EXIF", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img, { artist: "New Artist" }); const exif = await getExif(result); expect(exif?.Image?.Artist).toBe("New Artist"); }); it("writes copyright field to EXIF", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img, { copyright: "2026 Test" }); const exif = await getExif(result); expect(exif?.Image?.Copyright).toBe("2026 Test"); }); it("writes imageDescription to EXIF", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img, { imageDescription: "A test image" }); const exif = await getExif(result); expect(exif?.Image?.ImageDescription).toBe("A test image"); }); it("writes software field to EXIF", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img, { software: "SnapOtter v1" }); const exif = await getExif(result); expect(exif?.Image?.Software).toBe("SnapOtter v1"); }); it("writes dateTime field to IFD0", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img, { dateTime: "2026:01:15 10:30:00" }); const exif = await getExif(result); expect(exif?.Image?.DateTime).toBeDefined(); }); it("writes dateTimeOriginal to IFD2", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img, { dateTimeOriginal: "2025:06:01 12:00:00" }); const exif = await getExif(result); expect(exif?.Photo?.DateTimeOriginal).toBeDefined(); }); it("writes multiple fields at once", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img, { artist: "Multi Writer", copyright: "2026 Multi", software: "TestApp", }); const exif = await getExif(result); expect(exif?.Image?.Artist).toBe("Multi Writer"); expect(exif?.Image?.Copyright).toBe("2026 Multi"); expect(exif?.Image?.Software).toBe("TestApp"); }); // -- Ignoring empty strings ------------------------------------------------ it("ignores empty string values for fields", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img, { artist: "", copyright: "" }); // No edits + no removals = keepMetadata path const buf = await result.toBuffer(); expect(buf.length).toBeGreaterThan(0); }); // -- Removing fields ------------------------------------------------------- it("removes specified fields from EXIF", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img, { fieldsToRemove: ["Artist"] }); const exif = await getExif(result); // Artist should be removed expect(exif?.Image?.Artist).toBeUndefined(); }); it("filters out unsafe round-trip keys from fieldsToRemove", async () => { const img = sharp(jpgWithExif); // MakerNote is in the UNSAFE_ROUND_TRIP_KEYS set const result = await editMetadata(img, { fieldsToRemove: ["MakerNote"] }); // Should not throw, and image should be returned const buf = await result.toBuffer(); expect(buf.length).toBeGreaterThan(0); }); it("does not remove a field that is also being written", async () => { const img = sharp(jpgWithExif); // Write Artist and also try to remove it - write takes precedence const result = await editMetadata(img, { artist: "Keep Me", fieldsToRemove: ["Artist"], }); const exif = await getExif(result); expect(exif?.Image?.Artist).toBe("Keep Me"); }); // -- clearGps -------------------------------------------------------------- it("clearGps removes GPS data", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img, { clearGps: true }); const buf = await result.toBuffer(); expect(buf.length).toBeGreaterThan(0); }); it("removes a Photo (IFD2) field via fieldsToRemove", async () => { const img = sharp(jpgWithExif); // DateTimeOriginal is in the Photo (IFD2) section const result = await editMetadata(img, { fieldsToRemove: ["DateTimeOriginal"], }); const exif = await getExif(result); expect(exif?.Photo?.DateTimeOriginal).toBeUndefined(); // Other IFD0 fields should be preserved expect(exif?.Image?.Artist).toBe("Test Artist"); }); // -- Combined edit + remove ------------------------------------------------ it("handles both edits and removals together", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img, { artist: "New Creator", fieldsToRemove: ["Copyright"], }); const exif = await getExif(result); expect(exif?.Image?.Artist).toBe("New Creator"); expect(exif?.Image?.Copyright).toBeUndefined(); }); // -- Write-only path (withExifMerge) --------------------------------------- it("uses merge path when only writing fields (no removals)", async () => { const img = sharp(jpgWithExif); const result = await editMetadata(img, { artist: "Merge Writer" }); const exif = await getExif(result); expect(exif?.Image?.Artist).toBe("Merge Writer"); // Copyright should still exist from original EXIF expect(exif?.Image?.Copyright).toBeDefined(); }); // -- Image without EXIF --------------------------------------------------- it("writes EXIF to image that had no EXIF before", async () => { const img = sharp(png200x150); const result = await editMetadata(img, { artist: "PNG Artist" }); // Convert to JPEG first since PNG doesn't support EXIF natively const jpgBuf = await result.jpeg().toBuffer(); const meta = await sharp(jpgBuf).metadata(); if (meta.exif) { const exif = exifReader(meta.exif); expect(exif?.Image?.Artist).toBe("PNG Artist"); } }); it("handles removal on image without existing EXIF gracefully", async () => { const img = sharp(png200x150); const result = await editMetadata(img, { fieldsToRemove: ["Artist"], artist: "FallbackWriter", }); const buf = await result.toBuffer(); expect(buf.length).toBeGreaterThan(0); }); it("survives corrupt EXIF during removal (catch branch)", async () => { // Corrupt the byte order marker in the EXIF so exif-reader throws, // but sharp still sees the EXIF segment as present. const rawBuf = Buffer.from(jpgWithExif); // Find the 'Exif' header in the raw JPEG let exifStart = -1; for (let i = 0; i < rawBuf.length - 4; i++) { if ( rawBuf[i] === 0x45 && rawBuf[i + 1] === 0x78 && rawBuf[i + 2] === 0x69 && rawBuf[i + 3] === 0x66 ) { exifStart = i; break; } } expect(exifStart).toBeGreaterThanOrEqual(0); // Corrupt the byte order marker (offset +6 after 'Exif\0\0') const corruptBuf = Buffer.from(rawBuf); corruptBuf[exifStart + 6] = 0xde; corruptBuf[exifStart + 7] = 0xad; const img = sharp(corruptBuf); // fieldsToRemove triggers the removal path which tries to parse EXIF const result = await editMetadata(img, { fieldsToRemove: ["Software"], artist: "Survivor", }); const buf = await result.toBuffer(); expect(buf.length).toBeGreaterThan(0); }); });