From 4f0fbade6d0ef1cf1c292c160ace70ec13654807 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 9 May 2026 07:35:27 +0800 Subject: [PATCH] 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). --- tests/unit/api/analytics.test.ts | 309 ++++++++++ tests/unit/api/auth-functions.test.ts | 104 ++++ tests/unit/api/auto-orient.test.ts | 105 ++++ tests/unit/api/bg-effects.test.ts | 450 ++++++++++++++ tests/unit/api/cleanup.test.ts | 230 +++++++- tests/unit/api/errors.test.ts | 36 ++ tests/unit/api/exiftool.test.ts | 364 ++++++++++++ tests/unit/api/file-storage.test.ts | 191 ++++++ tests/unit/api/filename.test.ts | 142 +++++ tests/unit/api/format-decoders.test.ts | 225 +++++++ tests/unit/api/format-encoders.test.ts | 139 +++++ tests/unit/api/heic-converter.test.ts | 113 ++++ tests/unit/api/permissions-extended.test.ts | 280 +++++++++ tests/unit/api/static-upload.test.ts | 169 ++++++ tests/unit/api/svg-sanitize.test.ts | 208 +++++++ tests/unit/api/timeout.test.ts | 124 ++++ tests/unit/api/tool-factory.test.ts | 173 ++++++ tests/unit/web/editor-store.test.ts | 21 +- tests/unit/web/features-store.test.ts | 580 ++++++++++++++++++ tests/unit/web/meme-store.test.ts | 623 ++++++++++++++++++++ tests/unit/web/tool-registry.test.ts | 67 +++ 21 files changed, 4622 insertions(+), 31 deletions(-) create mode 100644 tests/unit/api/analytics.test.ts create mode 100644 tests/unit/api/auth-functions.test.ts create mode 100644 tests/unit/api/auto-orient.test.ts create mode 100644 tests/unit/api/bg-effects.test.ts create mode 100644 tests/unit/api/errors.test.ts create mode 100644 tests/unit/api/exiftool.test.ts create mode 100644 tests/unit/api/file-storage.test.ts create mode 100644 tests/unit/api/filename.test.ts create mode 100644 tests/unit/api/format-encoders.test.ts create mode 100644 tests/unit/api/heic-converter.test.ts create mode 100644 tests/unit/api/permissions-extended.test.ts create mode 100644 tests/unit/api/static-upload.test.ts create mode 100644 tests/unit/api/svg-sanitize.test.ts create mode 100644 tests/unit/api/timeout.test.ts create mode 100644 tests/unit/api/tool-factory.test.ts create mode 100644 tests/unit/web/features-store.test.ts create mode 100644 tests/unit/web/meme-store.test.ts diff --git a/tests/unit/api/analytics.test.ts b/tests/unit/api/analytics.test.ts new file mode 100644 index 00000000..72a2d87a --- /dev/null +++ b/tests/unit/api/analytics.test.ts @@ -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[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[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[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[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[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[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[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[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[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[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[0]; + expect(() => mod.trackEvent(fakeRequest, "test_event", { key: "value" })).not.toThrow(); + }); +}); diff --git a/tests/unit/api/auth-functions.test.ts b/tests/unit/api/auth-functions.test.ts new file mode 100644 index 00000000..8d8e2e32 --- /dev/null +++ b/tests/unit/api/auth-functions.test.ts @@ -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}$/); + } + }); +}); diff --git a/tests/unit/api/auto-orient.test.ts b/tests/unit/api/auto-orient.test.ts new file mode 100644 index 00000000..c9788bbb --- /dev/null +++ b/tests/unit/api/auto-orient.test.ts @@ -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 { + 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); + }); +}); diff --git a/tests/unit/api/bg-effects.test.ts b/tests/unit/api/bg-effects.test.ts new file mode 100644 index 00000000..615a98d1 --- /dev/null +++ b/tests/unit/api/bg-effects.test.ts @@ -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 { + 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 { + 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 { + 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); + }); +}); diff --git a/tests/unit/api/cleanup.test.ts b/tests/unit/api/cleanup.test.ts index d2beb874..a7ec0935 100644 --- a/tests/unit/api/cleanup.test.ts +++ b/tests/unit/api/cleanup.test.ts @@ -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 { + 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(); + }); +}); diff --git a/tests/unit/api/errors.test.ts b/tests/unit/api/errors.test.ts new file mode 100644 index 00000000..0d4adfe0 --- /dev/null +++ b/tests/unit/api/errors.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/api/exiftool.test.ts b/tests/unit/api/exiftool.test.ts new file mode 100644 index 00000000..c17489e3 --- /dev/null +++ b/tests/unit/api/exiftool.test.ts @@ -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); + }); +}); diff --git a/tests/unit/api/file-storage.test.ts b/tests/unit/api/file-storage.test.ts new file mode 100644 index 00000000..00ea54b3 --- /dev/null +++ b/tests/unit/api/file-storage.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/api/filename.test.ts b/tests/unit/api/filename.test.ts new file mode 100644 index 00000000..278eb295 --- /dev/null +++ b/tests/unit/api/filename.test.ts @@ -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}`); + } + }); +}); diff --git a/tests/unit/api/format-decoders.test.ts b/tests/unit/api/format-decoders.test.ts index 2110eb52..344f425e 100644 --- a/tests/unit/api/format-decoders.test.ts +++ b/tests/unit/api/format-decoders.test.ts @@ -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/); + }); }); diff --git a/tests/unit/api/format-encoders.test.ts b/tests/unit/api/format-encoders.test.ts new file mode 100644 index 00000000..24f5b0fd --- /dev/null +++ b/tests/unit/api/format-encoders.test.ts @@ -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 { + 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); + }); +}); diff --git a/tests/unit/api/heic-converter.test.ts b/tests/unit/api/heic-converter.test.ts new file mode 100644 index 00000000..647e7737 --- /dev/null +++ b/tests/unit/api/heic-converter.test.ts @@ -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); + }); +}); diff --git a/tests/unit/api/permissions-extended.test.ts b/tests/unit/api/permissions-extended.test.ts new file mode 100644 index 00000000..e2486d93 --- /dev/null +++ b/tests/unit/api/permissions-extended.test.ts @@ -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 & { 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(); + }); +}); diff --git a/tests/unit/api/static-upload.test.ts b/tests/unit/api/static-upload.test.ts new file mode 100644 index 00000000..62db5faa --- /dev/null +++ b/tests/unit/api/static-upload.test.ts @@ -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; + return { + ...actual, + existsSync: vi.fn(() => true), + }; +}); + +describe("registerStatic", () => { + let registerStatic: typeof import("../../../apps/api/src/plugins/static.js").registerStatic; + let existsSyncMock: ReturnType; + + 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; + }); + + 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, + }, + }); + }); +}); diff --git a/tests/unit/api/svg-sanitize.test.ts b/tests/unit/api/svg-sanitize.test.ts new file mode 100644 index 00000000..f1f3e0ad --- /dev/null +++ b/tests/unit/api/svg-sanitize.test.ts @@ -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( + '', + ); + const result = sanitizeSvg(input).toString("utf-8"); + expect(result).not.toContain("DOCTYPE"); + expect(result).toContain(""); + }); + + it("removes DOCTYPE with internal subset (XXE prevention)", () => { + const input = Buffer.from( + ']>&xxe;', + ); + 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( + '', + ); + const result = sanitizeSvg(input).toString("utf-8"); + expect(result).not.toContain(" { + const input = Buffer.from("evil"); + const result = sanitizeSvg(input).toString("utf-8"); + expect(result).not.toContain("foreignObject"); + expect(result).toContain(""); + }); + + it("removes event handlers (onload, onclick, onerror)", () => { + const input = Buffer.from( + '', + ); + 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('click'); + const result = sanitizeSvg(input).toString("utf-8"); + expect(result).not.toContain("javascript:"); + }); + + it("blocks http/https URLs in href", () => { + const input = Buffer.from(''); + 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(''); + const result = sanitizeSvg(input).toString("utf-8"); + expect(result).not.toContain("http://evil.com"); + }); + + it("blocks data:text/html URIs", () => { + const input = Buffer.from( + 'x', + ); + const result = sanitizeSvg(input).toString("utf-8"); + expect(result).not.toContain("data:text/html"); + }); + + it("blocks file: URIs", () => { + const input = Buffer.from(''); + const result = sanitizeSvg(input).toString("utf-8"); + expect(result).not.toContain("file:///etc/passwd"); + }); + + it("blocks external url() references", () => { + const input = Buffer.from(""); + const result = sanitizeSvg(input).toString("utf-8"); + expect(result).not.toContain("https://evil.com"); + }); + + it("blocks file: url() references", () => { + const input = Buffer.from(''); + const result = sanitizeSvg(input).toString("utf-8"); + expect(result).not.toContain("file:///etc/passwd"); + }); + + it("removes XInclude elements", () => { + const input = Buffer.from( + '', + ); + const result = sanitizeSvg(input).toString("utf-8"); + expect(result).not.toContain("xi:include"); + }); + + it("preserves valid SVG content", () => { + const input = Buffer.from( + '', + ); + const result = sanitizeSvg(input).toString("utf-8"); + expect(result).toContain(" { + 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(''); + 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(''); + const result = sanitizeSvg(input).toString("utf-8"); + expect(result).toContain(" { + it("returns non-gzipped buffer unchanged", () => { + const input = Buffer.from(''); + const result = decompressSvgz(input); + expect(result).toBe(input); + }); + + it("decompresses valid SVGZ content", () => { + const svgContent = + ''; + 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 { + const input = Buffer.from(''); + expect(isSvgBuffer(input)).toBe(true); + }); + + it("returns true for buffer starting with { + const input = Buffer.from(''); + 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 '); + expect(isSvgBuffer(input)).toBe(true); + }); + + it("returns true for { + const input = Buffer.from('\n'); + expect(isSvgBuffer(input)).toBe(true); + }); +}); diff --git a/tests/unit/api/timeout.test.ts b/tests/unit/api/timeout.test.ts new file mode 100644 index 00000000..c27e4a05 --- /dev/null +++ b/tests/unit/api/timeout.test.ts @@ -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); + }); +}); diff --git a/tests/unit/api/tool-factory.test.ts b/tests/unit/api/tool-factory.test.ts new file mode 100644 index 00000000..8072fd37 --- /dev/null +++ b/tests/unit/api/tool-factory.test.ts @@ -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; + 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); + }); + }); +}); diff --git a/tests/unit/web/editor-store.test.ts b/tests/unit/web/editor-store.test.ts index 08a0f0f3..db47c601 100644 --- a/tests/unit/web/editor-store.test.ts +++ b/tests/unit/web/editor-store.test.ts @@ -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); }); }); diff --git a/tests/unit/web/features-store.test.ts b/tests/unit/web/features-store.test.ts new file mode 100644 index 00000000..54c1dd02 --- /dev/null +++ b/tests/unit/web/features-store.test.ts @@ -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 & { 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" })]); + }); + }); +}); diff --git a/tests/unit/web/meme-store.test.ts b/tests/unit/web/meme-store.test.ts new file mode 100644 index 00000000..59ecec1d --- /dev/null +++ b/tests/unit/web/meme-store.test.ts @@ -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(); + }); + }); +}); diff --git a/tests/unit/web/tool-registry.test.ts b/tests/unit/web/tool-registry.test.ts index 9f6091bc..05c3539b 100644 --- a/tests/unit/web/tool-registry.test.ts +++ b/tests/unit/web/tool-registry.test.ts @@ -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); + }); }); // ==========================================================================