test: expand unit test coverage (+437 tests, 17 new files)

Add comprehensive unit tests for previously uncovered API lib modules,
web stores, and plugin functions. Fix 2 pre-existing editor-store test
failures (invertSelection mask values).

Coverage: 30.3% -> 36.85% stmts (unit), 57.71% stmts (integration).
This commit is contained in:
SnapOtter
2026-05-09 07:35:27 +08:00
parent 96b055093b
commit 4f0fbade6d
21 changed files with 4622 additions and 31 deletions
+309
View File
@@ -0,0 +1,309 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const config = vi.hoisted(() => ({
ANALYTICS_ENABLED: false,
POSTHOG_API_KEY: "",
POSTHOG_HOST: "",
SENTRY_DSN: "",
ANALYTICS_SAMPLE_RATE: 1.0,
}));
const dbGetResult = vi.hoisted(() => ({ value: null as unknown }));
const mockAuthUser = vi.hoisted(() => ({
value: null as { id: string; analyticsEnabled?: boolean } | null,
}));
const mockCapture = vi.hoisted(() => vi.fn());
const mockShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const MockPostHog = vi.hoisted(() =>
vi.fn().mockImplementation(() => ({
capture: mockCapture,
shutdown: mockShutdown,
})),
);
const mockSentryCapture = vi.hoisted(() => vi.fn());
const mockSentryClose = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockSentryInit = vi.hoisted(() => vi.fn());
vi.mock("../../../apps/api/src/config.js", () => ({ env: config }));
vi.mock("../../../apps/api/src/db/index.js", () => ({
db: {
select: () => ({
from: () => ({
where: () => ({
get: () => dbGetResult.value,
}),
}),
}),
},
schema: {
settings: { key: "key" },
users: { id: "id", analyticsEnabled: "analyticsEnabled" },
},
}));
vi.mock("../../../apps/api/src/plugins/auth.js", () => ({
getAuthUser: () => mockAuthUser.value,
}));
vi.mock("drizzle-orm", () => ({
eq: () => "mocked-eq",
}));
vi.mock("../../../apps/api/node_modules/posthog-node", () => ({
PostHog: MockPostHog,
}));
vi.mock("../../../apps/api/node_modules/@sentry/node", () => ({
init: mockSentryInit,
captureException: mockSentryCapture,
close: mockSentryClose,
}));
type AnalyticsModule = typeof import("../../../apps/api/src/lib/analytics.js");
let mod: AnalyticsModule;
beforeEach(async () => {
config.ANALYTICS_ENABLED = false;
config.POSTHOG_API_KEY = "";
config.POSTHOG_HOST = "";
config.SENTRY_DSN = "";
config.ANALYTICS_SAMPLE_RATE = 1.0;
dbGetResult.value = null;
mockAuthUser.value = null;
mockCapture.mockClear();
mockShutdown.mockClear();
MockPostHog.mockClear();
mockSentryCapture.mockClear();
mockSentryClose.mockClear();
mockSentryInit.mockClear();
vi.resetModules();
mod = await import("../../../apps/api/src/lib/analytics.js");
});
describe("initAnalytics", () => {
it("does nothing when ANALYTICS_ENABLED is false", async () => {
config.ANALYTICS_ENABLED = false;
await expect(mod.initAnalytics()).resolves.toBeUndefined();
expect(MockPostHog).not.toHaveBeenCalled();
});
it("does nothing when ANALYTICS_ENABLED is true but POSTHOG_API_KEY is empty", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "";
await expect(mod.initAnalytics()).resolves.toBeUndefined();
expect(MockPostHog).not.toHaveBeenCalled();
});
it("initializes posthog when enabled with API key", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
config.POSTHOG_HOST = "https://test.posthog.com";
await mod.initAnalytics();
expect(MockPostHog).toHaveBeenCalledWith("phc_test_key", {
host: "https://test.posthog.com",
flushAt: 20,
flushInterval: 30000,
});
});
it("initializes sentry when SENTRY_DSN is provided", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
config.SENTRY_DSN = "https://test@sentry.io/123";
await mod.initAnalytics();
expect(mockSentryInit).toHaveBeenCalledWith(
expect.objectContaining({
dsn: "https://test@sentry.io/123",
sendDefaultPii: false,
}),
);
});
});
describe("captureException", () => {
it("does nothing when sentryModule is null", () => {
expect(() => mod.captureException(new Error("test"))).not.toThrow();
});
it("does nothing when request user is not opted in", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
config.SENTRY_DSN = "https://test@sentry.io/123";
await mod.initAnalytics();
mockAuthUser.value = null;
const fakeRequest = { headers: {} } as Parameters<typeof mod.captureException>[1];
mod.captureException(new Error("test"), fakeRequest);
expect(mockSentryCapture).not.toHaveBeenCalled();
});
it("captures when sentry is initialized and no request provided", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
config.SENTRY_DSN = "https://test@sentry.io/123";
await mod.initAnalytics();
const err = new Error("test error");
mod.captureException(err);
expect(mockSentryCapture).toHaveBeenCalledWith(err);
});
it("captures when sentry is initialized and request user is opted in", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
config.SENTRY_DSN = "https://test@sentry.io/123";
await mod.initAnalytics();
mockAuthUser.value = { id: "user-1", analyticsEnabled: true };
dbGetResult.value = { analyticsEnabled: true };
const fakeRequest = { headers: {} } as Parameters<typeof mod.captureException>[1];
const err = new Error("opted in error");
mod.captureException(err, fakeRequest);
expect(mockSentryCapture).toHaveBeenCalledWith(err);
});
});
describe("shutdownAnalytics", () => {
it("resolves without error when no clients initialized", async () => {
await expect(mod.shutdownAnalytics()).resolves.toBeUndefined();
});
it("shuts down posthog when initialized", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
await mod.initAnalytics();
await mod.shutdownAnalytics();
expect(mockShutdown).toHaveBeenCalled();
});
it("closes sentry when initialized", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
config.SENTRY_DSN = "https://test@sentry.io/123";
await mod.initAnalytics();
await mod.shutdownAnalytics();
expect(mockSentryClose).toHaveBeenCalledWith(2000);
});
});
describe("trackEvent", () => {
it("does nothing when posthogClient is null", () => {
const fakeRequest = {} as Parameters<typeof mod.trackEvent>[0];
expect(() => mod.trackEvent(fakeRequest, "test_event", { key: "value" })).not.toThrow();
});
it("does nothing when ANALYTICS_ENABLED is false", () => {
config.ANALYTICS_ENABLED = false;
const fakeRequest = {} as Parameters<typeof mod.trackEvent>[0];
expect(() => mod.trackEvent(fakeRequest, "test_event", { key: "value" })).not.toThrow();
});
it("does nothing when request user is not opted in", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
await mod.initAnalytics();
mockAuthUser.value = null;
const fakeRequest = { headers: {} } as Parameters<typeof mod.trackEvent>[0];
mod.trackEvent(fakeRequest, "test_event", { key: "value" });
expect(mockCapture).not.toHaveBeenCalled();
});
it("does nothing when ANALYTICS_SAMPLE_RATE is 0", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
config.ANALYTICS_SAMPLE_RATE = 0;
await mod.initAnalytics();
mockAuthUser.value = { id: "user-1", analyticsEnabled: true };
dbGetResult.value = { analyticsEnabled: true };
const fakeRequest = { headers: {} } as Parameters<typeof mod.trackEvent>[0];
mod.trackEvent(fakeRequest, "test_event", { key: "value" });
expect(mockCapture).not.toHaveBeenCalled();
});
it("captures event when all conditions are met", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
config.ANALYTICS_SAMPLE_RATE = 1.0;
await mod.initAnalytics();
mockAuthUser.value = { id: "user-1", analyticsEnabled: true };
dbGetResult.value = { analyticsEnabled: true };
const fakeRequest = { headers: {} } as Parameters<typeof mod.trackEvent>[0];
mod.trackEvent(fakeRequest, "tool_used", { tool: "resize" });
expect(mockCapture).toHaveBeenCalledWith({
distinctId: "unknown",
event: "tool_used",
properties: { tool: "resize" },
});
});
it("uses instance ID from DB when available", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
config.ANALYTICS_SAMPLE_RATE = 1.0;
await mod.initAnalytics();
dbGetResult.value = { value: "inst-abc-123", analyticsEnabled: true };
mockAuthUser.value = { id: "user-1", analyticsEnabled: true };
const fakeRequest = { headers: {} } as Parameters<typeof mod.trackEvent>[0];
mod.trackEvent(fakeRequest, "tool_used", { tool: "crop" });
expect(mockCapture).toHaveBeenCalledWith(
expect.objectContaining({
distinctId: "inst-abc-123",
}),
);
});
it("allows anonymous users with x-analytics-consent header", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
config.ANALYTICS_SAMPLE_RATE = 1.0;
await mod.initAnalytics();
mockAuthUser.value = { id: "anonymous" };
const fakeRequest = {
headers: { "x-analytics-consent": "true" },
} as unknown as Parameters<typeof mod.trackEvent>[0];
mod.trackEvent(fakeRequest, "test_event", { key: "value" });
expect(mockCapture).toHaveBeenCalled();
});
it("rejects anonymous users without consent header", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
config.ANALYTICS_SAMPLE_RATE = 1.0;
await mod.initAnalytics();
mockAuthUser.value = { id: "anonymous" };
const fakeRequest = {
headers: {},
} as unknown as Parameters<typeof mod.trackEvent>[0];
mod.trackEvent(fakeRequest, "test_event", { key: "value" });
expect(mockCapture).not.toHaveBeenCalled();
});
it("does not throw when capture throws internally", async () => {
config.ANALYTICS_ENABLED = true;
config.POSTHOG_API_KEY = "phc_test_key";
config.ANALYTICS_SAMPLE_RATE = 1.0;
await mod.initAnalytics();
mockCapture.mockImplementationOnce(() => {
throw new Error("capture failed");
});
mockAuthUser.value = { id: "user-1", analyticsEnabled: true };
dbGetResult.value = { analyticsEnabled: true };
const fakeRequest = { headers: {} } as Parameters<typeof mod.trackEvent>[0];
expect(() => mod.trackEvent(fakeRequest, "test_event", { key: "value" })).not.toThrow();
});
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
computeKeyPrefix,
hashPassword,
verifyPassword,
} from "../../../apps/api/src/plugins/auth.js";
describe("hashPassword", () => {
it("returns a string in salt:hash format", async () => {
const result = await hashPassword("TestPass1");
const parts = result.split(":");
expect(parts).toHaveLength(2);
});
it("produces a 64-character hex salt (32 bytes)", async () => {
const result = await hashPassword("TestPass1");
const [salt] = result.split(":");
expect(salt).toHaveLength(64);
expect(salt).toMatch(/^[0-9a-f]{64}$/);
});
it("produces a 128-character hex hash (64 bytes)", async () => {
const result = await hashPassword("TestPass1");
const [, hash] = result.split(":");
expect(hash).toHaveLength(128);
expect(hash).toMatch(/^[0-9a-f]{128}$/);
});
it("generates different salts on each call", async () => {
const a = await hashPassword("TestPass1");
const b = await hashPassword("TestPass1");
const saltA = a.split(":")[0];
const saltB = b.split(":")[0];
expect(saltA).not.toBe(saltB);
});
it("produces a hash that can be verified back", async () => {
const stored = await hashPassword("MySecret9");
const ok = await verifyPassword("MySecret9", stored);
expect(ok).toBe(true);
});
});
describe("verifyPassword", () => {
it("returns true for the correct password", async () => {
const stored = await hashPassword("Correct1");
expect(await verifyPassword("Correct1", stored)).toBe(true);
});
it("returns false for a wrong password", async () => {
const stored = await hashPassword("Correct1");
expect(await verifyPassword("Wrong1abc", stored)).toBe(false);
});
it("returns false for an empty stored string", async () => {
expect(await verifyPassword("anything", "")).toBe(false);
});
it("returns false for a malformed stored string (no colon)", async () => {
expect(await verifyPassword("anything", "nocolonhere")).toBe(false);
});
it("returns false for a truncated hash", async () => {
const stored = await hashPassword("Correct1");
const [salt] = stored.split(":");
const truncated = `${salt}:abcd`;
expect(await verifyPassword("Correct1", truncated)).toBe(false);
});
it("handles unicode passwords", async () => {
const stored = await hashPassword("üéàñö");
expect(await verifyPassword("üéàñö", stored)).toBe(true);
expect(await verifyPassword("plain", stored)).toBe(false);
});
});
describe("computeKeyPrefix", () => {
it("returns a 16-character hex string", () => {
const prefix = computeKeyPrefix("si_some-api-key");
expect(prefix).toHaveLength(16);
expect(prefix).toMatch(/^[0-9a-f]{16}$/);
});
it("is deterministic (same input gives same output)", () => {
const a = computeKeyPrefix("si_test-key-123");
const b = computeKeyPrefix("si_test-key-123");
expect(a).toBe(b);
});
it("produces different prefixes for different inputs", () => {
const a = computeKeyPrefix("si_key-alpha");
const b = computeKeyPrefix("si_key-beta");
expect(a).not.toBe(b);
});
it("works with various key formats", () => {
const inputs = ["si_short", "si_a-very-long-api-key-with-many-segments-1234567890", "si_"];
for (const input of inputs) {
const prefix = computeKeyPrefix(input);
expect(prefix).toHaveLength(16);
expect(prefix).toMatch(/^[0-9a-f]{16}$/);
}
});
});
+105
View File
@@ -0,0 +1,105 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { autoOrient } from "../../../apps/api/src/lib/auto-orient.js";
async function createImageWithOrientation(orientation: number): Promise<Buffer> {
return sharp({
create: {
width: 100,
height: 50,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.withMetadata({ orientation })
.jpeg()
.toBuffer();
}
describe("autoOrient", () => {
it("returns original buffer unchanged when orientation is 1", async () => {
const buf = await createImageWithOrientation(1);
const result = await autoOrient(buf);
const meta = await sharp(result).metadata();
expect(meta.width).toBe(100);
expect(meta.height).toBe(50);
});
it("returns original buffer unchanged when no EXIF orientation is present", async () => {
const buf = await sharp({
create: {
width: 80,
height: 60,
channels: 3,
background: { r: 0, g: 255, b: 0 },
},
})
.png()
.toBuffer();
const result = await autoOrient(buf);
const meta = await sharp(result).metadata();
expect(meta.width).toBe(80);
expect(meta.height).toBe(60);
});
it("rotates image when orientation is 6 (90 CW)", async () => {
const buf = await createImageWithOrientation(6);
const meta = await sharp(buf).metadata();
expect(meta.orientation).toBe(6);
const result = await autoOrient(buf);
const resultMeta = await sharp(result).metadata();
expect(resultMeta.width).toBe(50);
expect(resultMeta.height).toBe(100);
});
it("rotates image when orientation is 8 (270 CW)", async () => {
const buf = await createImageWithOrientation(8);
const meta = await sharp(buf).metadata();
expect(meta.orientation).toBe(8);
const result = await autoOrient(buf);
const resultMeta = await sharp(result).metadata();
expect(resultMeta.width).toBe(50);
expect(resultMeta.height).toBe(100);
});
it("rotates image when orientation is 3 (180)", async () => {
const buf = await createImageWithOrientation(3);
const meta = await sharp(buf).metadata();
expect(meta.orientation).toBe(3);
const result = await autoOrient(buf);
const resultMeta = await sharp(result).metadata();
expect(resultMeta.width).toBe(100);
expect(resultMeta.height).toBe(50);
});
it("handles orientation 2 (horizontal flip)", async () => {
const buf = await createImageWithOrientation(2);
const origMeta = await sharp(buf).metadata();
expect(origMeta.orientation).toBe(2);
const result = await autoOrient(buf);
const meta = await sharp(result).metadata();
expect(meta.width).toBe(100);
expect(meta.height).toBe(50);
});
it("strips orientation tag after rotation", async () => {
const buf = await createImageWithOrientation(6);
const beforeMeta = await sharp(buf).metadata();
expect(beforeMeta.orientation).toBe(6);
const result = await autoOrient(buf);
const meta = await sharp(result).metadata();
expect(meta.orientation === undefined || meta.orientation === 1).toBe(true);
});
it("returns original buffer for corrupted/invalid input", async () => {
const invalid = Buffer.from("this is not an image at all");
const result = await autoOrient(invalid);
expect(result.equals(invalid)).toBe(true);
});
it("returns original buffer for empty buffer", async () => {
const empty = Buffer.alloc(0);
const result = await autoOrient(empty);
expect(result.equals(empty)).toBe(true);
});
});
+450
View File
@@ -0,0 +1,450 @@
import { join } from "node:path";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import {
addDropShadow,
applyEffects,
blurBackground,
compositeOnColor,
compositeOnImage,
createGradientBackground,
} from "../../../apps/api/src/lib/bg-effects.js";
const FIXTURES = join(__dirname, "../../fixtures");
async function createTestImage(
width: number,
height: number,
channels: 4 | 3 = 4,
): Promise<Buffer> {
return sharp({
create: {
width,
height,
channels,
background: { r: 255, g: 0, b: 0, alpha: 1 },
},
})
.png()
.toBuffer();
}
async function createMultiColorImage(width: number, height: number): Promise<Buffer> {
const pixels = Buffer.alloc(width * height * 3);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 3;
pixels[i] = Math.floor((x / width) * 255);
pixels[i + 1] = Math.floor((y / height) * 255);
pixels[i + 2] = 128;
}
}
return sharp(pixels, { raw: { width, height, channels: 3 } })
.png()
.toBuffer();
}
async function createSubjectWithAlpha(width: number, height: number): Promise<Buffer> {
const pixels = Buffer.alloc(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
pixels[i] = 255;
pixels[i + 1] = 0;
pixels[i + 2] = 0;
pixels[i + 3] = x < width / 2 ? 255 : 0;
}
}
return sharp(pixels, { raw: { width, height, channels: 4 } })
.png()
.toBuffer();
}
describe("blurBackground", () => {
it("returns a valid PNG buffer", async () => {
const original = await createTestImage(100, 100, 3);
const subject = await createSubjectWithAlpha(100, 100);
const result = await blurBackground(original, subject, 50);
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
expect(meta.width).toBe(100);
expect(meta.height).toBe(100);
});
it("intensity 0 produces minimal blur (sigma ~1)", async () => {
const original = await createTestImage(100, 100, 3);
const subject = await createSubjectWithAlpha(100, 100);
const result = await blurBackground(original, subject, 0);
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
expect(result.length).toBeGreaterThan(0);
});
it("intensity 100 produces heavy blur (sigma 50)", async () => {
const original = await createTestImage(100, 100, 3);
const subject = await createSubjectWithAlpha(100, 100);
const result = await blurBackground(original, subject, 100);
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
});
it("different intensity values produce different output", async () => {
const original = await createMultiColorImage(100, 100);
const subject = await createSubjectWithAlpha(100, 100);
const low = await blurBackground(original, subject, 10);
const high = await blurBackground(original, subject, 90);
expect(low.equals(high)).toBe(false);
});
it("clamps intensity below 0 to 0", async () => {
const original = await createTestImage(100, 100, 3);
const subject = await createSubjectWithAlpha(100, 100);
const clamped = await blurBackground(original, subject, -10);
const atZero = await blurBackground(original, subject, 0);
expect(clamped.equals(atZero)).toBe(true);
});
it("clamps intensity above 100 to 100", async () => {
const original = await createTestImage(100, 100, 3);
const subject = await createSubjectWithAlpha(100, 100);
const clamped = await blurBackground(original, subject, 200);
const atMax = await blurBackground(original, subject, 100);
expect(clamped.equals(atMax)).toBe(true);
});
it("output dimensions match subject dimensions", async () => {
const original = await createTestImage(200, 150, 3);
const subject = await createSubjectWithAlpha(80, 60);
const result = await blurBackground(original, subject, 50);
const meta = await sharp(result).metadata();
expect(meta.width).toBe(80);
expect(meta.height).toBe(60);
});
});
describe("addDropShadow", () => {
it("returns a valid PNG buffer", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const result = await addDropShadow(subject, 50);
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
});
it("output dimensions match input dimensions", async () => {
const subject = await createSubjectWithAlpha(120, 80);
const result = await addDropShadow(subject, 50);
const meta = await sharp(result).metadata();
expect(meta.width).toBe(120);
expect(meta.height).toBe(80);
});
it("opacity 0 produces no visible shadow (alpha bytes near zero in shadow area)", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const result = await addDropShadow(subject, 0);
const { data, info } = await sharp(result).raw().toBuffer({ resolveWithObject: true });
let shadowAlphaSum = 0;
for (let y = 90; y < info.height; y++) {
for (let x = 60; x < info.width; x++) {
const i = (y * info.width + x) * 4;
shadowAlphaSum += data[i + 3];
}
}
expect(shadowAlphaSum).toBe(0);
});
it("opacity 100 produces visible shadow", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const result = await addDropShadow(subject, 100);
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
expect(result.length).toBeGreaterThan(0);
});
it("higher opacity produces more shadow alpha than lower opacity", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const lowResult = await addDropShadow(subject, 20);
const highResult = await addDropShadow(subject, 80);
expect(lowResult.equals(highResult)).toBe(false);
});
it("throws when buffer has no readable dimensions", async () => {
const invalid = Buffer.from("not an image");
await expect(addDropShadow(invalid, 50)).rejects.toThrow();
});
});
describe("createGradientBackground", () => {
it("creates correct size output", async () => {
const result = await createGradientBackground(200, 100, "#ff0000", "#0000ff");
const meta = await sharp(result).metadata();
expect(meta.width).toBe(200);
expect(meta.height).toBe(100);
});
it("output is a valid PNG buffer", async () => {
const result = await createGradientBackground(100, 100, "#ff0000", "#0000ff");
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
});
it("default angle is 180 (top to bottom)", async () => {
const withDefault = await createGradientBackground(100, 100, "#ff0000", "#0000ff");
const with180 = await createGradientBackground(100, 100, "#ff0000", "#0000ff", 180);
expect(withDefault.equals(with180)).toBe(true);
});
it("different angles produce different pixel data", async () => {
const angle0 = await createGradientBackground(100, 100, "#ff0000", "#0000ff", 0);
const angle90 = await createGradientBackground(100, 100, "#ff0000", "#0000ff", 90);
expect(angle0.equals(angle90)).toBe(false);
});
it("different colors produce different output", async () => {
const grad1 = await createGradientBackground(100, 100, "#ff0000", "#0000ff");
const grad2 = await createGradientBackground(100, 100, "#00ff00", "#ffff00");
expect(grad1.equals(grad2)).toBe(false);
});
});
describe("compositeOnColor", () => {
it("composites subject onto solid color background", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const result = await compositeOnColor(subject, "#0000ff");
const { data } = await sharp(result).raw().toBuffer({ resolveWithObject: true });
const lastPixel = (99 * 100 + 99) * 4;
expect(data[lastPixel + 2]).toBeGreaterThan(200);
});
it("output dimensions match subject dimensions", async () => {
const subject = await createSubjectWithAlpha(150, 80);
const result = await compositeOnColor(subject, "#00ff00");
const meta = await sharp(result).metadata();
expect(meta.width).toBe(150);
expect(meta.height).toBe(80);
});
it("output is a valid PNG", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const result = await compositeOnColor(subject, "#ff0000");
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
});
it("accepts hex color without hash prefix", async () => {
const subject = await createSubjectWithAlpha(50, 50);
const result = await compositeOnColor(subject, "ff0000");
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
});
it("throws on invalid buffer with no dimensions", async () => {
const invalid = Buffer.from("not an image");
await expect(compositeOnColor(invalid, "#ff0000")).rejects.toThrow();
});
});
describe("compositeOnImage", () => {
it("composites subject onto background image", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const background = await createTestImage(200, 200, 3);
const result = await compositeOnImage(subject, background);
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
});
it("background is resized to cover subject dimensions", async () => {
const subject = await createSubjectWithAlpha(80, 60);
const background = await createTestImage(200, 200, 3);
const result = await compositeOnImage(subject, background);
const meta = await sharp(result).metadata();
expect(meta.width).toBe(80);
expect(meta.height).toBe(60);
});
it("output dimensions match subject dimensions", async () => {
const subject = await createSubjectWithAlpha(120, 90);
const background = await createTestImage(50, 50, 3);
const result = await compositeOnImage(subject, background);
const meta = await sharp(result).metadata();
expect(meta.width).toBe(120);
expect(meta.height).toBe(90);
});
it("output is a valid PNG", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const background = await createTestImage(100, 100);
const result = await compositeOnImage(subject, background);
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
});
it("throws on invalid subject buffer", async () => {
const invalid = Buffer.from("not an image");
const background = await createTestImage(100, 100);
await expect(compositeOnImage(invalid, background)).rejects.toThrow();
});
});
describe("applyEffects", () => {
it("transparent backgroundType with no effects returns subject approximately unchanged", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const original = await createTestImage(100, 100, 3);
const result = await applyEffects(subject, original, {
backgroundType: "transparent",
});
expect(result.equals(subject)).toBe(true);
});
it("shadowEnabled with opacity adds shadow", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const original = await createTestImage(100, 100, 3);
const withShadow = await applyEffects(subject, original, {
backgroundType: "transparent",
shadowEnabled: true,
shadowOpacity: 80,
});
expect(withShadow.equals(subject)).toBe(false);
const meta = await sharp(withShadow).metadata();
expect(meta.format).toBe("png");
});
it("blurEnabled with transparent bgType blurs the original background", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const original = await createTestImage(100, 100, 3);
const result = await applyEffects(subject, original, {
backgroundType: "transparent",
blurEnabled: true,
blurIntensity: 50,
});
expect(result.equals(subject)).toBe(false);
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
});
it("blurEnabled with blur bgType blurs the original background", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const original = await createTestImage(100, 100, 3);
const result = await applyEffects(subject, original, {
backgroundType: "blur",
blurEnabled: true,
blurIntensity: 30,
});
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
expect(result.equals(subject)).toBe(false);
});
it("color backgroundType composites on solid color", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const original = await createTestImage(100, 100, 3);
const result = await applyEffects(subject, original, {
backgroundType: "color",
backgroundColor: "#00ff00",
});
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
expect(result.equals(subject)).toBe(false);
});
it("gradient backgroundType composites on gradient", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const original = await createTestImage(100, 100, 3);
const result = await applyEffects(subject, original, {
backgroundType: "gradient",
gradientColor1: "#ff0000",
gradientColor2: "#0000ff",
gradientAngle: 90,
});
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
expect(result.equals(subject)).toBe(false);
});
it("image backgroundType composites on provided image", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const original = await createTestImage(100, 100, 3);
const bgImage = await sharp({
create: {
width: 200,
height: 200,
channels: 3,
background: { r: 0, g: 0, b: 255 },
},
})
.png()
.toBuffer();
const result = await applyEffects(subject, original, {
backgroundType: "image",
backgroundImageBuffer: bgImage,
});
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
expect(meta.width).toBe(100);
expect(meta.height).toBe(100);
});
it("image backgroundType with blurEnabled blurs the background image", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const original = await createTestImage(100, 100, 3);
const bgImage = await createMultiColorImage(200, 200);
const withoutBlur = await applyEffects(subject, original, {
backgroundType: "image",
backgroundImageBuffer: bgImage,
});
const withBlur = await applyEffects(subject, original, {
backgroundType: "image",
backgroundImageBuffer: bgImage,
blurEnabled: true,
blurIntensity: 50,
});
expect(withBlur.equals(withoutBlur)).toBe(false);
});
it("combined shadow + color background works", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const original = await createTestImage(100, 100, 3);
const result = await applyEffects(subject, original, {
backgroundType: "color",
backgroundColor: "#ffffff",
shadowEnabled: true,
shadowOpacity: 60,
});
const meta = await sharp(result).metadata();
expect(meta.format).toBe("png");
expect(meta.width).toBe(100);
expect(meta.height).toBe(100);
});
it("defaults blurIntensity to 50 when not provided", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const original = await createTestImage(100, 100, 3);
const withDefault = await applyEffects(subject, original, {
backgroundType: "blur",
blurEnabled: true,
});
const withExplicit = await applyEffects(subject, original, {
backgroundType: "blur",
blurEnabled: true,
blurIntensity: 50,
});
expect(withDefault.equals(withExplicit)).toBe(true);
});
it("shadowOpacity 0 does not add shadow", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const original = await createTestImage(100, 100, 3);
const result = await applyEffects(subject, original, {
backgroundType: "transparent",
shadowEnabled: true,
shadowOpacity: 0,
});
expect(result.equals(subject)).toBe(true);
});
it("defaults backgroundType to transparent when not provided", async () => {
const subject = await createSubjectWithAlpha(100, 100);
const original = await createTestImage(100, 100, 3);
const result = await applyEffects(subject, original, {});
expect(result.equals(subject)).toBe(true);
});
});
+210 -20
View File
@@ -1,23 +1,22 @@
/**
* Tests for cleanup.ts helper functions:
* getMaxAgeMs and shouldRunStartupCleanup.
*
* These test the DB-backed settings lookup with fallback to env vars.
* Requires migrations to be run first (shared DB from vitest env).
*/
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { env } from "../../../apps/api/src/config.js";
import { db, schema } from "../../../apps/api/src/db/index.js";
import { runMigrations } from "../../../apps/api/src/db/migrate.js";
import { getMaxAgeMs, shouldRunStartupCleanup } from "../../../apps/api/src/lib/cleanup.js";
import {
getMaxAgeMs,
shouldRunStartupCleanup,
startCleanupCron,
} from "../../../apps/api/src/lib/cleanup.js";
// Run migrations once to ensure the settings table exists
beforeAll(() => {
runMigrations();
});
// Helper to insert a setting
function setSetting(key: string, value: string) {
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
if (existing) {
@@ -30,19 +29,21 @@ function setSetting(key: string, value: string) {
}
}
// Helper to remove a setting
function removeSetting(key: string) {
db.delete(schema.settings).where(eq(schema.settings.key, key)).run();
}
async function waitForCleanup(): Promise<void> {
for (let i = 0; i < 50; i++) {
await new Promise((resolve) => setImmediate(resolve));
}
}
afterEach(() => {
removeSetting("tempFileMaxAgeHours");
removeSetting("startupCleanup");
});
// ═══════════════════════════════════════════════════════════════════════════
// getMaxAgeMs
// ═══════════════════════════════════════════════════════════════════════════
describe("getMaxAgeMs", () => {
it("returns DB value when tempFileMaxAgeHours is set", () => {
setSetting("tempFileMaxAgeHours", "48");
@@ -53,7 +54,6 @@ describe("getMaxAgeMs", () => {
it("returns env fallback when no DB setting exists", () => {
removeSetting("tempFileMaxAgeHours");
const result = getMaxAgeMs();
// vitest.config.ts sets FILE_MAX_AGE_HOURS=1
expect(result).toBe(1 * 60 * 60 * 1000);
});
@@ -80,9 +80,6 @@ describe("getMaxAgeMs", () => {
});
});
// ═══════════════════════════════════════════════════════════════════════════
// shouldRunStartupCleanup
// ═══════════════════════════════════════════════════════════════════════════
describe("shouldRunStartupCleanup", () => {
it("returns false when setting is 'false'", () => {
setSetting("startupCleanup", "false");
@@ -107,3 +104,196 @@ describe("shouldRunStartupCleanup", () => {
expect(shouldRunStartupCleanup()).toBe(true);
});
});
describe("startCleanupCron", () => {
let tempDir: string;
let originalWorkspacePath: string;
beforeEach(() => {
tempDir = join(tmpdir(), `cleanup-test-${randomUUID().slice(0, 8)}`);
originalWorkspacePath = env.WORKSPACE_PATH;
env.WORKSPACE_PATH = tempDir;
setSetting("startupCleanup", "false");
});
afterEach(() => {
env.WORKSPACE_PATH = originalWorkspacePath;
removeSetting("startupCleanup");
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
it("returns object with stop() method", () => {
vi.useFakeTimers();
const cron = startCleanupCron();
expect(typeof cron.stop).toBe("function");
cron.stop();
vi.useRealTimers();
});
it("creates workspace directory", () => {
vi.useFakeTimers();
expect(existsSync(tempDir)).toBe(false);
const cron = startCleanupCron();
expect(existsSync(tempDir)).toBe(true);
cron.stop();
vi.useRealTimers();
});
it("stop() clears intervals", () => {
vi.useFakeTimers();
const clearSpy = vi.spyOn(globalThis, "clearInterval");
const cron = startCleanupCron();
cron.stop();
expect(clearSpy).toHaveBeenCalledTimes(2);
clearSpy.mockRestore();
vi.useRealTimers();
});
it("removes old files on startup cleanup", async () => {
mkdirSync(tempDir, { recursive: true });
const oldFile = join(tempDir, "old-file.txt");
writeFileSync(oldFile, "old content");
const pastTime = new Date(Date.now() - 2 * 60 * 60 * 1000);
utimesSync(oldFile, pastTime, pastTime);
setSetting("startupCleanup", "true");
const cron = startCleanupCron();
await waitForCleanup();
expect(existsSync(oldFile)).toBe(false);
cron.stop();
});
it("keeps recent files on startup cleanup", async () => {
mkdirSync(tempDir, { recursive: true });
const recentFile = join(tempDir, "recent-file.txt");
writeFileSync(recentFile, "recent content");
setSetting("startupCleanup", "true");
const cron = startCleanupCron();
await waitForCleanup();
expect(existsSync(recentFile)).toBe(true);
cron.stop();
});
it("removes old subdirectory when its mtime is expired", async () => {
mkdirSync(tempDir, { recursive: true });
const oldDir = join(tempDir, "old-dir");
mkdirSync(oldDir);
const nestedFile = join(oldDir, "nested.txt");
writeFileSync(nestedFile, "nested");
const pastTime = new Date(Date.now() - 2 * 60 * 60 * 1000);
utimesSync(nestedFile, pastTime, pastTime);
utimesSync(oldDir, pastTime, pastTime);
setSetting("startupCleanup", "true");
const cron = startCleanupCron();
await waitForCleanup();
expect(existsSync(oldDir)).toBe(false);
cron.stop();
});
it("skips startup cleanup when startupCleanup is false", async () => {
mkdirSync(tempDir, { recursive: true });
const oldFile = join(tempDir, "skip-old.txt");
writeFileSync(oldFile, "old");
const pastTime = new Date(Date.now() - 2 * 60 * 60 * 1000);
utimesSync(oldFile, pastTime, pastTime);
setSetting("startupCleanup", "false");
const cron = startCleanupCron();
await waitForCleanup();
expect(existsSync(oldFile)).toBe(true);
cron.stop();
});
it("purges expired sessions on startup when enabled", () => {
const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
const userId = `test-user-${randomUUID().slice(0, 8)}`;
const existing = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!existing) {
db.insert(schema.users)
.values({
id: userId,
username: `cleanup-test-${randomUUID().slice(0, 8)}`,
passwordHash: "hash",
role: "user",
team: "Default",
mustChangePassword: false,
})
.run();
}
const sessionId = `sess-${randomUUID().slice(0, 8)}`;
db.insert(schema.sessions)
.values({
id: sessionId,
userId,
expiresAt: pastDate,
})
.run();
setSetting("startupCleanup", "true");
const cron = startCleanupCron();
const session = db
.select()
.from(schema.sessions)
.where(eq(schema.sessions.id, sessionId))
.get();
expect(session).toBeUndefined();
cron.stop();
db.delete(schema.users).where(eq(schema.users.id, userId)).run();
});
it("does not purge non-expired sessions", () => {
const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
const userId = `test-user-${randomUUID().slice(0, 8)}`;
db.insert(schema.users)
.values({
id: userId,
username: `cleanup-keep-${randomUUID().slice(0, 8)}`,
passwordHash: "hash",
role: "user",
team: "Default",
mustChangePassword: false,
})
.run();
const sessionId = `sess-${randomUUID().slice(0, 8)}`;
db.insert(schema.sessions)
.values({
id: sessionId,
userId,
expiresAt: futureDate,
})
.run();
setSetting("startupCleanup", "true");
const cron = startCleanupCron();
const session = db
.select()
.from(schema.sessions)
.where(eq(schema.sessions.id, sessionId))
.get();
expect(session).toBeDefined();
cron.stop();
db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)).run();
db.delete(schema.users).where(eq(schema.users.id, userId)).run();
});
it("handles empty workspace directory gracefully", async () => {
mkdirSync(tempDir, { recursive: true });
setSetting("startupCleanup", "true");
const cron = startCleanupCron();
await waitForCleanup();
expect(existsSync(tempDir)).toBe(true);
cron.stop();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { formatZodErrors } from "../../../apps/api/src/lib/errors.js";
describe("formatZodErrors", () => {
it("formats single issue with path", () => {
const result = formatZodErrors([
{ path: ["width"], message: "Must be positive", code: "custom" },
]);
expect(result).toBe("width: Must be positive");
});
it("formats single issue without path", () => {
const result = formatZodErrors([{ path: [], message: "Invalid input", code: "custom" }]);
expect(result).toBe("Invalid input");
});
it("joins multiple issues with semicolons", () => {
const result = formatZodErrors([
{ path: ["width"], message: "Required", code: "custom" },
{ path: ["height"], message: "Must be positive", code: "custom" },
]);
expect(result).toBe("width: Required; height: Must be positive");
});
it("returns empty string for empty array", () => {
const result = formatZodErrors([]);
expect(result).toBe("");
});
it("joins nested paths with dots", () => {
const result = formatZodErrors([
{ path: ["settings", "quality"], message: "Too high", code: "custom" },
]);
expect(result).toBe("settings.quality: Too high");
});
});
+364
View File
@@ -0,0 +1,364 @@
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);
});
});
+191
View File
@@ -0,0 +1,191 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const config = vi.hoisted(() => ({
FILES_STORAGE_PATH: "",
}));
vi.mock("../../../apps/api/src/config.js", () => ({
env: config,
}));
let testDir: string;
beforeEach(async () => {
testDir = join(tmpdir(), `snapotter-fs-test-${randomUUID().slice(0, 8)}`);
await mkdir(testDir, { recursive: true });
config.FILES_STORAGE_PATH = testDir;
vi.resetModules();
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
async function importModule() {
return await import("../../../apps/api/src/lib/file-storage.js");
}
describe("saveFile", () => {
it("saves buffer and returns UUID-based filename with correct extension", async () => {
const { saveFile } = await importModule();
const buf = Buffer.from("fake png data");
const name = await saveFile(buf, "photo.png");
expect(name).toMatch(/^[0-9a-f-]{36}\.png$/);
const saved = await readFile(join(testDir, name));
expect(saved.toString()).toBe("fake png data");
});
it("sanitizes dangerous extensions to .bin", async () => {
const { saveFile } = await importModule();
const buf = Buffer.from("evil");
const name = await saveFile(buf, "payload.exe");
expect(name).toMatch(/\.bin$/);
});
it("uses .bin for files with no extension", async () => {
const { saveFile } = await importModule();
const buf = Buffer.from("data");
const name = await saveFile(buf, "noext");
expect(name).toMatch(/\.bin$/);
});
it("creates storage directory on first call", async () => {
await rm(testDir, { recursive: true, force: true });
const freshDir = join(tmpdir(), `snapotter-fs-fresh-${randomUUID().slice(0, 8)}`);
config.FILES_STORAGE_PATH = freshDir;
try {
expect(existsSync(freshDir)).toBe(false);
const { saveFile } = await importModule();
await saveFile(Buffer.from("x"), "img.jpg");
expect(existsSync(freshDir)).toBe(true);
} finally {
await rm(freshDir, { recursive: true, force: true });
}
});
it("returns different filenames for same input (UUID-based)", async () => {
const { saveFile } = await importModule();
const buf = Buffer.from("same");
const name1 = await saveFile(buf, "a.png");
const name2 = await saveFile(buf, "a.png");
expect(name1).not.toBe(name2);
});
it("accepts known image extensions", async () => {
const { saveFile } = await importModule();
const extensions = [
".jpg",
".jpeg",
".png",
".webp",
".gif",
".svg",
".heic",
".tiff",
".avif",
];
for (const ext of extensions) {
const name = await saveFile(Buffer.from("x"), `file${ext}`);
expect(name).toMatch(new RegExp(`\\${ext}$`));
}
});
it("lowercases extensions", async () => {
const { saveFile } = await importModule();
const name = await saveFile(Buffer.from("x"), "PHOTO.PNG");
expect(name).toMatch(/\.png$/);
});
});
describe("deleteStoredFile", () => {
it("deletes existing file", async () => {
const { saveFile, deleteStoredFile } = await importModule();
const name = await saveFile(Buffer.from("del"), "del.png");
expect(existsSync(join(testDir, name))).toBe(true);
await deleteStoredFile(name);
expect(existsSync(join(testDir, name))).toBe(false);
});
it("does not throw for non-existent file", async () => {
const { deleteStoredFile } = await importModule();
await expect(deleteStoredFile("nonexistent.png")).resolves.toBeUndefined();
});
});
describe("getStoredFilePath", () => {
it("returns correct joined path", async () => {
const { getStoredFilePath } = await importModule();
const result = getStoredFilePath("abc-123.png");
expect(result).toBe(join(testDir, "abc-123.png"));
});
});
describe("ensureStorageDir", () => {
it("creates directory if not exists", async () => {
await rm(testDir, { recursive: true, force: true });
const freshDir = join(tmpdir(), `snapotter-fs-ensure-${randomUUID().slice(0, 8)}`);
config.FILES_STORAGE_PATH = freshDir;
try {
const { ensureStorageDir } = await importModule();
await ensureStorageDir();
expect(existsSync(freshDir)).toBe(true);
} finally {
await rm(freshDir, { recursive: true, force: true });
}
});
it("is idempotent (second call does not throw)", async () => {
const { ensureStorageDir } = await importModule();
await ensureStorageDir();
await expect(ensureStorageDir()).resolves.toBeUndefined();
});
});
describe("thumbnail functions", () => {
it("saveThumbnail creates .thumbs subdirectory", async () => {
const { saveThumbnail } = await importModule();
await saveThumbnail("test.png", Buffer.from("thumb"));
expect(existsSync(join(testDir, ".thumbs"))).toBe(true);
});
it("saveThumbnail writes to correct path", async () => {
const { saveThumbnail } = await importModule();
await saveThumbnail("test.png", Buffer.from("thumb-data"));
const content = await readFile(join(testDir, ".thumbs", "test.png.thumb.jpg"));
expect(content.toString()).toBe("thumb-data");
});
it("getCachedThumbnail reads back saved thumbnail", async () => {
const { saveThumbnail, getCachedThumbnail } = await importModule();
const data = Buffer.from("my-thumb");
await saveThumbnail("pic.jpg", data);
const result = await getCachedThumbnail("pic.jpg");
expect(result).not.toBeNull();
expect(result?.toString()).toBe("my-thumb");
});
it("getCachedThumbnail returns null for non-existent thumbnail", async () => {
const { getCachedThumbnail } = await importModule();
const result = await getCachedThumbnail("nope.png");
expect(result).toBeNull();
});
it("deleteThumbnail removes the file", async () => {
const { saveThumbnail, deleteThumbnail } = await importModule();
await saveThumbnail("rm.png", Buffer.from("x"));
const thumbFile = join(testDir, ".thumbs", "rm.png.thumb.jpg");
expect(existsSync(thumbFile)).toBe(true);
await deleteThumbnail("rm.png");
expect(existsSync(thumbFile)).toBe(false);
});
it("deleteThumbnail does not throw for non-existent", async () => {
const { deleteThumbnail } = await importModule();
await expect(deleteThumbnail("ghost.png")).resolves.toBeUndefined();
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import { sanitizeFilename } from "../../../apps/api/src/lib/filename.js";
describe("sanitizeFilename", () => {
it("passes through a simple filename", () => {
expect(sanitizeFilename("photo.png")).toBe("photo.png");
});
it("strips directory path and returns basename only", () => {
expect(sanitizeFilename("/usr/local/bin/image.png")).toBe("image.png");
});
it("strips relative directory path", () => {
expect(sanitizeFilename("some/nested/dir/file.jpg")).toBe("file.jpg");
});
it("removes .. sequences", () => {
const result = sanitizeFilename("../../etc/passwd");
expect(result).toBe("passwd");
expect(result).not.toContain("..");
});
it("removes embedded .. sequences in filename", () => {
expect(sanitizeFilename("my..file..name.png")).toBe("myfilename.png");
});
it("removes null bytes", () => {
expect(sanitizeFilename("image\0.png")).toBe("image.png");
});
it("falls back to upload for empty string", () => {
expect(sanitizeFilename("")).toBe("upload");
});
it("falls back to upload for single dot", () => {
expect(sanitizeFilename(".")).toBe("upload");
});
it("falls back to upload for double dot", () => {
expect(sanitizeFilename("..")).toBe("upload");
});
it("falls back to upload for triple dots (.. removal leaves .)", () => {
expect(sanitizeFilename("...")).toBe("upload");
});
it("falls back to upload for four dots (.. removal leaves empty)", () => {
expect(sanitizeFilename("....")).toBe("upload");
});
it("truncates after first safe image extension (photo.png.php)", () => {
expect(sanitizeFilename("photo.png.php")).toBe("photo.png");
});
it("truncates after first safe image extension (report.jpg.exe)", () => {
expect(sanitizeFilename("report.jpg.exe")).toBe("report.jpg");
});
it("truncates after first safe image extension with multiple unsafe parts", () => {
expect(sanitizeFilename("evil.webp.php.sh")).toBe("evil.webp");
});
it("handles no extension", () => {
expect(sanitizeFilename("README")).toBe("README");
});
it("handles unknown extensions without truncation", () => {
expect(sanitizeFilename("archive.tar.gz")).toBe("archive.tar.gz");
});
it("handles filename with only unknown extensions", () => {
expect(sanitizeFilename("data.csv")).toBe("data.csv");
});
it("truncates very long filenames over 200 bytes", () => {
const longName = "a".repeat(300) + ".png";
const result = sanitizeFilename(longName);
expect(new TextEncoder().encode(result).length).toBeLessThanOrEqual(200);
expect(result).toMatch(/\.png$/);
});
it("truncates long filename without extension", () => {
const longName = "b".repeat(300);
const result = sanitizeFilename(longName);
expect(new TextEncoder().encode(result).length).toBeLessThanOrEqual(200);
});
it("handles unicode filenames", () => {
expect(sanitizeFilename("写真.png")).toBe("写真.png");
});
it("handles emoji filenames", () => {
expect(sanitizeFilename("\u{1F600}photo.jpg")).toBe("\u{1F600}photo.jpg");
});
it("handles filenames with spaces", () => {
expect(sanitizeFilename("my photo 2024.jpg")).toBe("my photo 2024.jpg");
});
it("handles filenames with dashes and underscores", () => {
expect(sanitizeFilename("my-photo_v2.webp")).toBe("my-photo_v2.webp");
});
it("preserves dotfiles", () => {
expect(sanitizeFilename(".gitignore")).toBe(".gitignore");
});
it("handles trailing slash in path", () => {
expect(sanitizeFilename("/foo/bar/")).toBe("bar");
});
it("handles only null bytes falling back to upload", () => {
expect(sanitizeFilename("\0\0\0")).toBe("upload");
});
it("truncates long unicode filenames correctly", () => {
const longUnicode = "\u{1F600}".repeat(100) + ".png";
const result = sanitizeFilename(longUnicode);
expect(new TextEncoder().encode(result).length).toBeLessThanOrEqual(200);
expect(result).toMatch(/\.png$/);
});
it("recognizes all safe image extensions", () => {
const extensions = [
"jpg",
"jpeg",
"png",
"webp",
"gif",
"bmp",
"tiff",
"tif",
"avif",
"svg",
"pdf",
];
for (const ext of extensions) {
const result = sanitizeFilename(`file.${ext}.evil`);
expect(result).toBe(`file.${ext}`);
}
});
});
+225
View File
@@ -1,5 +1,29 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { decodeToSharpCompat, needsCliDecode } from "../../../apps/api/src/lib/format-decoders.js";
import { encodeQoi } from "../../../apps/api/src/lib/format-encoders.js";
const FIXTURES = join(__dirname, "../../fixtures");
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47];
function isPng(buf: Buffer): boolean {
return (
buf[0] === PNG_MAGIC[0] &&
buf[1] === PNG_MAGIC[1] &&
buf[2] === PNG_MAGIC[2] &&
buf[3] === PNG_MAGIC[3]
);
}
async function assertValidImage(buf: Buffer): Promise<{ width: number; height: number }> {
const meta = await sharp(buf).metadata();
expect(meta.width).toBeGreaterThan(0);
expect(meta.height).toBeGreaterThan(0);
return { width: meta.width!, height: meta.height! };
}
// ==========================================================================
// needsCliDecode
@@ -126,4 +150,205 @@ describe("decodeToSharpCompat", () => {
const result = await decodeToSharpCompat(buf, "webp");
expect(result).toBe(buf);
});
it("returns buffer unchanged for gif format", async () => {
const buf = Buffer.from("gif data");
const result = await decodeToSharpCompat(buf, "gif");
expect(result).toBe(buf);
});
it("returns buffer unchanged for avif format", async () => {
const buf = Buffer.from("avif data");
const result = await decodeToSharpCompat(buf, "avif");
expect(result).toBe(buf);
});
it("returns buffer unchanged for tiff format", async () => {
const buf = Buffer.from("tiff data");
const result = await decodeToSharpCompat(buf, "tiff");
expect(result).toBe(buf);
});
it("decodes BMP to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.bmp"));
const result = await decodeToSharpCompat(input, "bmp");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
it("decodes ICO to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.ico"));
const result = await decodeToSharpCompat(input, "ico");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
it("decodes TGA to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.tga"));
const result = await decodeToSharpCompat(input, "tga");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
it("decodes PSD to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.psd"));
const result = await decodeToSharpCompat(input, "psd");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
it("decodes EXR to valid PNG (requires EXR delegate)", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.exr"));
try {
const result = await decodeToSharpCompat(input, "exr");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("no decode delegate")) return;
throw e;
}
});
it("decodes HDR to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.hdr"));
const result = await decodeToSharpCompat(input, "hdr");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
it("decodes JXL to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.jxl"));
const result = await decodeToSharpCompat(input, "jxl");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
it("decodes JP2 to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.jp2"));
const result = await decodeToSharpCompat(input, "jp2");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
it("decodes DDS to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.dds"));
const result = await decodeToSharpCompat(input, "dds");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
it("decodes CUR using ICO decoder to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.cur"));
const result = await decodeToSharpCompat(input, "cur");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
it("decodes DPX to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.dpx"));
const result = await decodeToSharpCompat(input, "dpx");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
it("decodes FITS to valid PNG (requires FITS delegate)", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.fits"));
try {
const result = await decodeToSharpCompat(input, "fits");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("no decode delegate") || msg.includes("ENOENT")) return;
throw e;
}
});
it("decodes PPM to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.ppm"));
const result = await decodeToSharpCompat(input, "ppm");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
it("decodes PGM to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.pgm"));
const result = await decodeToSharpCompat(input, "pgm");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
it("decodes PBM to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.pbm"));
const result = await decodeToSharpCompat(input, "pbm");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
});
describe("decodeToSharpCompat - individual decoder verification", () => {
const formats = ["bmp", "ico", "tga", "psd", "hdr", "jxl", "jp2", "dds", "dpx"] as const;
for (const fmt of formats) {
it(`${fmt}: output has non-zero dimensions`, async () => {
const input = await readFile(join(FIXTURES, `formats/sample.${fmt}`));
const result = await decodeToSharpCompat(input, fmt);
const { width, height } = await assertValidImage(result);
expect(width).toBeGreaterThan(0);
expect(height).toBeGreaterThan(0);
});
}
const delegateFormats = ["exr", "fits"] as const;
for (const fmt of delegateFormats) {
it(`${fmt}: output has non-zero dimensions (requires delegate)`, async () => {
const input = await readFile(join(FIXTURES, `formats/sample.${fmt}`));
try {
const result = await decodeToSharpCompat(input, fmt);
const { width, height } = await assertValidImage(result);
expect(width).toBeGreaterThan(0);
expect(height).toBeGreaterThan(0);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes("no decode delegate") || msg.includes("ENOENT")) return;
throw e;
}
});
}
});
describe("decodeToSharpCompat - QOI decoder", () => {
it("decodes QOI fixture to valid PNG", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.qoi"));
const result = await decodeToSharpCompat(input, "qoi");
expect(isPng(result)).toBe(true);
await assertValidImage(result);
});
it("round-trips PNG -> QOI -> PNG", async () => {
const png = await readFile(join(FIXTURES, "formats/sample.png"));
const qoi = await encodeQoi(png);
const decoded = await decodeToSharpCompat(Buffer.from(qoi), "qoi");
expect(isPng(decoded)).toBe(true);
const { width, height } = await assertValidImage(decoded);
const originalMeta = await sharp(png).metadata();
expect(width).toBe(originalMeta.width);
expect(height).toBe(originalMeta.height);
});
it("decoded QOI produces a buffer sharp can process further", async () => {
const input = await readFile(join(FIXTURES, "formats/sample.qoi"));
const decoded = await decodeToSharpCompat(input, "qoi");
const resized = await sharp(decoded).resize(10, 10).png().toBuffer();
expect(resized.length).toBeGreaterThan(0);
});
});
describe("decodeToSharpCompat - EPS size limit", () => {
it("rejects EPS files over 50MB", async () => {
const largeBuffer = Buffer.alloc(51 * 1024 * 1024);
await expect(decodeToSharpCompat(largeBuffer, "eps")).rejects.toThrow(/EPS file too large/);
});
});
+139
View File
@@ -0,0 +1,139 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import {
encodeBmp,
encodeIco,
encodeJp2,
encodeQoi,
} from "../../../apps/api/src/lib/format-encoders.js";
const FIXTURES = join(__dirname, "../../fixtures");
async function createTestPng(width = 50, height = 50): Promise<Buffer> {
return sharp({
create: {
width,
height,
channels: 4,
background: { r: 128, g: 64, b: 200, alpha: 1 },
},
})
.png()
.toBuffer();
}
describe("encodeQoi", () => {
it("encodes a PNG buffer to QOI format", async () => {
const input = await createTestPng();
const result = await encodeQoi(input);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toBe(0x71);
expect(result[1]).toBe(0x6f);
expect(result[2]).toBe(0x69);
expect(result[3]).toBe(0x66);
});
it("encodes JPEG input to QOI", async () => {
const jpeg = await sharp({
create: {
width: 30,
height: 30,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.jpeg()
.toBuffer();
const result = await encodeQoi(jpeg);
expect(result.length).toBeGreaterThan(0);
expect(Buffer.from(result.subarray(0, 4)).toString("ascii")).toBe("qoif");
});
it("encodes fixture image to QOI", async () => {
const input = await readFile(join(FIXTURES, "test-200x150.png"));
const result = await encodeQoi(input);
expect(result.length).toBeGreaterThan(100);
});
});
describe("encodeBmp", () => {
it("encodes a PNG buffer to BMP format", async () => {
const input = await createTestPng();
const result = await encodeBmp(input);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toBe(0x42);
expect(result[1]).toBe(0x4d);
});
it("encodes JPEG input to BMP", async () => {
const jpeg = await sharp({
create: {
width: 20,
height: 20,
channels: 3,
background: { r: 0, g: 255, b: 0 },
},
})
.jpeg()
.toBuffer();
const result = await encodeBmp(jpeg);
expect(result[0]).toBe(0x42);
expect(result[1]).toBe(0x4d);
});
it("produces a BMP with correct dimensions", async () => {
const input = await createTestPng(80, 60);
const result = await encodeBmp(input);
const width = result.readUInt32LE(18);
const height = result.readUInt32LE(22);
expect(width).toBe(80);
expect(height).toBe(60);
});
});
describe("encodeIco", () => {
it("encodes a PNG buffer to ICO format", async () => {
const input = await createTestPng(64, 64);
const result = await encodeIco(input);
expect(result.length).toBeGreaterThan(0);
expect(result.readUInt16LE(0)).toBe(0);
expect(result.readUInt16LE(2)).toBe(1);
});
it("resizes large images to fit within 256x256", async () => {
const input = await createTestPng(400, 400);
const result = await encodeIco(input);
expect(result.length).toBeGreaterThan(0);
expect(result.readUInt16LE(2)).toBe(1);
});
it("preserves small images without enlargement", async () => {
const input = await createTestPng(16, 16);
const result = await encodeIco(input);
expect(result.length).toBeGreaterThan(0);
});
});
describe("encodeJp2", () => {
it("encodes a PNG buffer to JP2 format", async () => {
const input = await createTestPng();
const result = await encodeJp2(input);
expect(result.length).toBeGreaterThan(0);
});
it("accepts optional quality parameter", async () => {
const input = await createTestPng();
const result = await encodeJp2(input, 50);
expect(result.length).toBeGreaterThan(0);
});
it("produces valid output at different quality levels", async () => {
const input = await readFile(join(FIXTURES, "test-200x150.png"));
const low = await encodeJp2(input, 10);
const high = await encodeJp2(input, 90);
expect(low.length).toBeGreaterThan(0);
expect(high.length).toBeGreaterThan(0);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { decodeHeic, ensureSharpCompat } from "../../../apps/api/src/lib/heic-converter.js";
const FIXTURES = join(__dirname, "../../fixtures");
function isPng(buf: Buffer): boolean {
return buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47;
}
function makeHeicHeader(brand: string): Buffer {
const buf = Buffer.alloc(12);
buf.writeUInt32BE(12, 0);
buf.write("ftyp", 4, 4, "ascii");
buf.write(brand, 8, 4, "ascii");
return buf;
}
describe("decodeHeic", () => {
it("decodes sample.heic to a valid PNG buffer", async () => {
const heicBuf = await readFile(join(FIXTURES, "formats/sample.heic"));
const result = await decodeHeic(heicBuf);
expect(isPng(result)).toBe(true);
const meta = await sharp(result).metadata();
expect(meta.width).toBeGreaterThan(0);
expect(meta.height).toBeGreaterThan(0);
});
it("decodes sample.heif to a valid PNG buffer", async () => {
const heifBuf = await readFile(join(FIXTURES, "formats/sample.heif"));
const result = await decodeHeic(heifBuf);
expect(isPng(result)).toBe(true);
const meta = await sharp(result).metadata();
expect(meta.width).toBeGreaterThan(0);
expect(meta.height).toBeGreaterThan(0);
});
it("preserves image dimensions after decoding", async () => {
const heicBuf = await readFile(join(FIXTURES, "formats/sample.heic"));
const result = await decodeHeic(heicBuf);
const meta = await sharp(result).metadata();
expect(meta.width).toBeGreaterThan(0);
expect(meta.height).toBeGreaterThan(0);
expect(meta.format).toBe("png");
});
it("rejects invalid/corrupt data", async () => {
const garbage = Buffer.from("this is not a heic file at all");
await expect(decodeHeic(garbage)).rejects.toThrow();
});
it("rejects an empty buffer", async () => {
await expect(decodeHeic(Buffer.alloc(0))).rejects.toThrow();
});
it("cleans up temp files after success", async () => {
const heicBuf = await readFile(join(FIXTURES, "formats/sample.heic"));
const { tmpdir } = await import("node:os");
const { readdirSync } = await import("node:fs");
const before = readdirSync(tmpdir()).filter(
(f) => f.startsWith("heic-in-") || f.startsWith("heic-out-"),
);
await decodeHeic(heicBuf);
const after = readdirSync(tmpdir()).filter(
(f) => f.startsWith("heic-in-") || f.startsWith("heic-out-"),
);
expect(after.length).toBeLessThanOrEqual(before.length);
});
});
describe("ensureSharpCompat", () => {
it("decodes a HEIC buffer to PNG", async () => {
const heicBuf = await readFile(join(FIXTURES, "formats/sample.heic"));
const result = await ensureSharpCompat(heicBuf);
expect(isPng(result)).toBe(true);
});
it("passes through a PNG buffer unchanged", async () => {
const pngBuf = await readFile(join(FIXTURES, "formats/sample.png"));
const result = await ensureSharpCompat(pngBuf);
expect(result).toBe(pngBuf);
});
it("passes through a JPEG buffer unchanged", async () => {
const jpgBuf = await readFile(join(FIXTURES, "formats/sample.jpg"));
const result = await ensureSharpCompat(jpgBuf);
expect(result).toBe(jpgBuf);
});
it("passes through a WebP buffer unchanged", async () => {
const webpBuf = await readFile(join(FIXTURES, "formats/sample.webp"));
const result = await ensureSharpCompat(webpBuf);
expect(result).toBe(webpBuf);
});
it("detects heic brand in ftyp box", async () => {
const fakeBuf = makeHeicHeader("heic");
await expect(ensureSharpCompat(fakeBuf)).rejects.toThrow();
});
it("detects mif1 brand in ftyp box", async () => {
const fakeBuf = makeHeicHeader("mif1");
await expect(ensureSharpCompat(fakeBuf)).rejects.toThrow();
});
it("does not treat a short buffer as HEIF", async () => {
const shortBuf = Buffer.from("tiny");
const result = await ensureSharpCompat(shortBuf);
expect(result).toBe(shortBuf);
});
});
+280
View File
@@ -0,0 +1,280 @@
import type { Permission, Role } from "@snapotter/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../apps/api/src/db/index.js", () => ({
db: {
select: () => ({
from: () => ({ where: () => ({ get: () => null }) }),
}),
},
schema: { roles: {}, settings: {} },
}));
const mockGetAuthUser = vi.fn();
vi.mock("../../../apps/api/src/plugins/auth.js", () => ({
getAuthUser: (...args: unknown[]) => mockGetAuthUser(...args),
}));
import {
hasEffectivePermission,
hasPermission,
requireOwnershipOrPermission,
requirePermission,
} from "../../../apps/api/src/permissions.js";
import type { AuthUser } from "../../../apps/api/src/plugins/auth.js";
function makeUser(overrides: Partial<AuthUser> & { role: string }): AuthUser {
return {
id: "u-test",
username: "tester",
...overrides,
};
}
function makeMockReply() {
const sent: { status?: number; body?: unknown } = {};
const reply = {
status(code: number) {
sent.status = code;
return reply;
},
send(body: unknown) {
sent.body = body;
return reply;
},
};
return { reply, sent };
}
beforeEach(() => {
mockGetAuthUser.mockReset();
});
describe("hasPermission extended", () => {
const adminPerms: Permission[] = [
"tools:use",
"files:own",
"files:all",
"apikeys:own",
"apikeys:all",
"pipelines:own",
"pipelines:all",
"settings:read",
"settings:write",
"users:manage",
"teams:manage",
"features:manage",
"system:health",
"audit:read",
];
it("admin has all 14 permissions", () => {
for (const perm of adminPerms) {
expect(hasPermission("admin", perm)).toBe(true);
}
});
it("editor has the expected permissions", () => {
const editorYes: Permission[] = [
"tools:use",
"files:own",
"files:all",
"apikeys:own",
"pipelines:own",
"pipelines:all",
"settings:read",
];
for (const perm of editorYes) {
expect(hasPermission("editor", perm)).toBe(true);
}
});
it("editor does NOT have admin-only permissions", () => {
const editorNo: Permission[] = [
"users:manage",
"teams:manage",
"settings:write",
"features:manage",
"system:health",
"audit:read",
];
for (const perm of editorNo) {
expect(hasPermission("editor", perm)).toBe(false);
}
});
it("user has the expected permissions", () => {
const userYes: Permission[] = [
"tools:use",
"files:own",
"apikeys:own",
"pipelines:own",
"settings:read",
];
for (const perm of userYes) {
expect(hasPermission("user", perm)).toBe(true);
}
});
it("user does NOT have elevated permissions", () => {
const userNo: Permission[] = [
"files:all",
"pipelines:all",
"users:manage",
"teams:manage",
"settings:write",
"features:manage",
"apikeys:all",
"system:health",
"audit:read",
];
for (const perm of userNo) {
expect(hasPermission("user", perm)).toBe(false);
}
});
it("unknown role returns false for any permission", () => {
expect(hasPermission("ghost" as Role, "tools:use")).toBe(false);
expect(hasPermission("ghost" as Role, "users:manage")).toBe(false);
});
});
describe("hasEffectivePermission extended", () => {
it("admin without apiKeyPermissions has all permissions", () => {
const admin = makeUser({ role: "admin" });
expect(hasEffectivePermission(admin, "tools:use")).toBe(true);
expect(hasEffectivePermission(admin, "users:manage")).toBe(true);
expect(hasEffectivePermission(admin, "audit:read")).toBe(true);
});
it("user with apiKeyPermissions only gets intersecting permissions", () => {
const user = makeUser({
role: "user",
apiKeyPermissions: ["tools:use", "settings:read"],
});
expect(hasEffectivePermission(user, "tools:use")).toBe(true);
expect(hasEffectivePermission(user, "settings:read")).toBe(true);
expect(hasEffectivePermission(user, "files:own")).toBe(false);
});
it("apiKeyPermissions that include the permission returns true", () => {
const editor = makeUser({
role: "editor",
apiKeyPermissions: ["files:all"],
});
expect(hasEffectivePermission(editor, "files:all")).toBe(true);
});
it("apiKeyPermissions that do NOT include the permission returns false", () => {
const editor = makeUser({
role: "editor",
apiKeyPermissions: ["tools:use"],
});
expect(hasEffectivePermission(editor, "files:all")).toBe(false);
});
it("role lacking the permission returns false even if apiKeyPermissions include it", () => {
const user = makeUser({
role: "user",
apiKeyPermissions: ["users:manage", "settings:write"],
});
expect(hasEffectivePermission(user, "users:manage")).toBe(false);
expect(hasEffectivePermission(user, "settings:write")).toBe(false);
});
});
describe("requirePermission", () => {
it("returns null and sends 401 when getAuthUser returns null", () => {
mockGetAuthUser.mockReturnValue(null);
const { reply, sent } = makeMockReply();
const result = requirePermission("tools:use")({} as never, reply as never);
expect(result).toBeNull();
expect(sent.status).toBe(401);
expect(sent.body).toEqual({
error: "Authentication required",
code: "AUTH_REQUIRED",
});
});
it("returns null and sends 403 when user lacks permission", () => {
mockGetAuthUser.mockReturnValue(makeUser({ role: "user" }));
const { reply, sent } = makeMockReply();
const result = requirePermission("users:manage")({} as never, reply as never);
expect(result).toBeNull();
expect(sent.status).toBe(403);
expect(sent.body).toEqual({
error: "Insufficient permissions",
code: "FORBIDDEN",
});
});
it("returns user when user has the permission", () => {
const admin = makeUser({ role: "admin" });
mockGetAuthUser.mockReturnValue(admin);
const { reply } = makeMockReply();
const result = requirePermission("users:manage")({} as never, reply as never);
expect(result).toEqual(admin);
});
it("returns user when editor has an editor-level permission", () => {
const editor = makeUser({ role: "editor" });
mockGetAuthUser.mockReturnValue(editor);
const { reply } = makeMockReply();
const result = requirePermission("tools:use")({} as never, reply as never);
expect(result).toEqual(editor);
});
});
describe("requireOwnershipOrPermission", () => {
it("returns null and sends 401 when no user", () => {
mockGetAuthUser.mockReturnValue(null);
const { reply, sent } = makeMockReply();
const result = requireOwnershipOrPermission(
{} as never,
reply as never,
"other-user",
"files:all",
);
expect(result).toBeNull();
expect(sent.status).toBe(401);
});
it("returns user when resourceUserId matches user.id (own resource)", () => {
const user = makeUser({ role: "user", id: "u-owner" });
mockGetAuthUser.mockReturnValue(user);
const { reply } = makeMockReply();
const result = requireOwnershipOrPermission(
{} as never,
reply as never,
"u-owner",
"files:all",
);
expect(result).toEqual(user);
});
it("returns user when user has the allPermission", () => {
const admin = makeUser({ role: "admin", id: "u-admin" });
mockGetAuthUser.mockReturnValue(admin);
const { reply } = makeMockReply();
const result = requireOwnershipOrPermission(
{} as never,
reply as never,
"u-someone-else",
"files:all",
);
expect(result).toEqual(admin);
});
it("returns null when not owner and lacks allPermission", () => {
const user = makeUser({ role: "user", id: "u-basic" });
mockGetAuthUser.mockReturnValue(user);
const { reply } = makeMockReply();
const result = requireOwnershipOrPermission(
{} as never,
reply as never,
"u-someone-else",
"files:all",
);
expect(result).toBeNull();
});
});
+169
View File
@@ -0,0 +1,169 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const uploadConfig = vi.hoisted(() => ({
MAX_UPLOAD_SIZE_MB: 10,
MAX_BATCH_SIZE: 5,
}));
vi.mock("../../../apps/api/src/config.js", () => ({ env: uploadConfig }));
const mockStaticRegister = vi.fn().mockResolvedValue(undefined);
vi.mock("@fastify/static", () => ({ default: "fastify-static-plugin" }));
const mockMultipartPlugin = vi.fn();
vi.mock("@fastify/multipart", () => ({ default: mockMultipartPlugin }));
vi.mock("node:fs", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
existsSync: vi.fn(() => true),
};
});
describe("registerStatic", () => {
let registerStatic: typeof import("../../../apps/api/src/plugins/static.js").registerStatic;
let existsSyncMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.clearAllMocks();
const staticModule = await import("../../../apps/api/src/plugins/static.js");
registerStatic = staticModule.registerStatic;
const fsMod = await import("node:fs");
existsSyncMock = fsMod.existsSync as ReturnType<typeof vi.fn>;
});
it("registers static plugin when dist path exists", async () => {
existsSyncMock.mockReturnValue(true);
const app = {
register: vi.fn().mockResolvedValue(undefined),
setNotFoundHandler: vi.fn(),
log: { warn: vi.fn() },
};
await registerStatic(app as never);
expect(app.register).toHaveBeenCalledWith("fastify-static-plugin", {
root: expect.stringContaining("web/dist"),
prefix: "/",
wildcard: false,
});
expect(app.setNotFoundHandler).toHaveBeenCalled();
});
it("sets SPA not-found handler that returns 404 for API routes", async () => {
existsSyncMock.mockReturnValue(true);
let notFoundHandler: (request: unknown, reply: unknown) => void;
const app = {
register: vi.fn().mockResolvedValue(undefined),
setNotFoundHandler: vi.fn((handler: typeof notFoundHandler) => {
notFoundHandler = handler;
}),
log: { warn: vi.fn() },
};
await registerStatic(app as never);
const reply = { code: vi.fn().mockReturnThis(), send: vi.fn(), sendFile: vi.fn() };
notFoundHandler!({ url: "/api/v1/tools" }, reply);
expect(reply.code).toHaveBeenCalledWith(404);
expect(reply.send).toHaveBeenCalledWith({ error: "Not found", code: "NOT_FOUND" });
});
it("sets SPA not-found handler that serves index.html for non-API routes", async () => {
existsSyncMock.mockReturnValue(true);
let notFoundHandler: (request: unknown, reply: unknown) => void;
const app = {
register: vi.fn().mockResolvedValue(undefined),
setNotFoundHandler: vi.fn((handler: typeof notFoundHandler) => {
notFoundHandler = handler;
}),
log: { warn: vi.fn() },
};
await registerStatic(app as never);
const reply = { code: vi.fn().mockReturnThis(), send: vi.fn(), sendFile: vi.fn() };
notFoundHandler!({ url: "/resize" }, reply);
expect(reply.sendFile).toHaveBeenCalledWith("index.html");
});
it("logs warning and skips registration when dist path does not exist", async () => {
existsSyncMock.mockReturnValue(false);
const app = {
register: vi.fn().mockResolvedValue(undefined),
setNotFoundHandler: vi.fn(),
log: { warn: vi.fn() },
};
await registerStatic(app as never);
expect(app.log.warn).toHaveBeenCalledWith(expect.stringContaining("SPA dist not found"));
expect(app.register).not.toHaveBeenCalled();
expect(app.setNotFoundHandler).not.toHaveBeenCalled();
});
});
describe("registerUpload", () => {
let registerUpload: typeof import("../../../apps/api/src/plugins/upload.js").registerUpload;
beforeEach(async () => {
vi.clearAllMocks();
const uploadModule = await import("../../../apps/api/src/plugins/upload.js");
registerUpload = uploadModule.registerUpload;
});
it("registers multipart with correct file size limit", async () => {
uploadConfig.MAX_UPLOAD_SIZE_MB = 10;
uploadConfig.MAX_BATCH_SIZE = 5;
const app = { register: vi.fn().mockResolvedValue(undefined) };
await registerUpload(app as never);
expect(app.register).toHaveBeenCalledWith(mockMultipartPlugin, {
limits: {
fileSize: 10 * 1024 * 1024,
files: 5,
},
});
});
it("passes undefined for fileSize when MAX_UPLOAD_SIZE_MB is 0", async () => {
uploadConfig.MAX_UPLOAD_SIZE_MB = 0;
uploadConfig.MAX_BATCH_SIZE = 5;
const app = { register: vi.fn().mockResolvedValue(undefined) };
await registerUpload(app as never);
expect(app.register).toHaveBeenCalledWith(mockMultipartPlugin, {
limits: {
fileSize: undefined,
files: 5,
},
});
});
it("passes undefined for files when MAX_BATCH_SIZE is 0", async () => {
uploadConfig.MAX_UPLOAD_SIZE_MB = 10;
uploadConfig.MAX_BATCH_SIZE = 0;
const app = { register: vi.fn().mockResolvedValue(undefined) };
await registerUpload(app as never);
expect(app.register).toHaveBeenCalledWith(mockMultipartPlugin, {
limits: {
fileSize: 10 * 1024 * 1024,
files: undefined,
},
});
});
it("passes undefined for both limits when both are 0", async () => {
uploadConfig.MAX_UPLOAD_SIZE_MB = 0;
uploadConfig.MAX_BATCH_SIZE = 0;
const app = { register: vi.fn().mockResolvedValue(undefined) };
await registerUpload(app as never);
expect(app.register).toHaveBeenCalledWith(mockMultipartPlugin, {
limits: {
fileSize: undefined,
files: undefined,
},
});
});
});
+208
View File
@@ -0,0 +1,208 @@
import { gzipSync } from "node:zlib";
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../apps/api/src/config.js", () => ({
env: { MAX_SVG_SIZE_MB: 10 },
}));
import {
decompressSvgz,
isSvgBuffer,
sanitizeSvg,
} from "../../../apps/api/src/lib/svg-sanitize.js";
describe("sanitizeSvg", () => {
it("removes DOCTYPE declarations", () => {
const input = Buffer.from(
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg><rect/></svg>',
);
const result = sanitizeSvg(input).toString("utf-8");
expect(result).not.toContain("DOCTYPE");
expect(result).toContain("<svg>");
});
it("removes DOCTYPE with internal subset (XXE prevention)", () => {
const input = Buffer.from(
'<!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><svg><text>&xxe;</text></svg>',
);
const result = sanitizeSvg(input).toString("utf-8");
expect(result).not.toContain("DOCTYPE");
expect(result).not.toContain("ENTITY");
});
it("removes script tags", () => {
const input = Buffer.from(
'<svg><script>alert("xss")</script><rect width="10" height="10"/></svg>',
);
const result = sanitizeSvg(input).toString("utf-8");
expect(result).not.toContain("<script");
expect(result).not.toContain("alert");
expect(result).toContain("<rect");
});
it("removes foreignObject elements", () => {
const input = Buffer.from("<svg><foreignObject><body>evil</body></foreignObject><rect/></svg>");
const result = sanitizeSvg(input).toString("utf-8");
expect(result).not.toContain("foreignObject");
expect(result).toContain("<rect/>");
});
it("removes event handlers (onload, onclick, onerror)", () => {
const input = Buffer.from(
'<svg onload="alert(1)"><rect onclick="steal()" onerror="hack()"/></svg>',
);
const result = sanitizeSvg(input).toString("utf-8");
expect(result).not.toContain("onload=");
expect(result).not.toContain("onclick=");
expect(result).not.toContain("onerror=");
});
it("blocks javascript: URIs in href", () => {
const input = Buffer.from('<svg><a href="javascript:alert(1)"><text>click</text></a></svg>');
const result = sanitizeSvg(input).toString("utf-8");
expect(result).not.toContain("javascript:");
});
it("blocks http/https URLs in href", () => {
const input = Buffer.from('<svg><image href="https://evil.com/track.png"/></svg>');
const result = sanitizeSvg(input).toString("utf-8");
expect(result).not.toContain("https://evil.com");
});
it("blocks http/https URLs in xlink:href", () => {
const input = Buffer.from('<svg><use xlink:href="http://evil.com/sprite.svg#icon"/></svg>');
const result = sanitizeSvg(input).toString("utf-8");
expect(result).not.toContain("http://evil.com");
});
it("blocks data:text/html URIs", () => {
const input = Buffer.from(
'<svg><a href="data:text/html,<script>alert(1)</script>"><text>x</text></a></svg>',
);
const result = sanitizeSvg(input).toString("utf-8");
expect(result).not.toContain("data:text/html");
});
it("blocks file: URIs", () => {
const input = Buffer.from('<svg><image href="file:///etc/passwd"/></svg>');
const result = sanitizeSvg(input).toString("utf-8");
expect(result).not.toContain("file:///etc/passwd");
});
it("blocks external url() references", () => {
const input = Buffer.from("<svg><rect style=\"fill: url('https://evil.com/track')\"/></svg>");
const result = sanitizeSvg(input).toString("utf-8");
expect(result).not.toContain("https://evil.com");
});
it("blocks file: url() references", () => {
const input = Buffer.from('<svg><rect style="fill: url(file:///etc/passwd)"/></svg>');
const result = sanitizeSvg(input).toString("utf-8");
expect(result).not.toContain("file:///etc/passwd");
});
it("removes XInclude elements", () => {
const input = Buffer.from(
'<svg xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="file:///etc/passwd"/></svg>',
);
const result = sanitizeSvg(input).toString("utf-8");
expect(result).not.toContain("xi:include");
});
it("preserves valid SVG content", () => {
const input = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect x="0" y="0" width="100" height="100" fill="red"/><circle cx="50" cy="50" r="25" fill="blue"/></svg>',
);
const result = sanitizeSvg(input).toString("utf-8");
expect(result).toContain("<svg");
expect(result).toContain("<rect");
expect(result).toContain("<circle");
expect(result).toContain('fill="red"');
expect(result).toContain('fill="blue"');
});
it("throws on oversized SVG", async () => {
const configMod = await import("../../../apps/api/src/config.js");
const saved = configMod.env.MAX_SVG_SIZE_MB;
configMod.env.MAX_SVG_SIZE_MB = 0.000001;
try {
const input = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>');
expect(() => sanitizeSvg(input)).toThrow("SVG exceeds maximum size");
} finally {
configMod.env.MAX_SVG_SIZE_MB = saved;
}
});
it("does NOT remove xml processing instruction", () => {
const input = Buffer.from('<?xml version="1.0" encoding="UTF-8"?><svg><rect/></svg>');
const result = sanitizeSvg(input).toString("utf-8");
expect(result).toContain("<?xml");
});
});
describe("decompressSvgz", () => {
it("returns non-gzipped buffer unchanged", () => {
const input = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>');
const result = decompressSvgz(input);
expect(result).toBe(input);
});
it("decompresses valid SVGZ content", () => {
const svgContent =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"/></svg>';
const compressed = gzipSync(Buffer.from(svgContent));
const result = decompressSvgz(compressed);
expect(result.toString("utf-8")).toBe(svgContent);
});
it("throws on decompressed content that is not SVG", () => {
const notSvg = gzipSync(Buffer.from("this is just plain text, not SVG at all"));
expect(() => decompressSvgz(notSvg)).toThrow("does not contain valid SVG");
});
it("returns short buffer unchanged (less than 2 bytes)", () => {
const tiny = Buffer.from([0x42]);
expect(decompressSvgz(tiny)).toBe(tiny);
});
it("returns buffer unchanged when first bytes do not match gzip magic", () => {
const notGzip = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
expect(decompressSvgz(notGzip)).toBe(notGzip);
});
});
describe("isSvgBuffer", () => {
it("returns true for buffer starting with <svg", () => {
const input = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
expect(isSvgBuffer(input)).toBe(true);
});
it("returns true for buffer starting with <?xml followed by <svg", () => {
const input = Buffer.from('<?xml version="1.0"?><svg><rect/></svg>');
expect(isSvgBuffer(input)).toBe(true);
});
it("returns false for PNG buffer", () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
expect(isSvgBuffer(png)).toBe(false);
});
it("returns false for empty buffer", () => {
expect(isSvgBuffer(Buffer.alloc(0))).toBe(false);
});
it("returns false for plain text buffer", () => {
const text = Buffer.from("Hello, this is not an SVG file");
expect(isSvgBuffer(text)).toBe(false);
});
it("returns true for SVG with leading whitespace", () => {
const input = Buffer.from(' \n <svg xmlns="http://www.w3.org/2000/svg"></svg>');
expect(isSvgBuffer(input)).toBe(true);
});
it("returns true for <?xml with leading whitespace before <svg", () => {
const input = Buffer.from('<?xml version="1.0"?>\n<svg><rect/></svg>');
expect(isSvgBuffer(input)).toBe(true);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../../apps/api/src/config.js", () => ({
env: { PROCESSING_TIMEOUT_S: 0 },
}));
import { computeExternalToolTimeout, computeTimeout } from "../../../apps/api/src/lib/timeout.js";
describe("computeTimeout", () => {
it("returns correct timeout for sharp category", () => {
const result = computeTimeout(50, "sharp");
expect(result).toBe(Math.max(60_000, 50 * 2 * 1000));
});
it("returns correct timeout for ai_cpu category", () => {
const result = computeTimeout(10, "ai_cpu");
expect(result).toBe(Math.max(60_000, 10 * 30 * 1000));
});
it("returns correct timeout for ai_gpu category", () => {
const result = computeTimeout(20, "ai_gpu");
expect(result).toBe(Math.max(60_000, 20 * 5 * 1000));
});
it("returns correct timeout for external category", () => {
const result = computeTimeout(10, "external");
expect(result).toBe(Math.max(60_000, 10 * 10 * 1000));
});
it("returns correct timeout for python category", () => {
const result = computeTimeout(10, "python");
expect(result).toBe(Math.max(60_000, 10 * 15 * 1000));
});
it("enforces minimum timeout of 60_000ms", () => {
const result = computeTimeout(0.001, "sharp");
expect(result).toBe(60_000);
});
it("enforces minimum timeout for small megapixel values", () => {
const result = computeTimeout(1, "sharp");
expect(result).toBe(60_000);
});
it("multiplies timeout by file count", () => {
const single = computeTimeout(50, "sharp");
const triple = computeTimeout(50, "sharp", 3);
expect(triple).toBe(single * 3);
});
it("defaults to fileCount of 1", () => {
const result = computeTimeout(50, "sharp");
const explicit = computeTimeout(50, "sharp", 1);
expect(result).toBe(explicit);
});
it("scales correctly for large megapixel values", () => {
const result = computeTimeout(100, "ai_cpu");
expect(result).toBe(100 * 30 * 1000);
});
it("uses override when PROCESSING_TIMEOUT_S > 0", async () => {
const configMod = await import("../../../apps/api/src/config.js");
const saved = configMod.env.PROCESSING_TIMEOUT_S;
configMod.env.PROCESSING_TIMEOUT_S = 120;
try {
const result = computeTimeout(999, "ai_gpu", 10);
expect(result).toBe(120 * 1000);
} finally {
configMod.env.PROCESSING_TIMEOUT_S = saved;
}
});
it("override ignores megapixels and fileCount", async () => {
const configMod = await import("../../../apps/api/src/config.js");
const saved = configMod.env.PROCESSING_TIMEOUT_S;
configMod.env.PROCESSING_TIMEOUT_S = 60;
try {
const result = computeTimeout(200, "python", 5);
expect(result).toBe(60 * 1000);
} finally {
configMod.env.PROCESSING_TIMEOUT_S = saved;
}
});
it("minimum applies per-file before multiplying by count", () => {
const result = computeTimeout(0, "sharp", 3);
expect(result).toBe(60_000 * 3);
});
});
describe("computeExternalToolTimeout", () => {
it("returns correct timeout for external category", () => {
const result = computeExternalToolTimeout(10);
expect(result).toBe(10 * 10 * 1000);
});
it("enforces minimum timeout of 60_000ms", () => {
const result = computeExternalToolTimeout(0);
expect(result).toBe(60_000);
});
it("scales with megapixels", () => {
const result = computeExternalToolTimeout(50);
expect(result).toBe(50 * 10 * 1000);
});
it("uses override when PROCESSING_TIMEOUT_S > 0", async () => {
const configMod = await import("../../../apps/api/src/config.js");
const saved = configMod.env.PROCESSING_TIMEOUT_S;
configMod.env.PROCESSING_TIMEOUT_S = 300;
try {
const result = computeExternalToolTimeout(999);
expect(result).toBe(300 * 1000);
} finally {
configMod.env.PROCESSING_TIMEOUT_S = saved;
}
});
it("enforces minimum for small megapixel values", () => {
const result = computeExternalToolTimeout(0.5);
expect(result).toBe(60_000);
});
});
+173
View File
@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../apps/api/src/db/index.js", () => ({
db: {
select: () => ({
from: () => ({ where: () => ({ get: () => null }) }),
}),
insert: () => ({ values: () => ({ run: vi.fn() }) }),
},
schema: { settings: {}, userFiles: {} },
}));
vi.mock("../../../apps/api/src/config.js", () => ({
env: {
WORKSPACE_PATH: "/tmp/test",
MAX_MEGAPIXELS: 100,
MAX_SVG_SIZE_MB: 10,
},
}));
import type { AnyToolRouteConfig } from "../../../apps/api/src/routes/tool-factory.js";
import {
getRegisteredToolIds,
getToolConfig,
registerToolProcessFn,
} from "../../../apps/api/src/routes/tool-factory.js";
const mockSchema = {
safeParse: (data: unknown) => ({ success: true, data }),
parse: (data: unknown) => data,
};
const strictSchema = {
safeParse: (data: unknown) => {
const obj = data as Record<string, unknown>;
if (typeof obj?.quality !== "number" || obj.quality < 1 || obj.quality > 100) {
return { success: false, error: { issues: [{ message: "Invalid" }] } };
}
return { success: true, data };
},
parse: (data: unknown) => data,
};
function makeMockConfig(toolId: string): AnyToolRouteConfig {
return {
toolId,
settingsSchema: mockSchema as never,
process: async (buf: Buffer, _settings: unknown, filename: string) => ({
buffer: buf,
filename,
contentType: "image/png",
}),
};
}
describe("tool-factory registry functions", () => {
const uniqueId = () => `test-tool-${Math.random().toString(36).slice(2, 10)}`;
describe("registerToolProcessFn", () => {
it("adds a tool to the registry", () => {
const id = uniqueId();
registerToolProcessFn(makeMockConfig(id));
expect(getToolConfig(id)).toBeDefined();
});
it("stores the correct config", () => {
const id = uniqueId();
const config = makeMockConfig(id);
registerToolProcessFn(config);
const stored = getToolConfig(id);
expect(stored?.toolId).toBe(id);
expect(stored?.process).toBe(config.process);
expect(stored?.settingsSchema).toBe(config.settingsSchema);
});
});
describe("getToolConfig", () => {
it("returns the config for a registered tool", () => {
const id = uniqueId();
registerToolProcessFn(makeMockConfig(id));
const config = getToolConfig(id);
expect(config).toBeDefined();
expect(config?.toolId).toBe(id);
});
it("returns undefined for an unregistered tool", () => {
expect(getToolConfig("nonexistent-tool-xyz-999")).toBeUndefined();
});
});
describe("getRegisteredToolIds", () => {
it("includes a registered tool", () => {
const id = uniqueId();
registerToolProcessFn(makeMockConfig(id));
expect(getRegisteredToolIds()).toContain(id);
});
it("includes multiple registered tools", () => {
const id1 = uniqueId();
const id2 = uniqueId();
registerToolProcessFn(makeMockConfig(id1));
registerToolProcessFn(makeMockConfig(id2));
const ids = getRegisteredToolIds();
expect(ids).toContain(id1);
expect(ids).toContain(id2);
});
it("returns an array", () => {
expect(Array.isArray(getRegisteredToolIds())).toBe(true);
});
});
describe("overwrite behavior", () => {
it("later registration overwrites earlier for the same toolId", async () => {
const id = uniqueId();
const original = makeMockConfig(id);
registerToolProcessFn(original);
const replacement: AnyToolRouteConfig = {
toolId: id,
settingsSchema: strictSchema as never,
process: async (buf: Buffer, _settings: unknown, filename: string) => ({
buffer: buf,
filename: `replaced-${filename}`,
contentType: "image/jpeg",
}),
};
registerToolProcessFn(replacement);
const stored = getToolConfig(id);
expect(stored?.process).toBe(replacement.process);
expect(stored?.settingsSchema).toBe(replacement.settingsSchema);
expect(stored?.process).not.toBe(original.process);
});
it("overwriting does not change the count for that toolId", () => {
const id = uniqueId();
registerToolProcessFn(makeMockConfig(id));
const countBefore = getRegisteredToolIds().filter((x) => x === id).length;
registerToolProcessFn(makeMockConfig(id));
const countAfter = getRegisteredToolIds().filter((x) => x === id).length;
expect(countBefore).toBe(1);
expect(countAfter).toBe(1);
});
});
describe("process function execution", () => {
it("stored process function returns expected output", async () => {
const id = uniqueId();
registerToolProcessFn(makeMockConfig(id));
const config = getToolConfig(id)!;
const input = Buffer.from("test-data");
const result = await config.process(input, {}, "photo.png");
expect(result.buffer).toBe(input);
expect(result.filename).toBe("photo.png");
expect(result.contentType).toBe("image/png");
});
it("stored process function with custom schema validates correctly", () => {
const id = uniqueId();
registerToolProcessFn({
toolId: id,
settingsSchema: strictSchema as never,
process: async (buf, _s, fn) => ({ buffer: buf, filename: fn, contentType: "image/png" }),
});
const config = getToolConfig(id)!;
const valid = config.settingsSchema.safeParse({ quality: 50 });
expect(valid.success).toBe(true);
const invalid = config.settingsSchema.safeParse({ quality: 200 });
expect(invalid.success).toBe(false);
});
});
});
+10 -11
View File
@@ -806,18 +806,19 @@ describe("Selection", () => {
});
it("invertSelection flips mask bytes", () => {
useEditorStore.setState({ canvasSize: { width: 3, height: 1 } });
const mask = new Uint8Array([0, 128, 255]);
const sel: SelectionState = {
type: "rect",
points: [0, 0, 100, 100],
bounds: { x: 0, y: 0, width: 100, height: 100 },
points: [0, 0, 3, 1],
bounds: { x: 0, y: 0, width: 3, height: 1 },
mask,
};
act((s) => s.setSelection(sel));
act((s) => s.invertSelection());
const inverted = state().selection?.mask;
expect(inverted?.[0]).toBe(255);
expect(inverted?.[1]).toBe(127);
expect(inverted?.[0]).toBe(1);
expect(inverted?.[1]).toBe(0);
expect(inverted?.[2]).toBe(0);
});
@@ -1612,7 +1613,7 @@ describe("invertSelection for bounds-based selections", () => {
expect(mask?.length).toBe(100); // 10 * 10
});
it("area inside bounds is 0 after inversion, outside is 255", () => {
it("area inside bounds is 0 after inversion, outside is 1", () => {
useEditorStore.setState({ canvasSize: { width: 10, height: 10 } });
const sel = {
type: "rect" as const,
@@ -1622,12 +1623,10 @@ describe("invertSelection for bounds-based selections", () => {
act((s) => s.setSelection(sel));
act((s) => s.invertSelection());
const mask = state().selection!.mask!;
// Inside the bounds (rows 2-5, cols 2-5) should be 0 (was 255, now inverted)
expect(mask[2 * 10 + 2]).toBe(0); // row 2, col 2
expect(mask[5 * 10 + 5]).toBe(0); // row 5, col 5
// Outside the bounds should be 255 (was 0, now inverted)
expect(mask[0 * 10 + 0]).toBe(255); // row 0, col 0
expect(mask[9 * 10 + 9]).toBe(255); // row 9, col 9
expect(mask[2 * 10 + 2]).toBe(0);
expect(mask[5 * 10 + 5]).toBe(0);
expect(mask[0 * 10 + 0]).toBe(1);
expect(mask[9 * 10 + 9]).toBe(1);
});
});
+580
View File
@@ -0,0 +1,580 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const apiGetMock = vi.fn();
const apiPostMock = vi.fn();
vi.mock("@/lib/api", () => ({
apiGet: (...args: unknown[]) => apiGetMock(...args),
apiPost: (...args: unknown[]) => apiPostMock(...args),
}));
class FakeEventSource {
static instances: FakeEventSource[] = [];
url: string;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
constructor(url: string) {
this.url = url;
FakeEventSource.instances.push(this);
}
close() {
this.closed = true;
}
static reset() {
FakeEventSource.instances = [];
}
}
vi.stubGlobal("EventSource", FakeEventSource);
import type { FeatureBundleState } from "@snapotter/shared";
import { useFeaturesStore } from "@/stores/features-store";
function makeBundleState(
overrides: Partial<FeatureBundleState> & { id: string },
): FeatureBundleState {
return {
name: overrides.id,
description: "Test bundle",
status: "not_installed",
installedVersion: null,
estimatedSize: "100 MB",
enablesTools: [],
progress: null,
error: null,
...overrides,
};
}
describe("useFeaturesStore", () => {
beforeEach(() => {
useFeaturesStore.setState({
bundles: [],
loaded: false,
loadError: false,
installing: {},
errors: {},
queued: [],
installAllActive: false,
startTimes: {},
});
vi.clearAllMocks();
FakeEventSource.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("initial state", () => {
it("loaded is false", () => {
expect(useFeaturesStore.getState().loaded).toBe(false);
});
it("loadError is false", () => {
expect(useFeaturesStore.getState().loadError).toBe(false);
});
it("bundles is empty array", () => {
expect(useFeaturesStore.getState().bundles).toEqual([]);
});
it("installing is empty object", () => {
expect(useFeaturesStore.getState().installing).toEqual({});
});
it("errors is empty object", () => {
expect(useFeaturesStore.getState().errors).toEqual({});
});
it("queued is empty array", () => {
expect(useFeaturesStore.getState().queued).toEqual([]);
});
it("installAllActive is false", () => {
expect(useFeaturesStore.getState().installAllActive).toBe(false);
});
});
describe("fetch()", () => {
it("sets bundles from response and loaded = true on success", async () => {
const bundles = [makeBundleState({ id: "bundle-a", status: "installed" })];
apiGetMock.mockResolvedValueOnce({ bundles });
await useFeaturesStore.getState().fetch();
expect(apiGetMock).toHaveBeenCalledWith("/v1/features");
expect(useFeaturesStore.getState().bundles).toEqual(bundles);
expect(useFeaturesStore.getState().loaded).toBe(true);
expect(useFeaturesStore.getState().loadError).toBe(false);
});
it("sets loadError = true on failure", async () => {
apiGetMock.mockRejectedValueOnce(new Error("Network error"));
await useFeaturesStore.getState().fetch();
expect(useFeaturesStore.getState().loaded).toBe(true);
expect(useFeaturesStore.getState().loadError).toBe(true);
});
it("skips fetch if already loaded without error", async () => {
useFeaturesStore.setState({ loaded: true, loadError: false });
await useFeaturesStore.getState().fetch();
expect(apiGetMock).not.toHaveBeenCalled();
});
it("retries fetch if previously loaded with error", async () => {
useFeaturesStore.setState({ loaded: true, loadError: true });
const bundles = [makeBundleState({ id: "bundle-b" })];
apiGetMock.mockResolvedValueOnce({ bundles });
await useFeaturesStore.getState().fetch();
expect(apiGetMock).toHaveBeenCalled();
expect(useFeaturesStore.getState().bundles).toEqual(bundles);
expect(useFeaturesStore.getState().loadError).toBe(false);
});
it("recovers active installs after successful fetch", async () => {
const bundles = [
makeBundleState({
id: "active-bundle",
status: "installing",
progress: { percent: 42, stage: "Downloading..." },
}),
];
apiGetMock.mockResolvedValueOnce({ bundles });
await useFeaturesStore.getState().fetch();
const state = useFeaturesStore.getState();
expect(state.installing["active-bundle"]).toBeDefined();
expect(state.installing["active-bundle"].percent).toBe(42);
expect(state.installing["active-bundle"].stage).toBe("Downloading...");
});
});
describe("isToolInstalled()", () => {
it("returns true when tool has no bundle requirement", () => {
expect(useFeaturesStore.getState().isToolInstalled("resize")).toBe(true);
});
it("returns true when tool bundle is installed", () => {
useFeaturesStore.setState({
bundles: [
makeBundleState({
id: "background-removal",
status: "installed",
enablesTools: ["remove-background"],
}),
],
});
expect(useFeaturesStore.getState().isToolInstalled("remove-background")).toBe(true);
});
it("returns false when bundle exists but not installed", () => {
useFeaturesStore.setState({
bundles: [
makeBundleState({
id: "background-removal",
status: "not_installed",
enablesTools: ["remove-background"],
}),
],
});
expect(useFeaturesStore.getState().isToolInstalled("remove-background")).toBe(false);
});
});
describe("getBundleForTool()", () => {
it("returns matching bundle state", () => {
const bundle = makeBundleState({
id: "background-removal",
status: "installed",
});
useFeaturesStore.setState({ bundles: [bundle] });
const result = useFeaturesStore.getState().getBundleForTool("remove-background");
expect(result).toEqual(bundle);
});
it("returns null when no bundle for tool", () => {
const result = useFeaturesStore.getState().getBundleForTool("resize");
expect(result).toBeNull();
});
it("returns null when bundle id has no matching state", () => {
useFeaturesStore.setState({ bundles: [] });
const result = useFeaturesStore.getState().getBundleForTool("remove-background");
expect(result).toBeNull();
});
});
describe("clearError()", () => {
it("removes error for specified bundleId", () => {
useFeaturesStore.setState({
errors: { "bundle-a": "Something went wrong", "bundle-b": "Another error" },
});
useFeaturesStore.getState().clearError("bundle-a");
const errors = useFeaturesStore.getState().errors;
expect(errors["bundle-a"]).toBeUndefined();
expect(errors["bundle-b"]).toBe("Another error");
});
it("does nothing if error does not exist for bundleId", () => {
useFeaturesStore.setState({ errors: { "bundle-a": "Error" } });
useFeaturesStore.getState().clearError("bundle-nonexistent");
expect(useFeaturesStore.getState().errors).toEqual({ "bundle-a": "Error" });
});
});
describe("installBundle()", () => {
it("sets installing state and calls API", async () => {
apiPostMock.mockResolvedValueOnce({ jobId: "job-123" });
const promise = useFeaturesStore.getState().installBundle("test-bundle");
await vi.waitFor(() => {
expect(useFeaturesStore.getState().installing["test-bundle"]).toBeDefined();
});
expect(useFeaturesStore.getState().installing["test-bundle"].percent).toBe(5);
expect(useFeaturesStore.getState().installing["test-bundle"].stage).toBe("Starting...");
await vi.waitFor(() => {
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/test-bundle/install", {});
});
expect(FakeEventSource.instances.length).toBe(1);
expect(FakeEventSource.instances[0].url).toBe("/api/v1/jobs/job-123/progress");
const es = FakeEventSource.instances[0];
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
await promise;
});
it("on API failure: sets error and clears installing", async () => {
apiPostMock.mockRejectedValueOnce(new Error("Server error"));
await useFeaturesStore.getState().installBundle("fail-bundle");
const state = useFeaturesStore.getState();
expect(state.installing["fail-bundle"]).toBeUndefined();
expect(state.errors["fail-bundle"]).toBe("Server error");
});
it("on API failure with non-Error: uses fallback message", async () => {
apiPostMock.mockRejectedValueOnce("some string error");
await useFeaturesStore.getState().installBundle("fail-bundle2");
const state = useFeaturesStore.getState();
expect(state.installing["fail-bundle2"]).toBeUndefined();
expect(state.errors["fail-bundle2"]).toBe("Failed to start installation");
});
it("clears previous error for the bundle before installing", async () => {
useFeaturesStore.setState({ errors: { "retry-bundle": "Old error" } });
apiPostMock.mockResolvedValueOnce({ jobId: "job-retry" });
const promise = useFeaturesStore.getState().installBundle("retry-bundle");
await vi.waitFor(() => {
expect(useFeaturesStore.getState().errors["retry-bundle"]).toBeUndefined();
});
const es = FakeEventSource.instances[0];
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
await promise;
});
});
describe("uninstallBundle()", () => {
it("calls API and refreshes", async () => {
apiPostMock.mockResolvedValueOnce({});
apiGetMock.mockResolvedValueOnce({ bundles: [] });
await useFeaturesStore.getState().uninstallBundle("remove-bundle");
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/remove-bundle/uninstall", {});
expect(apiGetMock).toHaveBeenCalledWith("/v1/features");
});
it("sets error on failure with Error instance", async () => {
apiPostMock.mockRejectedValueOnce(new Error("Uninstall failed hard"));
await useFeaturesStore.getState().uninstallBundle("fail-uninstall");
expect(useFeaturesStore.getState().errors["fail-uninstall"]).toBe("Uninstall failed hard");
});
it("sets fallback error on failure with non-Error", async () => {
apiPostMock.mockRejectedValueOnce(42);
await useFeaturesStore.getState().uninstallBundle("fail-uninstall2");
expect(useFeaturesStore.getState().errors["fail-uninstall2"]).toBe("Uninstall failed");
});
});
describe("reinstallBundle()", () => {
it("calls uninstall then install in sequence", async () => {
apiPostMock.mockResolvedValueOnce({}).mockResolvedValueOnce({ jobId: "job-reinstall" });
apiGetMock.mockResolvedValueOnce({ bundles: [] });
const promise = useFeaturesStore.getState().reinstallBundle("re-bundle");
await vi.waitFor(() => {
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/re-bundle/uninstall", {});
});
await vi.waitFor(() => {
expect(apiPostMock).toHaveBeenCalledWith("/v1/admin/features/re-bundle/install", {});
});
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBeGreaterThan(0);
});
const es = FakeEventSource.instances[0];
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
await promise;
});
});
describe("installAll()", () => {
it("sets installAllActive = true and processes all uninstalled bundles", async () => {
const bundles = [
makeBundleState({ id: "bundle-1", status: "not_installed" }),
makeBundleState({ id: "bundle-2", status: "installed" }),
makeBundleState({ id: "bundle-3", status: "not_installed" }),
];
useFeaturesStore.setState({ bundles, loaded: true });
apiPostMock.mockImplementation((path: string) => {
if (path.includes("install")) {
return Promise.resolve({ jobId: `job-${path}` });
}
return Promise.resolve({});
});
apiGetMock.mockResolvedValue({ bundles });
const promise = useFeaturesStore.getState().installAll();
await vi.waitFor(() => {
expect(useFeaturesStore.getState().installAllActive).toBe(true);
});
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBeGreaterThan(0);
});
for (const es of FakeEventSource.instances) {
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
}
await vi
.waitFor(
() => {
if (FakeEventSource.instances.length < 2) {
throw new Error("waiting for second EventSource");
}
},
{ timeout: 2000 },
)
.catch(() => {});
for (const es of FakeEventSource.instances) {
if (!es.closed) {
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
}
}
await promise;
const state = useFeaturesStore.getState();
expect(state.installAllActive).toBe(false);
expect(state.queued).toEqual([]);
});
it("clears stale errors for pending bundles", async () => {
const bundles = [makeBundleState({ id: "err-bundle", status: "error" })];
useFeaturesStore.setState({
bundles,
loaded: true,
errors: { "err-bundle": "Old failure" },
});
apiPostMock.mockResolvedValue({ jobId: "job-err" });
apiGetMock.mockResolvedValue({ bundles });
const promise = useFeaturesStore.getState().installAll();
await vi.waitFor(() => {
expect(useFeaturesStore.getState().errors["err-bundle"]).toBeUndefined();
});
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBeGreaterThan(0);
});
for (const es of FakeEventSource.instances) {
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
}
await promise;
});
});
describe("EventSource progress handling", () => {
it("updates progress on message", async () => {
apiPostMock.mockResolvedValueOnce({ jobId: "job-progress" });
const promise = useFeaturesStore.getState().installBundle("progress-bundle");
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(1);
});
const es = FakeEventSource.instances[0];
es.onmessage?.({
data: JSON.stringify({ phase: "downloading", percent: 50, stage: "Downloading models..." }),
});
await vi.waitFor(() => {
const installing = useFeaturesStore.getState().installing["progress-bundle"];
expect(installing).toBeDefined();
expect(installing.percent).toBe(50);
expect(installing.stage).toBe("Downloading models...");
});
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
await promise;
});
it("handles failed phase from EventSource", async () => {
apiPostMock.mockResolvedValueOnce({ jobId: "job-fail" });
const promise = useFeaturesStore.getState().installBundle("fail-es-bundle");
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(1);
});
const es = FakeEventSource.instances[0];
es.onmessage?.({
data: JSON.stringify({ phase: "failed", error: "Download failed" }),
});
await promise;
const state = useFeaturesStore.getState();
expect(state.installing["fail-es-bundle"]).toBeUndefined();
expect(state.errors["fail-es-bundle"]).toBe("Download failed");
});
it("handles failed phase with no error message", async () => {
apiPostMock.mockResolvedValueOnce({ jobId: "job-fail-noerr" });
const promise = useFeaturesStore.getState().installBundle("fail-noerr-bundle");
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(1);
});
const es = FakeEventSource.instances[0];
es.onmessage?.({ data: JSON.stringify({ phase: "failed" }) });
await promise;
expect(useFeaturesStore.getState().errors["fail-noerr-bundle"]).toBe("Installation failed");
});
it("falls back to polling on EventSource error", async () => {
apiPostMock.mockResolvedValueOnce({ jobId: "job-es-err" });
useFeaturesStore.getState().installBundle("poll-bundle");
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(1);
});
const es = FakeEventSource.instances[0];
es.onerror?.();
expect(es.closed).toBe(true);
});
it("progress percent never decreases", async () => {
apiPostMock.mockResolvedValueOnce({ jobId: "job-monotonic" });
const promise = useFeaturesStore.getState().installBundle("mono-bundle");
await vi.waitFor(() => {
expect(FakeEventSource.instances.length).toBe(1);
});
const es = FakeEventSource.instances[0];
es.onmessage?.({
data: JSON.stringify({ phase: "downloading", percent: 60, stage: "Stage A" }),
});
await vi.waitFor(() => {
expect(useFeaturesStore.getState().installing["mono-bundle"]?.percent).toBe(60);
});
es.onmessage?.({
data: JSON.stringify({ phase: "downloading", percent: 30, stage: "Stage B" }),
});
await vi.waitFor(() => {
const installing = useFeaturesStore.getState().installing["mono-bundle"];
expect(installing?.percent).toBe(60);
expect(installing?.stage).toBe("Stage B");
});
es.onmessage?.({ data: JSON.stringify({ phase: "complete" }) });
await promise;
});
});
describe("refresh()", () => {
it("refreshes bundles from API", async () => {
const bundles = [makeBundleState({ id: "refreshed" })];
apiGetMock.mockResolvedValueOnce({ bundles });
await useFeaturesStore.getState().refresh();
expect(apiGetMock).toHaveBeenCalledWith("/v1/features");
expect(useFeaturesStore.getState().bundles).toEqual(bundles);
expect(useFeaturesStore.getState().loaded).toBe(true);
});
it("silently handles refresh failure", async () => {
useFeaturesStore.setState({ bundles: [makeBundleState({ id: "existing" })], loaded: true });
apiGetMock.mockRejectedValueOnce(new Error("fail"));
await useFeaturesStore.getState().refresh();
expect(useFeaturesStore.getState().bundles).toEqual([makeBundleState({ id: "existing" })]);
});
});
});
+623
View File
@@ -0,0 +1,623 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const revokeObjectURL = vi.fn();
const createObjectURL = vi.fn(() => "blob:fake-url");
vi.stubGlobal("URL", { ...globalThis.URL, createObjectURL, revokeObjectURL });
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.mock("@/lib/api", () => ({ formatHeaders: vi.fn(() => ({})) }));
import type { MemeTemplate } from "@/stores/meme-store";
import {
CATEGORIES,
FONT_FAMILY_MAP,
FONT_OPTIONS,
injectMemeFonts,
PRESET_LAYOUTS,
useMemeStore,
} from "@/stores/meme-store";
function okJson(data: unknown) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(data),
} as unknown as Response);
}
function failResponse(status: number, body?: unknown) {
return Promise.resolve({
ok: false,
status,
json: () => (body !== undefined ? Promise.resolve(body) : Promise.reject(new Error("no body"))),
} as unknown as Response);
}
const TEMPLATE: MemeTemplate = {
id: "drake",
name: "Drake Hotline Bling",
aliases: ["drake"],
tags: ["reaction"],
category: "reaction",
filename: "drake.jpg",
width: 600,
height: 600,
popularity: 100,
textBoxes: [
{ id: "top", x: 50, y: 0, width: 50, height: 50, defaultText: "Nah" },
{ id: "bottom", x: 50, y: 50, width: 50, height: 50, defaultText: "Yeah" },
],
};
function resetStore() {
useMemeStore.getState().reset();
vi.clearAllMocks();
}
describe("useMemeStore", () => {
beforeEach(() => {
resetStore();
});
afterEach(() => {
resetStore();
});
describe("initial state", () => {
it("has correct defaults", () => {
const s = useMemeStore.getState();
expect(s.phase).toBe("gallery");
expect(s.templates).toEqual([]);
expect(s.loading).toBe(true);
expect(s.searchQuery).toBe("");
expect(s.activeCategory).toBe("all");
expect(s.selectedTemplate).toBeNull();
expect(s.customFile).toBeNull();
expect(s.customImageUrl).toBeNull();
expect(s.customLayout).toBeNull();
expect(s.textBoxValues).toEqual([]);
expect(s.fontFamily).toBe("anton");
expect(s.fontSize).toBe(0);
expect(s.textColor).toBe("#ffffff");
expect(s.strokeColor).toBe("#000000");
expect(s.textAlign).toBe("center");
expect(s.allCaps).toBe(true);
expect(s.generating).toBe(false);
expect(s.resultUrl).toBeNull();
expect(s.downloadUrl).toBeNull();
expect(s.error).toBeNull();
});
});
describe("simple setters", () => {
it("setPhase updates phase", () => {
useMemeStore.getState().setPhase("editor");
expect(useMemeStore.getState().phase).toBe("editor");
});
it("setSearchQuery updates searchQuery", () => {
useMemeStore.getState().setSearchQuery("drake");
expect(useMemeStore.getState().searchQuery).toBe("drake");
});
it("setActiveCategory updates activeCategory", () => {
useMemeStore.getState().setActiveCategory("reaction");
expect(useMemeStore.getState().activeCategory).toBe("reaction");
});
it("setFontFamily updates fontFamily", () => {
useMemeStore.getState().setFontFamily("comic-sans");
expect(useMemeStore.getState().fontFamily).toBe("comic-sans");
});
it("setFontSize updates fontSize", () => {
useMemeStore.getState().setFontSize(32);
expect(useMemeStore.getState().fontSize).toBe(32);
});
it("setTextColor updates textColor", () => {
useMemeStore.getState().setTextColor("#ff0000");
expect(useMemeStore.getState().textColor).toBe("#ff0000");
});
it("setStrokeColor updates strokeColor", () => {
useMemeStore.getState().setStrokeColor("#00ff00");
expect(useMemeStore.getState().strokeColor).toBe("#00ff00");
});
it("setTextAlign updates textAlign", () => {
useMemeStore.getState().setTextAlign("left");
expect(useMemeStore.getState().textAlign).toBe("left");
});
it("setAllCaps updates allCaps", () => {
useMemeStore.getState().setAllCaps(false);
expect(useMemeStore.getState().allCaps).toBe(false);
});
});
describe("selectTemplate", () => {
it("sets phase to editor", () => {
useMemeStore.getState().selectTemplate(TEMPLATE);
expect(useMemeStore.getState().phase).toBe("editor");
});
it("sets selectedTemplate", () => {
useMemeStore.getState().selectTemplate(TEMPLATE);
expect(useMemeStore.getState().selectedTemplate).toBe(TEMPLATE);
});
it("creates textBoxValues from template textBoxes", () => {
useMemeStore.getState().selectTemplate(TEMPLATE);
const values = useMemeStore.getState().textBoxValues;
expect(values).toHaveLength(2);
expect(values[0]).toEqual({ id: "top", text: "" });
expect(values[1]).toEqual({ id: "bottom", text: "" });
});
it("clears customFile, customImageUrl, and customLayout", () => {
useMemeStore.setState({
customFile: new File(["x"], "test.png"),
customImageUrl: "blob:old",
customLayout: "top-bottom",
});
useMemeStore.getState().selectTemplate(TEMPLATE);
const s = useMemeStore.getState();
expect(s.customFile).toBeNull();
expect(s.customImageUrl).toBeNull();
expect(s.customLayout).toBeNull();
});
it("clears resultUrl, downloadUrl, error, and generating", () => {
useMemeStore.setState({
resultUrl: "http://old",
downloadUrl: "http://old",
error: "old error",
generating: true,
});
useMemeStore.getState().selectTemplate(TEMPLATE);
const s = useMemeStore.getState();
expect(s.resultUrl).toBeNull();
expect(s.downloadUrl).toBeNull();
expect(s.error).toBeNull();
expect(s.generating).toBe(false);
});
it("revokes old customImageUrl if present", () => {
useMemeStore.setState({ customImageUrl: "blob:to-revoke" });
useMemeStore.getState().selectTemplate(TEMPLATE);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:to-revoke");
});
it("does not call revokeObjectURL when customImageUrl is null", () => {
useMemeStore.getState().selectTemplate(TEMPLATE);
expect(revokeObjectURL).not.toHaveBeenCalled();
});
});
describe("setCustomImage", () => {
const file = new File(["pixels"], "photo.png", { type: "image/png" });
it("sets phase to layout-picker", () => {
useMemeStore.getState().setCustomImage(file);
expect(useMemeStore.getState().phase).toBe("layout-picker");
});
it("creates blob URL via URL.createObjectURL", () => {
useMemeStore.getState().setCustomImage(file);
expect(createObjectURL).toHaveBeenCalledWith(file);
expect(useMemeStore.getState().customImageUrl).toBe("blob:fake-url");
});
it("stores the file as customFile", () => {
useMemeStore.getState().setCustomImage(file);
expect(useMemeStore.getState().customFile).toBe(file);
});
it("clears selectedTemplate", () => {
useMemeStore.setState({ selectedTemplate: TEMPLATE });
useMemeStore.getState().setCustomImage(file);
expect(useMemeStore.getState().selectedTemplate).toBeNull();
});
it("revokes old customImageUrl", () => {
useMemeStore.setState({ customImageUrl: "blob:old-custom" });
useMemeStore.getState().setCustomImage(file);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:old-custom");
});
});
describe("setCustomLayout", () => {
it("sets phase to editor", () => {
useMemeStore.getState().setCustomLayout("top-bottom");
expect(useMemeStore.getState().phase).toBe("editor");
});
it("creates textBoxValues from PRESET_LAYOUTS", () => {
useMemeStore.getState().setCustomLayout("center");
const values = useMemeStore.getState().textBoxValues;
expect(values).toHaveLength(1);
expect(values[0]).toEqual({ id: "center", text: "" });
});
it("sets customLayout value", () => {
useMemeStore.getState().setCustomLayout("side-by-side");
expect(useMemeStore.getState().customLayout).toBe("side-by-side");
});
it("creates two text boxes for top-bottom layout", () => {
useMemeStore.getState().setCustomLayout("top-bottom");
const values = useMemeStore.getState().textBoxValues;
expect(values).toHaveLength(2);
expect(values[0].id).toBe("top");
expect(values[1].id).toBe("bottom");
});
it("falls back to top-bottom layout for unknown layout", () => {
useMemeStore.getState().setCustomLayout("nonexistent" as "top-bottom");
const values = useMemeStore.getState().textBoxValues;
expect(values).toHaveLength(2);
expect(values[0].id).toBe("top");
expect(values[1].id).toBe("bottom");
});
});
describe("updateTextValue", () => {
beforeEach(() => {
useMemeStore.getState().selectTemplate(TEMPLATE);
});
it("updates the correct text box by id", () => {
useMemeStore.getState().updateTextValue("top", "Hello");
const values = useMemeStore.getState().textBoxValues;
expect(values.find((v) => v.id === "top")?.text).toBe("Hello");
});
it("leaves other text boxes unchanged", () => {
useMemeStore.getState().updateTextValue("top", "Changed");
const values = useMemeStore.getState().textBoxValues;
expect(values.find((v) => v.id === "bottom")?.text).toBe("");
});
it("handles non-existent id gracefully", () => {
useMemeStore.getState().updateTextValue("missing", "Nope");
const values = useMemeStore.getState().textBoxValues;
expect(values).toHaveLength(2);
expect(values.every((v) => v.text === "" || v.id === "missing")).toBe(true);
});
});
describe("fetchTemplates", () => {
it("sets loading true and error null on start", async () => {
useMemeStore.setState({ loading: false, error: "old" });
fetchMock.mockReturnValue(new Promise(() => {}));
const promise = useMemeStore.getState().fetchTemplates();
expect(useMemeStore.getState().loading).toBe(true);
expect(useMemeStore.getState().error).toBeNull();
fetchMock.mockReset();
await Promise.resolve();
});
it("on success: sets templates from manifest and loading false", async () => {
const manifest = { version: 1, categories: ["reaction"], templates: [TEMPLATE] };
fetchMock.mockReturnValue(okJson(manifest));
await useMemeStore.getState().fetchTemplates();
const s = useMemeStore.getState();
expect(s.templates).toEqual([TEMPLATE]);
expect(s.loading).toBe(false);
expect(s.error).toBeNull();
});
it("on fetch failure: sets error message and loading false", async () => {
fetchMock.mockRejectedValue(new Error("Network down"));
await useMemeStore.getState().fetchTemplates();
const s = useMemeStore.getState();
expect(s.error).toBe("Network down");
expect(s.loading).toBe(false);
});
it("on non-ok response: sets error with status code", async () => {
fetchMock.mockReturnValue(failResponse(500));
await useMemeStore.getState().fetchTemplates();
const s = useMemeStore.getState();
expect(s.error).toBe("Failed to load templates: 500");
expect(s.loading).toBe(false);
});
});
describe("generateMeme", () => {
it("sets generating true on start", async () => {
useMemeStore.setState({ selectedTemplate: TEMPLATE });
fetchMock.mockReturnValue(new Promise(() => {}));
const promise = useMemeStore.getState().generateMeme();
expect(useMemeStore.getState().generating).toBe(true);
expect(useMemeStore.getState().error).toBeNull();
fetchMock.mockReset();
await Promise.resolve();
});
it("with template: sends JSON POST (no FormData)", async () => {
useMemeStore.setState({
selectedTemplate: TEMPLATE,
textBoxValues: [
{ id: "top", text: "Hello" },
{ id: "bottom", text: "World" },
],
});
const result = { jobId: "j1", downloadUrl: "/dl/j1", originalSize: 100, processedSize: 200 };
fetchMock.mockReturnValue(okJson(result));
await useMemeStore.getState().generateMeme();
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/tools/meme-generator",
expect.objectContaining({
method: "POST",
body: expect.any(String),
}),
);
const callBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
expect(callBody.templateId).toBe("drake");
});
it("with custom file: sends FormData POST", async () => {
const file = new File(["x"], "img.png", { type: "image/png" });
useMemeStore.setState({
customFile: file,
customLayout: "top-bottom",
textBoxValues: [{ id: "top", text: "Hi" }],
});
const result = { jobId: "j2", downloadUrl: "/dl/j2", originalSize: 50, processedSize: 100 };
fetchMock.mockReturnValue(okJson(result));
await useMemeStore.getState().generateMeme();
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/tools/meme-generator",
expect.objectContaining({
method: "POST",
body: expect.any(FormData),
}),
);
});
it("on success: sets resultUrl, downloadUrl, phase result, generating false", async () => {
useMemeStore.setState({ selectedTemplate: TEMPLATE });
const result = { jobId: "j1", downloadUrl: "/dl/j1", originalSize: 100, processedSize: 200 };
fetchMock.mockReturnValue(okJson(result));
await useMemeStore.getState().generateMeme();
const s = useMemeStore.getState();
expect(s.resultUrl).toBe("/dl/j1");
expect(s.downloadUrl).toBe("/dl/j1");
expect(s.phase).toBe("result");
expect(s.generating).toBe(false);
});
it("on failure: sets error and generating false", async () => {
useMemeStore.setState({ selectedTemplate: TEMPLATE });
fetchMock.mockRejectedValue(new Error("Boom"));
await useMemeStore.getState().generateMeme();
const s = useMemeStore.getState();
expect(s.error).toBe("Boom");
expect(s.generating).toBe(false);
});
it("on non-ok response: extracts error from body", async () => {
useMemeStore.setState({ selectedTemplate: TEMPLATE });
fetchMock.mockReturnValue(failResponse(422, { error: "Invalid text" }));
await useMemeStore.getState().generateMeme();
expect(useMemeStore.getState().error).toBe("Invalid text");
});
it("on non-ok response with no body error: uses status code", async () => {
useMemeStore.setState({ selectedTemplate: TEMPLATE });
fetchMock.mockReturnValue(failResponse(500, {}));
await useMemeStore.getState().generateMeme();
expect(useMemeStore.getState().error).toBe("Generation failed: 500");
});
});
describe("backToGallery", () => {
it("resets phase to gallery", () => {
useMemeStore.setState({ phase: "editor" });
useMemeStore.getState().backToGallery();
expect(useMemeStore.getState().phase).toBe("gallery");
});
it("clears all selection state", () => {
useMemeStore.setState({
selectedTemplate: TEMPLATE,
customFile: new File(["x"], "x.png"),
customLayout: "top-bottom",
textBoxValues: [{ id: "top", text: "hi" }],
resultUrl: "http://r",
downloadUrl: "http://d",
error: "err",
generating: true,
});
useMemeStore.getState().backToGallery();
const s = useMemeStore.getState();
expect(s.selectedTemplate).toBeNull();
expect(s.customFile).toBeNull();
expect(s.customImageUrl).toBeNull();
expect(s.customLayout).toBeNull();
expect(s.textBoxValues).toEqual([]);
expect(s.resultUrl).toBeNull();
expect(s.downloadUrl).toBeNull();
expect(s.error).toBeNull();
expect(s.generating).toBe(false);
});
it("revokes customImageUrl", () => {
useMemeStore.setState({ customImageUrl: "blob:revoke-me" });
useMemeStore.getState().backToGallery();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:revoke-me");
});
});
describe("backToEditor", () => {
it("sets phase to editor", () => {
useMemeStore.setState({ phase: "result" });
useMemeStore.getState().backToEditor();
expect(useMemeStore.getState().phase).toBe("editor");
});
it("clears resultUrl and downloadUrl", () => {
useMemeStore.setState({ resultUrl: "http://r", downloadUrl: "http://d" });
useMemeStore.getState().backToEditor();
expect(useMemeStore.getState().resultUrl).toBeNull();
expect(useMemeStore.getState().downloadUrl).toBeNull();
});
it("preserves other state", () => {
useMemeStore.setState({
phase: "result",
selectedTemplate: TEMPLATE,
fontFamily: "comic-sans",
textColor: "#ff0000",
textBoxValues: [{ id: "top", text: "kept" }],
});
useMemeStore.getState().backToEditor();
const s = useMemeStore.getState();
expect(s.selectedTemplate).toBe(TEMPLATE);
expect(s.fontFamily).toBe("comic-sans");
expect(s.textColor).toBe("#ff0000");
expect(s.textBoxValues[0].text).toBe("kept");
});
});
describe("reset", () => {
it("resets everything to initial defaults", () => {
useMemeStore.setState({
phase: "result",
templates: [TEMPLATE],
loading: false,
searchQuery: "drake",
activeCategory: "reaction",
selectedTemplate: TEMPLATE,
customFile: new File(["x"], "x.png"),
customLayout: "center",
textBoxValues: [{ id: "top", text: "hi" }],
fontFamily: "comic-sans",
fontSize: 24,
textColor: "#ff0000",
strokeColor: "#00ff00",
textAlign: "left",
allCaps: false,
generating: true,
resultUrl: "http://r",
downloadUrl: "http://d",
error: "err",
});
useMemeStore.getState().reset();
const s = useMemeStore.getState();
expect(s.phase).toBe("gallery");
expect(s.templates).toEqual([]);
expect(s.loading).toBe(true);
expect(s.searchQuery).toBe("");
expect(s.activeCategory).toBe("all");
expect(s.selectedTemplate).toBeNull();
expect(s.customFile).toBeNull();
expect(s.customImageUrl).toBeNull();
expect(s.customLayout).toBeNull();
expect(s.textBoxValues).toEqual([]);
expect(s.fontFamily).toBe("anton");
expect(s.fontSize).toBe(0);
expect(s.textColor).toBe("#ffffff");
expect(s.strokeColor).toBe("#000000");
expect(s.textAlign).toBe("center");
expect(s.allCaps).toBe(true);
expect(s.generating).toBe(false);
expect(s.resultUrl).toBeNull();
expect(s.downloadUrl).toBeNull();
expect(s.error).toBeNull();
});
it("revokes customImageUrl", () => {
useMemeStore.setState({ customImageUrl: "blob:reset-me" });
useMemeStore.getState().reset();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:reset-me");
});
});
describe("constants", () => {
it("FONT_OPTIONS has expected entries", () => {
expect(FONT_OPTIONS.length).toBe(7);
const values = FONT_OPTIONS.map((f) => f.value);
expect(values).toContain("anton");
expect(values).toContain("comic-sans");
expect(values).toContain("bebas-neue");
expect(values).toContain("permanent-marker");
});
it("FONT_FAMILY_MAP maps all font options", () => {
for (const opt of FONT_OPTIONS) {
expect(FONT_FAMILY_MAP[opt.value]).toBeDefined();
expect(typeof FONT_FAMILY_MAP[opt.value]).toBe("string");
}
});
it("CATEGORIES has expected entries", () => {
expect(CATEGORIES.length).toBe(6);
const ids = CATEGORIES.map((c) => c.id);
expect(ids).toContain("all");
expect(ids).toContain("reaction");
expect(ids).toContain("comparison");
expect(ids).toContain("animals");
expect(ids).toContain("classic");
});
it("PRESET_LAYOUTS has all 5 layouts", () => {
const keys = Object.keys(PRESET_LAYOUTS);
expect(keys).toHaveLength(5);
expect(keys).toContain("top-bottom");
expect(keys).toContain("top-only");
expect(keys).toContain("bottom-only");
expect(keys).toContain("center");
expect(keys).toContain("side-by-side");
});
it("PRESET_LAYOUTS have correct box structures", () => {
expect(PRESET_LAYOUTS["top-bottom"].boxes).toHaveLength(2);
expect(PRESET_LAYOUTS["top-only"].boxes).toHaveLength(1);
expect(PRESET_LAYOUTS["bottom-only"].boxes).toHaveLength(1);
expect(PRESET_LAYOUTS["center"].boxes).toHaveLength(1);
expect(PRESET_LAYOUTS["side-by-side"].boxes).toHaveLength(2);
for (const layout of Object.values(PRESET_LAYOUTS)) {
for (const box of layout.boxes) {
expect(box).toHaveProperty("id");
expect(box).toHaveProperty("x");
expect(box).toHaveProperty("y");
expect(box).toHaveProperty("width");
expect(box).toHaveProperty("height");
expect(box).toHaveProperty("defaultText");
}
}
});
});
describe("injectMemeFonts", () => {
it("creates style element in document head and is idempotent", async () => {
const existing = document.getElementById("meme-generator-fonts");
if (existing) existing.remove();
vi.resetModules();
const mod = await import("@/stores/meme-store");
const inject = mod.injectMemeFonts;
inject();
const el = document.getElementById("meme-generator-fonts");
expect(el).not.toBeNull();
expect(el?.tagName).toBe("STYLE");
expect(el?.textContent).toContain("@font-face");
expect(el?.textContent).toContain("Anton");
inject();
const elements = document.querySelectorAll("#meme-generator-fonts");
expect(elements).toHaveLength(1);
el?.remove();
});
});
});
+67
View File
@@ -169,6 +169,18 @@ vi.mock("@/components/tools/transparency-fixer-settings", () => ({
vi.mock("@/components/tools/content-aware-resize-settings", () => ({
ContentAwareResizeSettings: () => null,
}));
vi.mock("@/components/tools/beautify-settings", () => ({
BeautifySettings: () => null,
}));
vi.mock("@/components/tools/meme-generator-settings", () => ({
MemeGeneratorSettings: () => null,
}));
vi.mock("@/components/tools/meme-generator-preview", () => ({
MemeGeneratorPreview: () => null,
}));
vi.mock("@/components/tools/color-blindness-settings", () => ({
ColorBlindnessSettings: () => null,
}));
// ---------------------------------------------------------------------------
// Import after mocks
@@ -319,6 +331,35 @@ describe("toolRegistry", () => {
expect(border?.livePreview).toBe(true);
});
it("beautify has livePreview enabled", () => {
const beautify = toolRegistry.get("beautify");
expect(beautify).toBeDefined();
expect(beautify?.livePreview).toBe(true);
expect(beautify?.displayMode).toBe("live-preview");
});
it("meme-generator has no-dropzone display mode with ResultsPanel", () => {
const meme = toolRegistry.get("meme-generator");
expect(meme).toBeDefined();
expect(meme?.displayMode).toBe("no-dropzone");
expect(meme?.ResultsPanel).toBeDefined();
expect(meme?.Settings).toBeDefined();
});
it("color-blindness uses before-after display mode", () => {
const cb = toolRegistry.get("color-blindness");
expect(cb).toBeDefined();
expect(cb?.displayMode).toBe("before-after");
expect(cb?.Settings).toBeDefined();
});
it("transparency-fixer uses before-after display mode", () => {
const tf = toolRegistry.get("transparency-fixer");
expect(tf).toBeDefined();
expect(tf?.displayMode).toBe("before-after");
expect(tf?.Settings).toBeDefined();
});
it("crop uses interactive-crop display mode", () => {
const crop = toolRegistry.get("crop");
expect(crop?.displayMode).toBe("interactive-crop");
@@ -389,6 +430,32 @@ describe("getToolRegistryEntry", () => {
expect(entry?.displayMode).toBe("side-by-side");
expect(entry?.Settings).toBeDefined();
});
it("returns entry for beautify with live-preview display", () => {
const entry = getToolRegistryEntry("beautify");
expect(entry).toBeDefined();
expect(entry?.displayMode).toBe("live-preview");
expect(entry?.livePreview).toBe(true);
});
it("returns entry for meme-generator with ResultsPanel", () => {
const entry = getToolRegistryEntry("meme-generator");
expect(entry).toBeDefined();
expect(entry?.displayMode).toBe("no-dropzone");
expect(entry?.ResultsPanel).toBeDefined();
});
it("returns entry for color-blindness", () => {
const entry = getToolRegistryEntry("color-blindness");
expect(entry).toBeDefined();
expect(entry?.displayMode).toBe("before-after");
});
it("no duplicate tool IDs in registry", () => {
const ids = [...toolRegistry.keys()];
const unique = new Set(ids);
expect(ids.length).toBe(unique.size);
});
});
// ==========================================================================