mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-24 11:39:31 -05:00
418 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|