Files
SnapOtter/tests/unit/api/exiftool.test.ts
T
SnapOtter 649ad5db9e test: massive test coverage expansion (+1,437 tests, 22 new files)
Expand test coverage across all layers via 14 parallel agents:

Unit tests (3,378 total, +534):
- First-ever AI sidecar tests (157 tests covering bridge lifecycle, all 12 tool modules)
- API route infrastructure (auth, pipeline, batch, settings, teams, roles, audit, api-keys, files, docs)
- Lib coverage improvements (audit 7%->95%, worker-pool 33%->100%)
- Web store/lib gap fills (features-store, tool-registry)

Integration tests (4,403 total, +903):
- Expanded 19 tool test files with parameter variations, format edge cases, boundary values
- Cross-format matrix: 290 tests covering 14 tools x 17 formats
- Adversarial/edge cases: 63 tests for extreme inputs, concurrent requests, corrupted files

E2E-Docker (125 new tests):
- Expanded 8 spec files + 1 new file covering all 49 tools
- Added HEIC/format handling, auth failures, download verification

GUI E2E (expanded 28 spec files):
- Navigation, responsive layout, keyboard shortcuts
- All 51 tool UIs with settings, processing, display modes
- Batch/pipeline workflows, settings/RBAC, visual regression
- Resilience, accessibility (ARIA, contrast, focus), performance budgets
2026-05-09 09:02:29 +08:00

365 lines
14 KiB
TypeScript

