import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const mockGetBoolean = vi.fn(); const mockGet = vi.fn(); const mockSet = vi.fn(); const mockPost = vi.fn(); function makeChain(resolveValue: unknown) { const chain: Record = {}; const methods = ["from", "where", "groupBy"]; for (const m of methods) { chain[m] = vi.fn(() => chain); } (chain as unknown as Promise).then = (cb: (v: unknown) => unknown) => Promise.resolve(resolveValue).then(cb); return chain; } vi.mock("../../database/repositories/factory.js", () => ({ createCurrentSettingsRepository: () => ({ getBoolean: mockGetBoolean, get: mockGet, set: mockSet, }), createCurrentRepositoryContext: () => ({ drizzle: { select: vi.fn(() => makeChain([{ count: 0 }])), }, }), })); vi.mock("../../database/db/schema.js", () => ({ users: {}, hosts: {}, recentActivity: { type: "type", timestamp: "timestamp" }, })); vi.mock("../../utils/logger.js", () => ({ Logger: class { info = vi.fn(); warn = vi.fn(); error = vi.fn(); }, })); vi.mock("axios", () => ({ default: { post: mockPost }, })); describe("analytics", () => { const originalEnv = { ...process.env }; beforeEach(() => { vi.clearAllMocks(); process.env = { ...originalEnv }; delete process.env.ENABLE_TELEMETRY; }); afterEach(() => { process.env = { ...originalEnv }; }); it("isAnalyticsEnabled defaults to true via the settings repository", async () => { delete process.env.ENABLE_TELEMETRY; mockGetBoolean.mockResolvedValue(true); const { isAnalyticsEnabled } = await import("../../utils/analytics.js"); const result = await isAnalyticsEnabled(); expect(result).toBe(true); expect(mockGetBoolean).toHaveBeenCalledWith("analytics_enabled", true); }); it("ENABLE_TELEMETRY=false disables analytics without consulting the database", async () => { process.env.ENABLE_TELEMETRY = "false"; const { isAnalyticsEnabled } = await import("../../utils/analytics.js"); const result = await isAnalyticsEnabled(); expect(result).toBe(false); expect(mockGetBoolean).not.toHaveBeenCalled(); }); it("ENABLE_TELEMETRY=true forces analytics on without consulting the database", async () => { process.env.ENABLE_TELEMETRY = "TRUE"; const { isAnalyticsEnabled } = await import("../../utils/analytics.js"); const result = await isAnalyticsEnabled(); expect(result).toBe(true); expect(mockGetBoolean).not.toHaveBeenCalled(); }); it("getTelemetryEnvOverride returns null when unset or blank", async () => { const { getTelemetryEnvOverride } = await import("../../utils/analytics.js"); delete process.env.ENABLE_TELEMETRY; expect(getTelemetryEnvOverride()).toBe(null); process.env.ENABLE_TELEMETRY = " "; expect(getTelemetryEnvOverride()).toBe(null); }); it("startAnalyticsHeartbeat sends nothing when ENABLE_TELEMETRY=false", async () => { process.env.ENABLE_TELEMETRY = "false"; process.env.POSTHOG_API_KEY = "phc_test"; const { startAnalyticsHeartbeat } = await import("../../utils/analytics.js"); startAnalyticsHeartbeat(); await Promise.resolve(); expect(mockPost).not.toHaveBeenCalled(); }); it("collectAndSendHeartbeat does not call PostHog when ENABLE_TELEMETRY=false", async () => { process.env.ENABLE_TELEMETRY = "false"; process.env.POSTHOG_API_KEY = "phc_test"; const { collectAndSendHeartbeat } = await import("../../utils/analytics.js"); await collectAndSendHeartbeat(); expect(mockPost).not.toHaveBeenCalled(); }); it("getOrCreateInstanceId returns the existing id without generating one", async () => { mockGet.mockResolvedValue("existing-id"); const { getOrCreateInstanceId } = await import("../../utils/analytics.js"); const id = await getOrCreateInstanceId(); expect(id).toBe("existing-id"); expect(mockSet).not.toHaveBeenCalled(); }); it("getOrCreateInstanceId generates and persists a new id when absent", async () => { mockGet.mockResolvedValue(null); const { getOrCreateInstanceId } = await import("../../utils/analytics.js"); const id = await getOrCreateInstanceId(); expect(id).toMatch(/^[0-9a-f-]{36}$/); expect(mockSet).toHaveBeenCalledWith("analytics_instance_id", id); }); it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => { delete process.env.POSTHOG_API_KEY; const { collectAndSendHeartbeat } = await import("../../utils/analytics.js"); await collectAndSendHeartbeat(); expect(mockPost).not.toHaveBeenCalled(); }); it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => { process.env.POSTHOG_API_KEY = "phc_test"; mockGetBoolean.mockResolvedValue(false); const { collectAndSendHeartbeat } = await import("../../utils/analytics.js"); await collectAndSendHeartbeat(); expect(mockPost).not.toHaveBeenCalled(); }); it("collectAndSendHeartbeat posts a heartbeat event with the expected shape when enabled", async () => { process.env.POSTHOG_API_KEY = "phc_test"; mockGetBoolean.mockResolvedValue(true); mockGet.mockResolvedValue("instance-123"); mockPost.mockResolvedValue({}); const { collectAndSendHeartbeat } = await import("../../utils/analytics.js"); await collectAndSendHeartbeat(); expect(mockPost).toHaveBeenCalledTimes(1); const [url, body] = mockPost.mock.calls[0]; expect(url).toContain("/capture/"); expect(body).toMatchObject({ api_key: "phc_test", event: "instance_heartbeat", distinct_id: "instance-123", properties: expect.objectContaining({ user_count: 0, host_count: 0, used_terminal: 0, }), }); }); });