mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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" })]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
|
||||
Reference in New Issue
Block a user