import { beforeEach, describe, expect, it, vi } from "vitest"; const bakedConfig = vi.hoisted(() => ({ enabled: false, posthogApiKey: "", posthogHost: "", sentryDsn: "", sampleRate: 1.0, })); 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("@snapotter/shared", async (importOriginal) => { const actual: Record = await importOriginal(); return { ...actual, ANALYTICS_BAKED: bakedConfig, }; }); vi.mock("../../../apps/api/src/db/index.js", () => ({ db: { select: () => ({ from: () => ({ where: () => Promise.resolve([]), }), }), }, pool: {}, closeDb: async () => {}, schema: { settings: { key: "key" }, }, })); vi.mock("drizzle-orm", () => ({ eq: () => "mocked-eq", })); vi.mock("posthog-node", () => ({ PostHog: MockPostHog, })); vi.mock("@sentry/node", () => ({ init: mockSentryInit, captureException: mockSentryCapture, close: mockSentryClose, })); type AnalyticsModule = typeof import("../../../apps/api/src/lib/analytics.js"); let mod: AnalyticsModule; beforeEach(async () => { bakedConfig.enabled = false; bakedConfig.posthogApiKey = ""; bakedConfig.posthogHost = ""; bakedConfig.sentryDsn = ""; bakedConfig.sampleRate = 1.0; 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_BAKED.enabled is false", async () => { bakedConfig.enabled = false; await expect(mod.initAnalytics()).resolves.toBeUndefined(); expect(MockPostHog).not.toHaveBeenCalled(); }); it("does nothing when enabled is true but posthogApiKey is empty", async () => { bakedConfig.enabled = true; bakedConfig.posthogApiKey = ""; await expect(mod.initAnalytics()).resolves.toBeUndefined(); expect(MockPostHog).not.toHaveBeenCalled(); }); it("initializes posthog when enabled with API key", async () => { bakedConfig.enabled = true; bakedConfig.posthogApiKey = "phc_test_key"; bakedConfig.posthogHost = "https://test.posthog.com"; await mod.initAnalytics(); expect(MockPostHog).toHaveBeenCalledWith("phc_test_key", { host: "https://test.posthog.com", flushAt: 20, flushInterval: 30000, }); }); it("does not initialize sentry (moved to preload)", async () => { bakedConfig.enabled = true; bakedConfig.posthogApiKey = "phc_test_key"; bakedConfig.sentryDsn = "https://test@sentry.io/123"; await mod.initAnalytics(); expect(mockSentryInit).not.toHaveBeenCalled(); }); }); describe("captureException", () => { it("does nothing when sentryModule is null", async () => { await expect(mod.captureException(new Error("test"))).resolves.toBeUndefined(); }); it("captures when sentry is initialized", async () => { bakedConfig.enabled = true; bakedConfig.posthogApiKey = "phc_test_key"; bakedConfig.sentryDsn = "https://test@sentry.io/123"; await mod.initAnalytics(); const err = new Error("test error"); await mod.captureException(err); 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 () => { bakedConfig.enabled = true; bakedConfig.posthogApiKey = "phc_test_key"; await mod.initAnalytics(); await mod.shutdownAnalytics(); expect(mockShutdown).toHaveBeenCalled(); }); it("does not close sentry (moved to preload)", async () => { bakedConfig.enabled = true; bakedConfig.posthogApiKey = "phc_test_key"; bakedConfig.sentryDsn = "https://test@sentry.io/123"; await mod.initAnalytics(); await mod.shutdownAnalytics(); expect(mockSentryClose).not.toHaveBeenCalled(); }); }); describe("trackEvent", () => { it("does nothing when posthogClient is null", async () => { await expect(mod.trackEvent("test_event", { key: "value" })).resolves.toBeUndefined(); }); it("does nothing when ANALYTICS_BAKED.enabled is false", async () => { bakedConfig.enabled = false; await expect(mod.trackEvent("test_event", { key: "value" })).resolves.toBeUndefined(); }); it("does nothing when sampleRate is 0", async () => { bakedConfig.enabled = true; bakedConfig.posthogApiKey = "phc_test_key"; bakedConfig.sampleRate = 0; await mod.initAnalytics(); await mod.trackEvent("test_event", { key: "value" }); expect(mockCapture).not.toHaveBeenCalled(); }); it("captures event with only allow-listed properties when enabled", async () => { bakedConfig.enabled = true; bakedConfig.posthogApiKey = "phc_test_key"; bakedConfig.sampleRate = 1.0; await mod.initAnalytics(); await mod.trackEvent("tool_used", { tool_id: "resize", status: "completed", error_message: "secret detail", }); expect(mockCapture).toHaveBeenCalledWith({ distinctId: "unknown", event: "tool_used", // error_message is free-text and never allow-listed. properties: { tool_id: "resize", status: "completed" }, }); }); it("uses provided distinctId when given", async () => { bakedConfig.enabled = true; bakedConfig.posthogApiKey = "phc_test_key"; bakedConfig.sampleRate = 1.0; await mod.initAnalytics(); await mod.trackEvent("tool_used", { tool: "crop" }, "custom-id-123"); expect(mockCapture).toHaveBeenCalledWith( expect.objectContaining({ distinctId: "custom-id-123", }), ); }); it("does not throw when capture throws internally", async () => { bakedConfig.enabled = true; bakedConfig.posthogApiKey = "phc_test_key"; bakedConfig.sampleRate = 1.0; await mod.initAnalytics(); mockCapture.mockImplementationOnce(() => { throw new Error("capture failed"); }); await expect(mod.trackEvent("test_event", { key: "value" })).resolves.toBeUndefined(); }); }); describe("captureFeedback", () => { it("captures feedback_submitted with explicit feedback properties", async () => { bakedConfig.enabled = true; bakedConfig.posthogApiKey = "phc_test_key"; await mod.initAnalytics(); await mod.captureFeedback( { source: "admin_installer", survey_id: "admin-install-v1", prompt_variant: "settings-card-v1", sentiment: "issue", feedback_type: "bug", message: "Docs need a complete S3 example.", contact_ok: true, contact_email: "admin@example.com", contact_name: "Pat", company: "Example Co", install_method: "docker_compose", usage_type: "team_internal", friction_area: "environment_variables", important_areas: ["pdf_docs", "batch_workflows"], error_category: "processing_error", }, "distinct-feedback", ); expect(mockCapture).toHaveBeenCalledWith({ distinctId: "distinct-feedback", event: "feedback_submitted", properties: expect.objectContaining({ feedback_version: 1, source: "admin_installer", survey_id: "admin-install-v1", prompt_variant: "settings-card-v1", contact_ok: true, contact_email: "admin@example.com", install_method: "docker_compose", usage_type: "team_internal", friction_area: "environment_variables", important_areas: ["pdf_docs", "batch_workflows"], error_category: "processing_error", }), }); }); it("forwards search_query for a search_miss request", async () => { bakedConfig.enabled = true; bakedConfig.posthogApiKey = "phc_test_key"; await mod.initAnalytics(); await mod.captureFeedback( { source: "search_miss", survey_id: "search-miss-v1", prompt_variant: "search-empty-v1", feedback_type: "feature_request", search_query: "convert to dicom", contact_ok: false, }, "distinct-search-miss", ); expect(mockCapture).toHaveBeenCalledWith({ distinctId: "distinct-search-miss", event: "feedback_submitted", properties: expect.objectContaining({ source: "search_miss", survey_id: "search-miss-v1", prompt_variant: "search-empty-v1", feedback_type: "feature_request", search_query: "convert to dicom", }), }); }); it("does nothing when analytics is disabled", async () => { bakedConfig.enabled = false; await mod.captureFeedback({ source: "global", contact_ok: false, message: "A message", }); expect(mockCapture).not.toHaveBeenCalled(); }); });