Files
formbricks/apps/web/modules/response-pipeline/lib/telemetry.test.ts
T
2026-04-17 14:33:21 +00:00

418 lines
15 KiB
TypeScript

import { IntegrationType } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { sendTelemetryEvents } from "./telemetry";
// Mock dependencies
vi.mock("@formbricks/cache", () => ({
getCacheService: vi.fn(),
createCacheKey: {
custom: vi.fn((_namespace: string, key: string) => key),
},
}));
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findFirst: vi.fn(),
count: vi.fn(),
},
user: { count: vi.fn() },
team: { count: vi.fn() },
workspace: { count: vi.fn() },
survey: { count: vi.fn() },
response: {
count: vi.fn(),
findFirst: vi.fn(),
},
display: { count: vi.fn() },
contact: { count: vi.fn() },
segment: { count: vi.fn() },
integration: { findMany: vi.fn() },
account: { findMany: vi.fn() },
$queryRaw: vi.fn(),
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("@/lib/env", () => ({
env: {
SMTP_HOST: "smtp.example.com",
S3_BUCKET_NAME: "my-bucket",
PROMETHEUS_ENABLED: true,
RECAPTCHA_SITE_KEY: "site-key",
RECAPTCHA_SECRET_KEY: "secret-key",
GITHUB_ID: "github-id",
SAML_DATABASE_URL: "postgresql://saml.example.com/formbricks",
ENTERPRISE_LICENSE_KEY: "test-license-key",
},
}));
vi.mock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: false,
}));
vi.mock("@/lib/hash-string", () => ({
hashString: vi.fn((s: string) => `hashed-${s}`),
}));
vi.mock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn(),
}));
// Mock fetch
const fetchMock = vi.fn();
globalThis.fetch = fetchMock;
const mockCacheService = {
get: vi.fn(),
set: vi.fn(),
tryLock: vi.fn(),
del: vi.fn(),
};
describe("sendTelemetryEvents", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers();
// Set a fixed time far in the past to ensure we can always send telemetry
vi.setSystemTime(new Date("2024-01-01T00:00:00.000Z"));
// Setup default cache behavior
vi.mocked(getCacheService).mockResolvedValue({
ok: true,
data: mockCacheService as any,
});
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
mockCacheService.get.mockResolvedValue({ ok: true, data: null }); // No last sent time
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
// Setup default prisma behavior
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
id: "org-123",
createdAt: new Date("2023-01-01"),
} as any);
// Mock raw SQL query for counts (batched query)
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{
organizationCount: BigInt(1),
userCount: BigInt(5),
teamCount: BigInt(2),
workspaceCount: BigInt(3),
surveyCount: BigInt(10),
inProgressSurveyCount: BigInt(4),
completedSurveyCount: BigInt(6),
responseCountAllTime: BigInt(100),
responseCountSinceLastUpdate: BigInt(10),
displayCount: BigInt(50),
contactCount: BigInt(20),
segmentCount: BigInt(4),
newestResponseAt: new Date("2024-01-01T00:00:00.000Z"),
},
] as any);
// Mock other queries
vi.mocked(prisma.integration.findMany).mockResolvedValue([{ type: IntegrationType.notion }] as any);
vi.mocked(prisma.account.findMany).mockResolvedValue([{ provider: "github" }] as any);
fetchMock.mockResolvedValue({ ok: true });
});
afterEach(() => {
vi.useRealTimers();
});
test("should send telemetry successfully when conditions are met", async () => {
await sendTelemetryEvents();
// Check lock acquisition
expect(mockCacheService.tryLock).toHaveBeenCalledWith(
"telemetry_lock",
"locked",
60 * 1000 // 1 minute TTL
);
// Check data gathering
expect(prisma.organization.findFirst).toHaveBeenCalled();
expect(prisma.$queryRaw).toHaveBeenCalled();
// Check fetch call
expect(fetchMock).toHaveBeenCalledTimes(1);
const payload = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(payload.organizationCount).toBe(1);
expect(payload.userCount).toBe(5);
expect(payload.integrations.notion).toBe(true);
expect(payload.sso.github).toBe(true);
expect(payload.sso.saml).toBe(true);
// Check cache update (no TTL parameter)
expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String));
// Check lock release
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
});
test("should skip if in-memory check fails", async () => {
// Run once to set nextTelemetryCheck
await sendTelemetryEvents();
vi.clearAllMocks();
// Run again immediately (should fail in-memory check)
await sendTelemetryEvents();
expect(getCacheService).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
test("should skip if Redis last sent time is recent", async () => {
// Mock last sent time as recent
const recentTime = Date.now() - 1000 * 60 * 60; // 1 hour ago
mockCacheService.get.mockResolvedValue({ ok: true, data: String(recentTime) });
await sendTelemetryEvents();
expect(mockCacheService.tryLock).not.toHaveBeenCalled(); // No lock attempt
expect(fetchMock).not.toHaveBeenCalled();
});
test("should skip if lock cannot be acquired", async () => {
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: false }); // Lock not acquired
await sendTelemetryEvents();
expect(fetchMock).not.toHaveBeenCalled();
expect(mockCacheService.del).not.toHaveBeenCalled(); // Shouldn't try to delete lock we didn't acquire
});
test("should handle cache service failure gracefully", async () => {
vi.mocked(getCacheService).mockResolvedValue({
ok: false,
error: new Error("Cache error"),
} as any);
await sendTelemetryEvents();
expect(fetchMock).not.toHaveBeenCalled();
// Should verify that nextTelemetryCheck was updated, but it's a module variable.
// We can infer it by running again and checking calls
vi.clearAllMocks();
await sendTelemetryEvents();
expect(getCacheService).not.toHaveBeenCalled(); // Should be blocked by in-memory check
});
test("should handle telemetry send failure and apply cooldown", async () => {
// Reset module to clear nextTelemetryCheck state from previous tests
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: false,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
// Ensure we can acquire lock by setting last sent time far in the past
const oldTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
mockCacheService.get.mockResolvedValue({ ok: true, data: String(oldTime) });
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
// Make fetch fail to trigger the catch block
const networkError = new Error("Network error");
fetchMock.mockRejectedValue(networkError);
await freshSendTelemetryEvents();
// Verify lock was acquired
expect(mockCacheService.tryLock).toHaveBeenCalledWith("telemetry_lock", "locked", 60 * 1000);
// The error should be caught in the inner catch block
// The actual implementation logs as warning, not error
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
error: networkError,
message: "Network error",
hashedLicenseKey: "hashed-test-license-key",
}),
"Failed to send telemetry - applying 1h cooldown"
);
// Lock should be released in finally block
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
// Cache should not be updated on failure
expect(mockCacheService.set).not.toHaveBeenCalled();
// Verify cooldown: run again immediately (should be blocked by in-memory check)
vi.clearAllMocks();
mockCacheService.get.mockResolvedValue({ ok: true, data: null });
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true });
await freshSendTelemetryEvents();
expect(getCacheService).not.toHaveBeenCalled(); // Should be blocked by in-memory check
});
test("should skip if no organization exists", async () => {
// Reset module to clear nextTelemetryCheck state from previous tests
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: false,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
// Ensure we can acquire lock by setting last sent time far in the past
const oldTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
// Re-setup mocks after resetModules
vi.mocked(getCacheService).mockResolvedValue({
ok: true,
data: mockCacheService as any,
});
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); // Lock acquired
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
mockCacheService.get.mockResolvedValue({ ok: true, data: String(oldTime) });
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
await freshSendTelemetryEvents();
// sendTelemetry returns early when no org exists
// Since it returns (not throws), the try block completes successfully
// Then cache.set is called, and finally block executes
expect(fetchMock).not.toHaveBeenCalled();
// Verify lock was acquired (prerequisite for finally block to execute)
expect(mockCacheService.tryLock).toHaveBeenCalledWith("telemetry_lock", "locked", 60 * 1000);
// Lock should be released in finally block
expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]);
// Note: The current implementation calls cache.set even when no org exists
// This might be a bug, but we test the actual behavior
expect(mockCacheService.set).toHaveBeenCalled();
});
test("should skip telemetry when TELEMETRY_DISABLED is true and no active EE license", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: true,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
await freshSendTelemetryEvents();
// Should return early without touching cache or sending telemetry
expect(getCacheService).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
test("should send telemetry when TELEMETRY_DISABLED is true but EE license is active", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: true,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
// Re-setup mocks after resetModules
vi.mocked(getCacheService).mockResolvedValue({
ok: true,
data: mockCacheService as any,
});
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true });
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
mockCacheService.get.mockResolvedValue({ ok: true, data: null });
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
id: "org-123",
createdAt: new Date("2023-01-01"),
} as any);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{
organizationCount: BigInt(1),
userCount: BigInt(5),
teamCount: BigInt(2),
projectCount: BigInt(3),
surveyCount: BigInt(10),
inProgressSurveyCount: BigInt(4),
completedSurveyCount: BigInt(6),
responseCountAllTime: BigInt(100),
responseCountSinceLastUpdate: BigInt(10),
displayCount: BigInt(50),
contactCount: BigInt(20),
segmentCount: BigInt(4),
newestResponseAt: new Date("2024-01-01T00:00:00.000Z"),
},
] as any);
vi.mocked(prisma.integration.findMany).mockResolvedValue([{ type: IntegrationType.notion }] as any);
vi.mocked(prisma.account.findMany).mockResolvedValue([{ provider: "github" }] as any);
fetchMock.mockResolvedValue({ ok: true });
await freshSendTelemetryEvents();
// EE license active — telemetry should bypass TELEMETRY_DISABLED and send
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("should unconditionally skip when E2E_TESTING is true even with active EE license", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: true,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: false,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
await freshSendTelemetryEvents();
// E2E_TESTING is a hard skip — no EE bypass, no cache, no fetch
expect(getCacheService).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
test("should unconditionally skip when IS_DEVELOPMENT is true", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: true,
TELEMETRY_DISABLED: false,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
await freshSendTelemetryEvents();
expect(getCacheService).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
});