Files
SnapOtter/tests/unit/image-engine/metadata.test.ts
T
SnapOtter b1afcfdf3c test: migrate 207 test files to typed fixture registry (phase 5)
Codemod rewrites all five ad-hoc fixture-path styles to import from the
typed registry (tests/fixtures/index.ts):
- join(__dirname, "..", "fixtures", ...) inline patterns (100 files)
- const FIXTURES = join(__dirname, ...) + join(FIXTURES, ...) (92 files)
- process.cwd()-based paths (10 files)
- path.resolve(__dirname, ...) patterns (5 files)

Registry extended with 8 new keys (portraitJpg, portraitHeic,
motorcycle, svgLogo, barcodeAvif, qrAvif, crossFormatChat,
multipageTiff) and directory accessors (fixtureDir, fixtureRoot).

Dynamic directory scanners (format-matrix-generated,
format-matrix-multimodal, hostile-inputs) preserved via fixtureDir
imports. No fixtures swapped, bytes identical.

Parity gate: dropped 0, net-new 388.
2026-06-20 04:20:17 +08:00

412 lines
13 KiB
TypeScript

import { createRequire } from "node:module";
import path from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
// sharp is only installed in the image-engine package, so resolve it from there
const require = createRequire(
path.resolve(__dirname, "../../../packages/image-engine/src/index.ts"),
);
const sharp = require("sharp") as typeof import("sharp").default;
import {
getImageInfo,
parseExif,
parseGps,
parseXmp,
sanitizeValue,
} from "@snapotter/image-engine";
import { fixtures, readFixture } from "../../fixtures/index.js";
let png200x150: Buffer;
let jpg100x100: Buffer;
let webp50x50: Buffer;
let jpgWithExif: Buffer;
beforeAll(() => {
png200x150 = readFixture(fixtures.image.base.png200);
jpg100x100 = readFixture(fixtures.image.base.jpg100);
webp50x50 = readFixture(fixtures.image.base.webp50);
jpgWithExif = readFixture(fixtures.image.exifGps);
});
// ---------------------------------------------------------------------------
// getImageInfo
// ---------------------------------------------------------------------------
describe("getImageInfo", () => {
it("returns correct dimensions for PNG", async () => {
const info = await getImageInfo(png200x150);
expect(info.width).toBe(200);
expect(info.height).toBe(150);
});
it("returns correct format for PNG", async () => {
const info = await getImageInfo(png200x150);
expect(info.format).toBe("png");
});
it("returns correct format for JPEG", async () => {
const info = await getImageInfo(jpg100x100);
expect(info.format).toBe("jpeg");
});
it("returns correct dimensions for JPEG", async () => {
const info = await getImageInfo(jpg100x100);
expect(info.width).toBe(100);
expect(info.height).toBe(100);
});
it("returns correct format for WebP", async () => {
const info = await getImageInfo(webp50x50);
expect(info.format).toBe("webp");
});
it("returns correct dimensions for WebP", async () => {
const info = await getImageInfo(webp50x50);
expect(info.width).toBe(50);
expect(info.height).toBe(50);
});
it("returns correct size matching buffer length", async () => {
const info = await getImageInfo(png200x150);
expect(info.size).toBe(png200x150.length);
});
it("returns channel count", async () => {
const info = await getImageInfo(png200x150);
expect(info.channels).toBeGreaterThanOrEqual(3);
});
it("returns hasAlpha boolean", async () => {
const info = await getImageInfo(png200x150);
expect(typeof info.hasAlpha).toBe("boolean");
});
it("returns metadata sub-object with expected keys", async () => {
const info = await getImageInfo(png200x150);
expect(info.metadata).toBeDefined();
expect("space" in info.metadata).toBe(true);
expect("density" in info.metadata).toBe(true);
expect("exif" in info.metadata).toBe(true);
expect("icc" in info.metadata).toBe(true);
expect("xmp" in info.metadata).toBe(true);
});
it("detects EXIF presence in JPEG with EXIF", async () => {
const info = await getImageInfo(jpgWithExif);
expect(info.metadata.exif).toBe(true);
});
it("reports no EXIF for plain PNG", async () => {
const info = await getImageInfo(png200x150);
expect(info.metadata.exif).toBe(false);
});
it("throws for invalid buffer", async () => {
await expect(getImageInfo(Buffer.from("not an image"))).rejects.toThrow();
});
it("returns info for a dynamically created image", async () => {
const buf = await sharp({
create: { width: 30, height: 20, channels: 4, background: "#ff000080" },
})
.png()
.toBuffer();
const info = await getImageInfo(buf);
expect(info.width).toBe(30);
expect(info.height).toBe(20);
expect(info.channels).toBe(4);
expect(info.hasAlpha).toBe(true);
expect(info.format).toBe("png");
});
});
// ---------------------------------------------------------------------------
// sanitizeValue — these tests complement the ones in operations.test.ts
// ---------------------------------------------------------------------------
describe("sanitizeValue", () => {
it("converts Date to ISO string", () => {
const d = new Date("2025-06-15T12:00:00Z");
expect(sanitizeValue(d)).toBe("2025-06-15T12:00:00.000Z");
});
it("converts small Buffer to number array", () => {
const buf = Buffer.from([10, 20, 30]);
expect(sanitizeValue(buf)).toEqual([10, 20, 30]);
});
it("converts large Buffer (>256 bytes) to placeholder", () => {
const buf = Buffer.alloc(512, 0xab);
expect(sanitizeValue(buf)).toBe("<binary 512 bytes>");
});
it("handles Buffer at exactly 256 bytes boundary", () => {
const buf = Buffer.alloc(256, 0xcd);
// 256 is not > 256, so it should convert to array
expect(Array.isArray(sanitizeValue(buf))).toBe(true);
});
it("handles Buffer at 257 bytes (just over boundary)", () => {
const buf = Buffer.alloc(257, 0xef);
expect(sanitizeValue(buf)).toBe("<binary 257 bytes>");
});
it("recursively sanitizes arrays", () => {
const d = new Date("2025-01-01T00:00:00Z");
expect(sanitizeValue([d, 42, "text"])).toEqual(["2025-01-01T00:00:00.000Z", 42, "text"]);
});
it("recursively sanitizes nested objects", () => {
const result = sanitizeValue({
a: new Date("2025-03-01T00:00:00Z"),
b: { c: Buffer.from([1, 2]) },
});
expect(result).toEqual({
a: "2025-03-01T00:00:00.000Z",
b: { c: [1, 2] },
});
});
it("passes through null", () => {
expect(sanitizeValue(null)).toBe(null);
});
it("passes through undefined", () => {
expect(sanitizeValue(undefined)).toBe(undefined);
});
it("passes through booleans", () => {
expect(sanitizeValue(true)).toBe(true);
expect(sanitizeValue(false)).toBe(false);
});
it("passes through numbers", () => {
expect(sanitizeValue(0)).toBe(0);
expect(sanitizeValue(3.14)).toBe(3.14);
expect(sanitizeValue(-99)).toBe(-99);
});
});
// ---------------------------------------------------------------------------
// parseExif
// ---------------------------------------------------------------------------
describe("parseExif", () => {
it("parses EXIF from test fixture with metadata fields", async () => {
const metadata = await sharp(jpgWithExif).metadata();
expect(metadata.exif).toBeTruthy();
const result = parseExif(metadata.exif!);
expect(result.image).toBeDefined();
expect(result.photo).toBeDefined();
expect(result.iop).toBeDefined();
expect(result.gps).toBeDefined();
// Test fixture has known fields
expect(result.image.Artist).toBe("Test Artist");
expect(result.image.Copyright).toBe("2026 Test Copyright");
});
it("returns empty sections for empty buffer", () => {
const result = parseExif(Buffer.alloc(0));
expect(result.image).toEqual({});
expect(result.photo).toEqual({});
expect(result.iop).toEqual({});
expect(result.gps).toEqual({});
});
it("returns empty sections for null-like buffer", () => {
// Passing a minimal buffer that won't parse as valid EXIF
const result = parseExif(Buffer.from([0x00, 0x00]));
expect(result.image).toEqual({});
expect(result.photo).toEqual({});
});
it("parses GPSInfo section when GPS data is present", async () => {
// Create a JPEG with GPS EXIF data via withExif IFD3
const buf = await sharp({
create: { width: 10, height: 10, channels: 3, background: "#808080" },
})
.withExif({
IFD0: { Artist: "GPS Test" },
IFD3: { GPSLatitudeRef: "N" },
})
.jpeg()
.toBuffer();
const metadata = await sharp(buf).metadata();
expect(metadata.exif).toBeTruthy();
const result = parseExif(metadata.exif!);
// The GPSInfo section should be populated with sanitized values
expect(typeof result.gps).toBe("object");
expect(Object.keys(result.gps).length).toBeGreaterThan(0);
expect(result.gps.GPSLatitudeRef).toBe("N");
});
it("populates Iop section when exif-reader returns Iop data", async () => {
// Create a JPEG that includes the InteropOffset (0xA005) tag in its
// EXIF sub-IFD. Sharp >= 0.33 writes IFD2 as the Interoperability IFD.
// If Sharp's version doesn't support writing IFD2, we embed the tag
// manually by round-tripping through a buffer with a modified EXIF.
// First, create a JPEG with full EXIF including IFD1 (thumbnail)
// which often triggers exif-reader to parse more sections.
const base = await sharp({
create: { width: 100, height: 100, channels: 3, background: "#808080" },
})
.withExif({
IFD0: { Artist: "Iop Test", Software: "TestSuite" },
IFD1: { Compression: "6" },
})
.jpeg()
.toBuffer();
const metadata = await sharp(base).metadata();
expect(metadata.exif).toBeTruthy();
const result = parseExif(metadata.exif!);
// The Iop section should always be initialized as an object
expect(typeof result.iop).toBe("object");
// Even if it is empty, the Image section should be populated
expect(result.image.Artist).toBe("Iop Test");
});
});
// ---------------------------------------------------------------------------
// parseGps
// ---------------------------------------------------------------------------
describe("parseGps", () => {
it("parses north-east coordinates", () => {
const result = parseGps({
GPSLatitude: [40, 44, 54],
GPSLatitudeRef: "N",
GPSLongitude: [73, 59, 8.4],
GPSLongitudeRef: "W",
});
expect(result.latitude).toBeCloseTo(40.7483, 3);
expect(result.longitude).toBeCloseTo(-73.9857, 3);
expect(result.altitude).toBeNull();
});
it("parses southern hemisphere with altitude", () => {
const result = parseGps({
GPSLatitude: [33, 51, 54],
GPSLatitudeRef: "S",
GPSLongitude: [151, 12, 36],
GPSLongitudeRef: "E",
GPSAltitude: 25,
GPSAltitudeRef: 0,
});
expect(result.latitude).toBeCloseTo(-33.865, 2);
expect(result.longitude).toBeCloseTo(151.21, 2);
expect(result.altitude).toBe(25);
});
it("returns negative altitude for below sea level", () => {
const result = parseGps({
GPSLatitude: [31, 30, 0],
GPSLatitudeRef: "N",
GPSLongitude: [35, 28, 0],
GPSLongitudeRef: "E",
GPSAltitude: 400,
GPSAltitudeRef: 1, // below sea level
});
expect(result.altitude).toBe(-400);
});
it("returns nulls for empty GPS data", () => {
const result = parseGps({});
expect(result.latitude).toBeNull();
expect(result.longitude).toBeNull();
expect(result.altitude).toBeNull();
});
it("handles missing longitude", () => {
const result = parseGps({
GPSLatitude: [51, 30, 0],
GPSLatitudeRef: "N",
});
expect(result.latitude).toBeCloseTo(51.5, 1);
expect(result.longitude).toBeNull();
});
it("handles missing latitude", () => {
const result = parseGps({
GPSLongitude: [0, 7, 39.6],
GPSLongitudeRef: "W",
});
expect(result.latitude).toBeNull();
expect(result.longitude).toBeCloseTo(-0.1277, 3);
});
it("ignores invalid (NaN) GPS values", () => {
const result = parseGps({
GPSLatitude: [NaN, 30, 0],
GPSLatitudeRef: "N",
GPSLongitude: [0, NaN, 39.6],
GPSLongitudeRef: "W",
});
expect(result.latitude).toBeNull();
expect(result.longitude).toBeNull();
});
it("ignores wrong-length GPS arrays", () => {
const result = parseGps({
GPSLatitude: [51, 30],
GPSLatitudeRef: "N",
});
expect(result.latitude).toBeNull();
});
});
// ---------------------------------------------------------------------------
// parseXmp
// ---------------------------------------------------------------------------
describe("parseXmp", () => {
it("extracts key-value pairs from XMP XML", () => {
const xml = Buffer.from(
'<x:xmpmeta xmlns:x="adobe:ns:meta/">' +
'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' +
'<rdf:Description dc:creator="Bob" dc:title="Sunset" />' +
"</rdf:RDF></x:xmpmeta>",
);
const result = parseXmp(xml);
expect(result["dc:creator"]).toBe("Bob");
expect(result["dc:title"]).toBe("Sunset");
});
it("filters out xmlns: namespace declarations", () => {
const xml = Buffer.from(
'<x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
'<rdf:Description dc:format="image/png" />' +
"</x:xmpmeta>",
);
const result = parseXmp(xml);
expect(result["xmlns:x"]).toBeUndefined();
expect(result["xmlns:dc"]).toBeUndefined();
expect(result["dc:format"]).toBe("image/png");
});
it("filters out rdf: prefixed attributes", () => {
const xml = Buffer.from('<rdf:Description rdf:about="" dc:subject="test" />');
const result = parseXmp(xml);
expect(result["rdf:about"]).toBeUndefined();
expect(result["dc:subject"]).toBe("test");
});
it("returns empty object for empty buffer", () => {
expect(parseXmp(Buffer.alloc(0))).toEqual({});
});
it("returns empty object for non-XML content", () => {
expect(parseXmp(Buffer.from("hello world"))).toEqual({});
});
it("extracts multiple namespaced attributes", () => {
const xml = Buffer.from(
"<rdf:Description " +
'xmp:CreateDate="2025-01-01" ' +
'xmp:ModifyDate="2025-06-01" ' +
'photoshop:ColorMode="3" />',
);
const result = parseXmp(xml);
expect(result["xmp:CreateDate"]).toBe("2025-01-01");
expect(result["xmp:ModifyDate"]).toBe("2025-06-01");
expect(result["photoshop:ColorMode"]).toBe("3");
});
});