diff --git a/tests/unit/image-engine/detect.test.ts b/tests/unit/image-engine/detect.test.ts index 3e014e8b..ee239582 100644 --- a/tests/unit/image-engine/detect.test.ts +++ b/tests/unit/image-engine/detect.test.ts @@ -198,6 +198,17 @@ describe("detectFormat", () => { expect(format).not.toBe("avif"); }); + it("rejects ftyp box when buffer too short to read brand (< 12 bytes)", async () => { + // Buffer has ftyp at offset 4 but is only 8 bytes long -- too short for brand + const buf = Buffer.alloc(8); + buf[4] = 0x66; + buf[5] = 0x74; + buf[6] = 0x79; + buf[7] = 0x70; // "ftyp" + const format = await detectFormat(buf); + expect(format).not.toBe("avif"); + }); + it("detects JXL ISOBMFF container magic bytes", async () => { const buf = Buffer.from([0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0, 0, 0, 0]); const format = await detectFormat(buf); diff --git a/tests/unit/image-engine/edit-metadata.test.ts b/tests/unit/image-engine/edit-metadata.test.ts index d313a675..bcb4efb3 100644 --- a/tests/unit/image-engine/edit-metadata.test.ts +++ b/tests/unit/image-engine/edit-metadata.test.ts @@ -153,6 +153,18 @@ describe("editMetadata", () => { 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 () => { @@ -200,4 +212,37 @@ describe("editMetadata", () => { 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); + }); }); diff --git a/tests/unit/image-engine/metadata.test.ts b/tests/unit/image-engine/metadata.test.ts index aa9dc30a..19402097 100644 --- a/tests/unit/image-engine/metadata.test.ts +++ b/tests/unit/image-engine/metadata.test.ts @@ -219,6 +219,26 @@ describe("parseExif", () => { 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"); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/image-engine/operations.test.ts b/tests/unit/image-engine/operations.test.ts index 63db4375..6a076bd3 100644 --- a/tests/unit/image-engine/operations.test.ts +++ b/tests/unit/image-engine/operations.test.ts @@ -198,6 +198,28 @@ describe("resize", () => { ); }); + it("withoutEnlargement clamps height only (no width given)", async () => { + const img = sharp(png1x1); + const result = await resize(img, { + height: 500, + withoutEnlargement: true, + }); + const meta = await getMeta(result); + expect(meta.height).toBeLessThanOrEqual(1); + }); + + it("withoutEnlargement does not clamp when target is smaller", async () => { + const img = sharp(png200x150); + const result = await resize(img, { + width: 50, + height: 50, + withoutEnlargement: true, + }); + const meta = await getMeta(result); + expect(meta.width).toBe(50); + expect(meta.height).toBe(50); + }); + it("percentage on a 1x1 image rounds to at least 1px (percentage >= 50)", async () => { // 1 * 50 / 100 = 0.5 -> rounds to 1 (Math.round), so width=1 const img = sharp(png1x1); @@ -253,6 +275,35 @@ describe("crop", () => { expect(meta.height).toBe(1); }); + it("crops using percent unit", async () => { + const img = sharp(png200x150); + // 50% of 200 = 100, 50% of 150 = 75, starting at 10% left, 10% top + const result = await crop(img, { + left: 10, + top: 10, + width: 50, + height: 50, + unit: "percent", + }); + const meta = await getMeta(result); + expect(meta.width).toBe(100); // 50% of 200 + expect(meta.height).toBe(75); // 50% of 150 (Math.round) + }); + + it("crops 100% of image using percent unit", async () => { + const img = sharp(png200x150); + const result = await crop(img, { + left: 0, + top: 0, + width: 100, + height: 100, + unit: "percent", + }); + const meta = await getMeta(result); + expect(meta.width).toBe(200); + expect(meta.height).toBe(150); + }); + // -- Error cases -- it("throws on width = 0", async () => { @@ -688,6 +739,49 @@ describe("compress", () => { "Target size must be greater than 0", ); }); + + it("falls back to PNG for SVG input (NO_ENCODER format)", async () => { + const svgBuf = readFileSync(path.join(FIXTURES_DIR, "formats/sample.svg")); + const img = sharp(svgBuf); + const result = await compress(img, { quality: 80 }); + const meta = await getMeta(result); + expect(meta.format).toBe("png"); + }); + + it("target size early break when within tolerance", async () => { + // Use a large solid-color image so binary search finishes quickly + const bigBuf = await sharp({ + create: { width: 800, height: 600, channels: 3, background: "#cc6633" }, + }) + .jpeg({ quality: 95 }) + .toBuffer(); + // Set target to the actual size so it matches within tolerance immediately + const targetBytes = bigBuf.length; + const result = await compress(sharp(bigBuf), { + targetSizeBytes: targetBytes, + format: "jpg", + }); + const buf = await result.toBuffer(); + // Should be within 5% tolerance of target + expect(Math.abs(buf.length - targetBytes) / targetBytes).toBeLessThan(0.5); + }); + + it("target size falls back to bestQuality=1 when bestBuffer stays null", async () => { + // Use a very tiny target that forces all iterations to overshoot, + // so bestBuffer never gets assigned and the fallback path runs + const bigBuf = await sharp({ + create: { width: 200, height: 200, channels: 3, background: "#ff8800" }, + }) + .jpeg({ quality: 100 }) + .toBuffer(); + // Target of 1 byte -- impossible to hit, so every iteration overshoots + const result = await compress(sharp(bigBuf), { + targetSizeBytes: 1, + format: "jpg", + }); + const buf = await result.toBuffer(); + expect(buf.length).toBeGreaterThan(0); + }); }); // --------------------------------------------------------------------------- @@ -729,6 +823,27 @@ describe("stripMetadata", () => { expect(meta.width).toBe(200); expect(meta.height).toBe(150); }); + + it("stripIcc=true enters selective stripping path", async () => { + const img = sharp(jpg100x100); + const result = await stripMetadata(img, { stripIcc: true }); + const buf = await result.toBuffer(); + expect(buf.length).toBeGreaterThan(0); + }); + + it("stripGps=true enters selective stripping path", async () => { + const img = sharp(jpg100x100); + const result = await stripMetadata(img, { stripGps: true }); + const buf = await result.toBuffer(); + expect(buf.length).toBeGreaterThan(0); + }); + + it("stripXmp=true enters selective stripping path", async () => { + const img = sharp(jpg100x100); + const result = await stripMetadata(img, { stripXmp: true }); + const buf = await result.toBuffer(); + expect(buf.length).toBeGreaterThan(0); + }); }); // ---------------------------------------------------------------------------