import { readFileSync } from "node:fs";
import { join } from "node:path";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import type { EditMetadataSettings } from "../../../apps/api/src/lib/exiftool.js";
import {
buildTagArgs,
inspectMetadata,
writeMetadata,
} from "../../../apps/api/src/lib/exiftool.js";
const FIXTURES = join(import.meta.dirname, "../../fixtures");
describe("buildTagArgs", () => {
it("returns empty array for empty settings", () => {
expect(buildTagArgs({})).toEqual([]);
});
it("uses artist field when provided", () => {
const args = buildTagArgs({ artist: "Jane Doe" });
expect(args).toContain("-Artist=Jane Doe");
});
it("uses author as fallback when artist is not provided", () => {
const args = buildTagArgs({ author: "John Smith" });
expect(args).toContain("-Artist=John Smith");
});
it("artist takes precedence over author", () => {
const args = buildTagArgs({ artist: "Jane Doe", author: "John Smith" });
expect(args).toContain("-Artist=Jane Doe");
expect(args).not.toContain("-Artist=John Smith");
});
it("sets copyright field", () => {
const args = buildTagArgs({ copyright: "2024 Acme Inc" });
expect(args).toContain("-Copyright=2024 Acme Inc");
});
it("uses imageDescription when provided", () => {
const args = buildTagArgs({ imageDescription: "A sunset photo" });
expect(args).toContain("-ImageDescription=A sunset photo");
});
it("uses title as fallback for imageDescription", () => {
const args = buildTagArgs({ title: "Sunset" });
expect(args).toContain("-ImageDescription=Sunset");
expect(args).toContain("-XMP:Title=Sunset");
});
it("imageDescription takes precedence over title for ImageDescription tag", () => {
const args = buildTagArgs({ imageDescription: "A sunset", title: "Sunset Title" });
expect(args).toContain("-ImageDescription=A sunset");
expect(args).toContain("-XMP:Title=Sunset Title");
});
it("sets software field", () => {
const args = buildTagArgs({ software: "SnapOtter v1.0" });
expect(args).toContain("-Software=SnapOtter v1.0");
});
it("sets dateTime as ModifyDate", () => {
const args = buildTagArgs({ dateTime: "2024:01:15 10:30:00" });
expect(args).toContain("-ModifyDate=2024:01:15 10:30:00");
});
it("sets dateTimeOriginal", () => {
const args = buildTagArgs({ dateTimeOriginal: "2024:01:15 08:00:00" });
expect(args).toContain("-DateTimeOriginal=2024:01:15 08:00:00");
});
it("applies positive dateShift", () => {
const args = buildTagArgs({ dateShift: "+5" });
expect(args).toContain("-AllDates+=0:0:0 5:0");
});
it("applies negative dateShift", () => {
const args = buildTagArgs({ dateShift: "-3" });
expect(args).toContain("-AllDates-=0:0:0 3:0");
});
it("applies dateShift without explicit sign as positive", () => {
const args = buildTagArgs({ dateShift: "2" });
expect(args).toContain("-AllDates+=0:0:0 2:0");
});
it("sets all dates to a specific value", () => {
const args = buildTagArgs({ setAllDates: "2024:06:01 12:00:00" });
expect(args).toContain("-AllDates=2024:06:01 12:00:00");
});
it("sets positive GPS coordinates", () => {
const args = buildTagArgs({ gpsLatitude: 40.7128, gpsLongitude: -74.006 });
expect(args).toContain("-GPSLatitude=40.7128");
expect(args).toContain("-GPSLatitudeRef=N");
expect(args).toContain("-GPSLongitude=74.006");
expect(args).toContain("-GPSLongitudeRef=W");
});
it("sets negative GPS latitude", () => {
const args = buildTagArgs({ gpsLatitude: -33.8688, gpsLongitude: 151.2093 });
expect(args).toContain("-GPSLatitude=33.8688");
expect(args).toContain("-GPSLatitudeRef=S");
expect(args).toContain("-GPSLongitude=151.2093");
expect(args).toContain("-GPSLongitudeRef=E");
});
it("includes GPS altitude when provided", () => {
const args = buildTagArgs({ gpsLatitude: 10, gpsLongitude: 20, gpsAltitude: 500 });
expect(args).toContain("-GPSAltitude=500");
expect(args).toContain("-GPSAltitudeRef=Above Sea Level");
});
it("handles negative GPS altitude (below sea level)", () => {
const args = buildTagArgs({ gpsLatitude: 10, gpsLongitude: 20, gpsAltitude: -50 });
expect(args).toContain("-GPSAltitude=50");
expect(args).toContain("-GPSAltitudeRef=Below Sea Level");
});
it("does not set GPS when only latitude is provided", () => {
const args = buildTagArgs({ gpsLatitude: 40 });
const gpsArgs = args.filter((a) => a.startsWith("-GPS"));
expect(gpsArgs).toEqual([]);
});
it("does not set GPS when only longitude is provided", () => {
const args = buildTagArgs({ gpsLongitude: -74 });
const gpsArgs = args.filter((a) => a.startsWith("-GPS"));
expect(gpsArgs).toEqual([]);
});
it("clears GPS data with clearGps flag", () => {
const args = buildTagArgs({ clearGps: true });
expect(args).toContain("-gps:all=");
});
it("clearGps takes priority over GPS coordinates", () => {
const args = buildTagArgs({ clearGps: true, gpsLatitude: 10, gpsLongitude: 20 });
expect(args).toContain("-gps:all=");
expect(args).not.toContain("-GPSLatitude=10");
});
it("adds keywords in add mode", () => {
const args = buildTagArgs({ keywords: ["nature", "sunset"], keywordsMode: "add" });
expect(args).toContain("-IPTC:Keywords+=nature");
expect(args).toContain("-XMP:Subject+=nature");
expect(args).toContain("-IPTC:Keywords+=sunset");
expect(args).toContain("-XMP:Subject+=sunset");
expect(args).not.toContain("-IPTC:Keywords=");
expect(args).not.toContain("-XMP:Subject=");
});
it("clears existing keywords before setting in set mode", () => {
const args = buildTagArgs({ keywords: ["travel"], keywordsMode: "set" });
expect(args).toContain("-IPTC:Keywords=");
expect(args).toContain("-XMP:Subject=");
expect(args).toContain("-IPTC:Keywords+=travel");
expect(args).toContain("-XMP:Subject+=travel");
});
it("filters out blank and whitespace-only keywords", () => {
const args = buildTagArgs({ keywords: ["valid", "", " ", "also-valid"], keywordsMode: "add" });
expect(args).toContain("-IPTC:Keywords+=valid");
expect(args).toContain("-IPTC:Keywords+=also-valid");
const kwArgs = args.filter((a) => a.startsWith("-IPTC:Keywords+="));
expect(kwArgs).toHaveLength(2);
});
it("does not add keywords args when keywords array is empty", () => {
const args = buildTagArgs({ keywords: [], keywordsMode: "add" });
const kwArgs = args.filter((a) => a.includes("Keywords") || a.includes("Subject"));
expect(kwArgs).toEqual([]);
});
it("sets IPTC title (ObjectName)", () => {
const args = buildTagArgs({ iptcTitle: "My Photo" });
expect(args).toContain("-IPTC:ObjectName=My Photo");
});
it("sets IPTC headline", () => {
const args = buildTagArgs({ iptcHeadline: "Breaking News" });
expect(args).toContain("-IPTC:Headline=Breaking News");
});
it("sets IPTC city", () => {
const args = buildTagArgs({ iptcCity: "New York" });
expect(args).toContain("-IPTC:City=New York");
});
it("sets IPTC state", () => {
const args = buildTagArgs({ iptcState: "California" });
expect(args).toContain("-IPTC:Province-State=California");
});
it("sets IPTC country", () => {
const args = buildTagArgs({ iptcCountry: "United States" });
expect(args).toContain("-IPTC:Country-PrimaryLocationName=United States");
});
it("removes safe field names", () => {
const args = buildTagArgs({ fieldsToRemove: ["Artist", "Copyright"] });
expect(args).toContain("-Artist=");
expect(args).toContain("-Copyright=");
});
it("filters unsafe field names from removal", () => {
const args = buildTagArgs({ fieldsToRemove: ["Artist", "rm -rf /", "../../etc"] });
expect(args).toContain("-Artist=");
expect(args).not.toContain("-rm -rf /=");
expect(args).not.toContain("-../../etc=");
});
it("allows field names with colons and hyphens", () => {
const args = buildTagArgs({
fieldsToRemove: ["IPTC:Keywords", "XMP:Subject", "Province-State"],
});
expect(args).toContain("-IPTC:Keywords=");
expect(args).toContain("-XMP:Subject=");
expect(args).toContain("-Province-State=");
});
it("allows field names with underscores", () => {
const args = buildTagArgs({ fieldsToRemove: ["Custom_Field"] });
expect(args).toContain("-Custom_Field=");
});
it("does nothing for empty fieldsToRemove array", () => {
const args = buildTagArgs({ fieldsToRemove: [] });
expect(args).toEqual([]);
});
it("handles complex settings with multiple fields combined", () => {
const settings: EditMetadataSettings = {
artist: "Jane Doe",
copyright: "2024 Acme",
title: "Landscape",
software: "SnapOtter",
dateTimeOriginal: "2024:03:15 14:00:00",
gpsLatitude: 48.8566,
gpsLongitude: 2.3522,
gpsAltitude: 35,
keywords: ["paris", "travel"],
keywordsMode: "set",
iptcCity: "Paris",
iptcCountry: "France",
fieldsToRemove: ["Rating"],
};
const args = buildTagArgs(settings);
expect(args).toContain("-Artist=Jane Doe");
expect(args).toContain("-Copyright=2024 Acme");
expect(args).toContain("-ImageDescription=Landscape");
expect(args).toContain("-XMP:Title=Landscape");
expect(args).toContain("-Software=SnapOtter");
expect(args).toContain("-DateTimeOriginal=2024:03:15 14:00:00");
expect(args).toContain("-GPSLatitude=48.8566");
expect(args).toContain("-GPSLatitudeRef=N");
expect(args).toContain("-GPSLongitude=2.3522");
expect(args).toContain("-GPSLongitudeRef=E");
expect(args).toContain("-GPSAltitude=35");
expect(args).toContain("-GPSAltitudeRef=Above Sea Level");
expect(args).toContain("-IPTC:Keywords=");
expect(args).toContain("-XMP:Subject=");
expect(args).toContain("-IPTC:Keywords+=paris");
expect(args).toContain("-XMP:Subject+=travel");
expect(args).toContain("-IPTC:City=Paris");
expect(args).toContain("-IPTC:Country-PrimaryLocationName=France");
expect(args).toContain("-Rating=");
});
it("trims keyword whitespace", () => {
const args = buildTagArgs({ keywords: [" nature ", " sunset "], keywordsMode: "add" });
expect(args).toContain("-IPTC:Keywords+=nature");
expect(args).toContain("-XMP:Subject+=sunset");
});
it("handles zero GPS coordinates", () => {
const args = buildTagArgs({ gpsLatitude: 0, gpsLongitude: 0 });
expect(args).toContain("-GPSLatitude=0");
expect(args).toContain("-GPSLatitudeRef=N");
expect(args).toContain("-GPSLongitude=0");
expect(args).toContain("-GPSLongitudeRef=E");
});
it("handles zero GPS altitude", () => {
const args = buildTagArgs({ gpsLatitude: 10, gpsLongitude: 20, gpsAltitude: 0 });
expect(args).toContain("-GPSAltitude=0");
expect(args).toContain("-GPSAltitudeRef=Above Sea Level");
});
});
describe("inspectMetadata", () => {
it("returns correct filename and fileSize for JPEG with EXIF", async () => {
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
const result = await inspectMetadata(buf, "test-with-exif.jpg");
expect(result.filename).toBe("test-with-exif.jpg");
expect(result.fileSize).toBe(buf.length);
});
it("returns non-null exif object for JPEG with EXIF data", async () => {
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
const result = await inspectMetadata(buf, "test-with-exif.jpg");
expect(result.exif).not.toBeNull();
expect(typeof result.exif).toBe("object");
});
it("returns keywords array (may be empty)", async () => {
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
const result = await inspectMetadata(buf, "test-with-exif.jpg");
expect(Array.isArray(result.keywords)).toBe(true);
});
it("returns null for GPS when no GPS data present", async () => {
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
const result = await inspectMetadata(buf, "test-with-exif.jpg");
expect(result.gps).toBeNull();
});
it("works with PNG which has no EXIF -- returns null for exif, iptc, xmp, gps", async () => {
const buf = readFileSync(join(FIXTURES, "test-1x1.png"));
const result = await inspectMetadata(buf, "test-1x1.png");
expect(result.filename).toBe("test-1x1.png");
expect(result.fileSize).toBe(buf.length);
expect(result.exif).toBeNull();
expect(result.iptc).toBeNull();
expect(result.xmp).toBeNull();
expect(result.gps).toBeNull();
});
});
describe("writeMetadata", () => {
it("empty tags array returns buffer unchanged", async () => {
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
const result = await writeMetadata(buf, "test-with-exif.jpg", []);
expect(Buffer.compare(result, buf)).toBe(0);
});
it("writing -Artist=TestArtist then inspecting shows the artist", async () => {
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
const written = await writeMetadata(buf, "test-with-exif.jpg", ["-Artist=TestArtist"]);
const result = await inspectMetadata(written, "test-with-exif.jpg");
expect(result.exif).not.toBeNull();
expect(result.exif?.Artist).toBe("TestArtist");
});
it("writing multiple tags works", async () => {
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
const written = await writeMetadata(buf, "test-with-exif.jpg", [
"-Artist=MultiTest",
"-Copyright=2024 Test Corp",
]);
const result = await inspectMetadata(written, "test-with-exif.jpg");
expect(result.exif?.Artist).toBe("MultiTest");
expect(result.exif?.Copyright).toBe("2024 Test Corp");
});
it("returns a valid image buffer that Sharp can read", async () => {
const buf = readFileSync(join(FIXTURES, "test-with-exif.jpg"));
const written = await writeMetadata(buf, "test-with-exif.jpg", ["-Software=SnapOtter"]);
const meta = await sharp(written).metadata();
expect(meta.format).toBe("jpeg");
expect(meta.width).toBeGreaterThan(0);
expect(meta.height).toBeGreaterThan(0);
});
});