Compare commits

..

1 Commits

Author SHA1 Message Date
Dhruwang
626b40e070 refactor: removed i18n-utils dependency from surveys package 2026-02-06 18:38:28 +05:30
9 changed files with 405 additions and 287 deletions

View File

@@ -24,7 +24,6 @@ const mockCache = {
set: vi.fn(),
del: vi.fn(),
exists: vi.fn(),
tryLock: vi.fn(),
withCache: vi.fn(),
getRedisClient: vi.fn(),
};
@@ -33,47 +32,12 @@ vi.mock("@/lib/cache", () => ({
cache: mockCache,
}));
// Helper to set up cache mocks for the new distributed lock flow
const setupCacheMocksForLockFlow = (options: {
cachedLicense?: TEnterpriseLicenseDetails | null;
previousResult?: { active: boolean; features: unknown; lastChecked: Date } | null;
lockAcquired?: boolean;
}) => {
const { cachedLicense, previousResult = null, lockAcquired = true } = options;
// cache.get: returns cached license on status key, previous result on previous_result key
mockCache.get.mockImplementation(async (key: string) => {
if (key.includes(":status")) {
return { ok: true, data: cachedLicense ?? null };
}
if (key.includes(":previous_result")) {
return { ok: true, data: previousResult };
}
return { ok: true, data: null };
});
// cache.exists: for distinguishing cache miss from cached null
mockCache.exists.mockImplementation(async (key: string) => {
if (key.includes(":status") && cachedLicense !== undefined) {
return { ok: true, data: true };
}
return { ok: true, data: false };
});
// cache.tryLock: simulate lock acquisition
mockCache.tryLock.mockResolvedValue({ ok: true, data: lockAcquired });
// cache.set: success by default
mockCache.set.mockResolvedValue({ ok: true });
};
// Mock the createCacheKey functions
vi.mock("@formbricks/cache", () => ({
createCacheKey: {
license: {
status: (identifier: string) => `fb:license:${identifier}:status`,
previous_result: (identifier: string) => `fb:license:${identifier}:previous_result`,
fetch_lock: (identifier: string) => `fb:license:${identifier}:fetch_lock`,
},
custom: (namespace: string, identifier: string, subResource?: string) => {
const base = `fb:${namespace}:${identifier}`;
@@ -130,13 +94,10 @@ describe("License Core Logic", () => {
beforeEach(() => {
originalProcessEnv = { ...process.env };
// Reset all mocks
vi.resetAllMocks();
mockCache.get.mockReset();
mockCache.set.mockReset();
mockCache.del.mockReset();
mockCache.exists.mockReset();
mockCache.tryLock.mockReset();
mockCache.withCache.mockReset();
mockLogger.error.mockReset();
mockLogger.warn.mockReset();
@@ -144,10 +105,7 @@ describe("License Core Logic", () => {
mockLogger.debug.mockReset();
// Set up default mock implementations for Result types
// Default: cache miss (no cached license), lock acquired, no previous result
mockCache.get.mockResolvedValue({ ok: true, data: null });
mockCache.exists.mockResolvedValue({ ok: true, data: false });
mockCache.tryLock.mockResolvedValue({ ok: true, data: true });
mockCache.set.mockResolvedValue({ ok: true });
mockCache.withCache.mockImplementation(async (fn) => await fn());
@@ -161,7 +119,7 @@ describe("License Core Logic", () => {
instanceId: "test-hashed-instance-id",
createdAt: new Date("2024-01-01"),
});
vi.clearAllMocks();
// Mock window to be undefined for server-side tests
vi.stubGlobal("window", undefined);
});
@@ -206,14 +164,16 @@ describe("License Core Logic", () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Set up cache to return cached license (cache hit)
setupCacheMocksForLockFlow({ cachedLicense: mockFetchedLicenseDetails });
// Mock cache.withCache to return cached license details (simulating cache hit)
mockCache.withCache.mockResolvedValue(mockFetchedLicenseDetails);
const license = await getEnterpriseLicense();
expect(license).toEqual(expectedActiveLicenseState);
// Should have checked cache but NOT acquired lock or called fetch
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.tryLock).not.toHaveBeenCalled();
expect(mockCache.withCache).toHaveBeenCalledWith(
expect.any(Function),
expect.stringContaining("fb:license:"),
expect.any(Number)
);
expect(fetch).not.toHaveBeenCalled();
});
@@ -221,8 +181,8 @@ describe("License Core Logic", () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Set up cache miss: no cached license, lock acquired
setupCacheMocksForLockFlow({ cachedLicense: undefined, lockAcquired: true });
// Mock cache.withCache to execute the function (simulating cache miss)
mockCache.withCache.mockImplementation(async (fn) => await fn());
fetch.mockResolvedValueOnce({
ok: true,
@@ -232,18 +192,19 @@ describe("License Core Logic", () => {
const license = await getEnterpriseLicense();
expect(fetch).toHaveBeenCalledTimes(1);
// Should have tried to acquire lock and set the cache
expect(mockCache.tryLock).toHaveBeenCalled();
expect(mockCache.set).toHaveBeenCalled();
expect(mockCache.withCache).toHaveBeenCalledWith(
expect.any(Function),
expect.stringContaining("fb:license:"),
expect.any(Number)
);
expect(license).toEqual(expectedActiveLicenseState);
});
test("should handle grace period logic when previous result exists", async () => {
// This test verifies the grace period fallback behavior.
// Due to vitest module caching, full mock verification is limited.
test("should use previous result if fetch fails and previous result exists and is within grace period", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago, within grace period
const mockPreviousResult = {
active: true,
features: { removeBranding: true, projects: 5 },
@@ -251,32 +212,37 @@ describe("License Core Logic", () => {
version: 1,
};
// Set up cache miss for license, but have previous result available
mockCache.get.mockImplementation(async (key: string) => {
// Mock cache.withCache to return null (simulating fetch failure)
mockCache.withCache.mockResolvedValue(null);
// Mock cache.get to return previous result when requested
mockCache.get.mockImplementation(async (key) => {
if (key.includes(":previous_result")) {
return { ok: true, data: mockPreviousResult };
}
return { ok: true, data: null };
});
mockCache.exists.mockResolvedValue({ ok: true, data: false });
mockCache.tryLock.mockResolvedValue({ ok: true, data: true });
mockCache.set.mockResolvedValue({ ok: true });
const fetch = (await import("node-fetch")).default as Mock;
fetch.mockResolvedValueOnce({ ok: false, status: 500 } as any);
const license = await getEnterpriseLicense();
// Should return some license state (exact values depend on mock application)
expect(license).toHaveProperty("active");
expect(license).toHaveProperty("fallbackLevel");
expect(mockCache.withCache).toHaveBeenCalled();
expect(license).toEqual({
active: true,
features: mockPreviousResult.features,
lastChecked: previousTime,
isPendingDowngrade: true,
fallbackLevel: "grace" as const,
status: "unreachable" as const,
});
});
test("should handle expired grace period when previous result is too old", async () => {
// This test verifies behavior when previous result is outside grace period.
test("should return inactive and set new previousResult if fetch fails and previous result is outside grace period", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
const previousTime = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // 5 days ago
const previousTime = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // 5 days ago, outside grace period
const mockPreviousResult = {
active: true,
features: { removeBranding: true },
@@ -284,33 +250,80 @@ describe("License Core Logic", () => {
version: 1,
};
// Set up cache miss for license, previous result outside grace period
mockCache.get.mockImplementation(async (key: string) => {
// Mock cache.withCache to return null (simulating fetch failure)
mockCache.withCache.mockResolvedValue(null);
// Mock cache.get to return previous result when requested
mockCache.get.mockImplementation(async (key) => {
if (key.includes(":previous_result")) {
return { ok: true, data: mockPreviousResult };
}
return { ok: true, data: null };
});
mockCache.exists.mockResolvedValue({ ok: true, data: false });
mockCache.tryLock.mockResolvedValue({ ok: true, data: true });
mockCache.set.mockResolvedValue({ ok: true });
const fetch = (await import("node-fetch")).default as Mock;
fetch.mockResolvedValueOnce({ ok: false, status: 500 } as any);
const license = await getEnterpriseLicense();
// Should return some license state
expect(license).toHaveProperty("active");
expect(license).toHaveProperty("fallbackLevel");
expect(mockCache.withCache).toHaveBeenCalled();
expect(mockCache.set).toHaveBeenCalledWith(
expect.stringContaining("fb:license:"),
{
active: false,
features: {
isMultiOrgEnabled: false,
projects: 3,
twoFactorAuth: false,
sso: false,
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
saml: false,
spamProtection: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
quotas: false,
},
lastChecked: expect.any(Date),
},
expect.any(Number)
);
expect(license).toEqual({
active: false,
features: {
isMultiOrgEnabled: false,
projects: 3,
twoFactorAuth: false,
sso: false,
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
saml: false,
spamProtection: false,
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
quotas: false,
},
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "unreachable" as const,
});
});
test("should return inactive with default features if fetch fails and no previous result (initial fail)", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Set up cache miss with no previous result
setupCacheMocksForLockFlow({ cachedLicense: undefined, previousResult: null, lockAcquired: true });
// Mock cache.withCache to return null (simulating fetch failure)
mockCache.withCache.mockResolvedValue(null);
// Mock cache.get to return no previous result
mockCache.get.mockResolvedValue({ ok: true, data: null });
fetch.mockRejectedValueOnce(new Error("Network error"));
@@ -331,13 +344,13 @@ describe("License Core Logic", () => {
accessControl: false,
quotas: false,
};
// Should have set previous_result with default features
expect(mockCache.set).toHaveBeenCalledWith(
expect.stringContaining("fb:license:"),
expect.objectContaining({
{
active: false,
features: expectedFeatures,
}),
lastChecked: expect.any(Date),
},
expect.any(Number)
);
expect(license).toEqual({
@@ -355,7 +368,7 @@ describe("License Core Logic", () => {
vi.resetAllMocks();
mockCache.get.mockReset();
mockCache.set.mockReset();
mockCache.tryLock.mockReset();
mockCache.withCache.mockReset();
const fetch = (await import("node-fetch")).default as Mock;
fetch.mockReset();
@@ -382,56 +395,306 @@ describe("License Core Logic", () => {
fallbackLevel: "default" as const,
status: "no-license" as const,
});
// No cache operations should happen when there's no license key
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.set).not.toHaveBeenCalled();
expect(mockCache.tryLock).not.toHaveBeenCalled();
expect(mockCache.withCache).not.toHaveBeenCalled();
});
test("should handle fetch throwing an error gracefully", async () => {
// Set up cache miss with no previous result
setupCacheMocksForLockFlow({ cachedLicense: undefined, previousResult: null, lockAcquired: true });
test("should handle fetch throwing an error and use grace period or return inactive", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Mock cache.withCache to return null (simulating fetch failure)
mockCache.withCache.mockResolvedValue(null);
// Mock cache.get to return no previous result
mockCache.get.mockResolvedValue({ ok: true, data: null });
fetch.mockRejectedValueOnce(new Error("Network error"));
const { getEnterpriseLicense } = await import("./license");
const license = await getEnterpriseLicense();
// Should return inactive license (exact status depends on env configuration)
expect(license.active).toBe(false);
expect(license.isPendingDowngrade).toBe(false);
expect(license.fallbackLevel).toBe("default");
expect(license).toEqual({
active: false,
features: null,
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "no-license" as const,
});
});
});
describe("getLicenseFeatures", () => {
// These tests use dynamic imports which have issues with vitest mocking.
// The core getLicenseFeatures functionality is tested through
// integration tests and the getEnterpriseLicense tests above.
test("should be exported from license module", async () => {
test("should return features if license is active", async () => {
// Set up environment before import
vi.stubGlobal("window", undefined);
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
// Mock cache.withCache to return license details
mockCache.withCache.mockResolvedValue({
status: "active",
features: {
isMultiOrgEnabled: true,
contacts: true,
projects: 5,
whitelabel: true,
removeBranding: true,
twoFactorAuth: true,
sso: true,
saml: true,
spamProtection: true,
ai: true,
auditLogs: true,
},
});
// Import after env and mocks are set
const { getLicenseFeatures } = await import("./license");
expect(typeof getLicenseFeatures).toBe("function");
const features = await getLicenseFeatures();
expect(features).toEqual({
isMultiOrgEnabled: true,
contacts: true,
projects: 5,
whitelabel: true,
removeBranding: true,
twoFactorAuth: true,
sso: true,
saml: true,
spamProtection: true,
ai: true,
auditLogs: true,
});
});
test("should return null if license is inactive", async () => {
const { getLicenseFeatures } = await import("./license");
// Mock cache.withCache to return expired license
mockCache.withCache.mockResolvedValue({ status: "expired", features: null });
const features = await getLicenseFeatures();
expect(features).toBeNull();
});
test("should return null if getEnterpriseLicense throws", async () => {
const { getLicenseFeatures } = await import("./license");
// Mock cache.withCache to throw an error
mockCache.withCache.mockRejectedValue(new Error("Cache error"));
const features = await getLicenseFeatures();
expect(features).toBeNull();
});
});
describe("Cache Key Generation", () => {
test("getCacheKeys should be exported from license module", async () => {
const { getCacheKeys } = await import("./license");
expect(typeof getCacheKeys).toBe("function");
beforeEach(() => {
vi.resetAllMocks();
mockCache.get.mockReset();
mockCache.set.mockReset();
mockCache.del.mockReset();
mockCache.withCache.mockReset();
vi.resetModules();
});
test("should use 'browser' as cache key in browser environment", async () => {
vi.stubGlobal("window", {});
// Set up default mock for cache.withCache
mockCache.withCache.mockImplementation(async (fn) => await fn());
const { getEnterpriseLicense } = await import("./license");
await getEnterpriseLicense();
expect(mockCache.withCache).toHaveBeenCalledWith(
expect.any(Function),
expect.stringContaining("fb:license:browser:status"),
expect.any(Number)
);
});
test("should use 'no-license' as cache key when ENTERPRISE_LICENSE_KEY is not set", async () => {
vi.resetModules();
vi.stubGlobal("window", undefined);
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: undefined,
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
const { getEnterpriseLicense } = await import("./license");
await getEnterpriseLicense();
// The cache should NOT be accessed if there is no license key
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.withCache).not.toHaveBeenCalled();
});
test("should use hashed license key as cache key when ENTERPRISE_LICENSE_KEY is set", async () => {
vi.resetModules();
const testLicenseKey = "test-license-key";
vi.stubGlobal("window", undefined);
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: testLicenseKey,
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
},
}));
// Set up default mock for cache.withCache
mockCache.withCache.mockImplementation(async (fn) => await fn());
const { hashString } = await import("@/lib/hash-string");
const expectedHash = hashString(testLicenseKey);
const { getEnterpriseLicense } = await import("./license");
await getEnterpriseLicense();
expect(mockCache.withCache).toHaveBeenCalledWith(
expect.any(Function),
expect.stringContaining(`fb:license:${expectedHash}:status`),
expect.any(Number)
);
});
});
describe("Error and Warning Logging", () => {
// These tests verify that logging functions are called correctly.
// Due to vitest module caching, these tests may not work reliably
// in isolation. The logging functionality is tested implicitly
// through the other tests.
test("should have logger available for error tracking", async () => {
// Just verify the mockLogger is set up correctly
expect(mockLogger.error).toBeDefined();
expect(mockLogger.warn).toBeDefined();
expect(mockLogger.info).toBeDefined();
expect(mockLogger.debug).toBeDefined();
test("should log warning when setPreviousResult cache.set fails (line 176-178)", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
const mockFetchedLicenseDetails: TEnterpriseLicenseDetails = {
status: "active",
features: {
isMultiOrgEnabled: true,
contacts: true,
projects: 10,
whitelabel: true,
removeBranding: true,
twoFactorAuth: true,
sso: true,
saml: true,
spamProtection: true,
ai: false,
auditLogs: true,
multiLanguageSurveys: true,
accessControl: true,
quotas: true,
},
};
// Mock successful fetch from API
mockCache.withCache.mockResolvedValue(mockFetchedLicenseDetails);
// Mock cache.set to fail when saving previous result
mockCache.set.mockResolvedValue({
ok: false,
error: new Error("Redis connection failed"),
});
await getEnterpriseLicense();
// Verify that the warning was logged
expect(mockLogger.warn).toHaveBeenCalledWith(
{ error: new Error("Redis connection failed") },
"Failed to cache previous result"
);
});
test("should log error when trackApiError is called (line 196-203)", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Mock cache.withCache to execute the function (simulating cache miss)
mockCache.withCache.mockImplementation(async (fn) => await fn());
// Mock API response with 500 status
const mockStatus = 500;
fetch.mockResolvedValueOnce({
ok: false,
status: mockStatus,
json: async () => ({ error: "Internal Server Error" }),
} as any);
await getEnterpriseLicense();
// Verify that the API error was logged with correct structure
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
status: mockStatus,
code: "API_ERROR",
timestamp: expect.any(String),
}),
expect.stringContaining("License API error:")
);
});
test("should log error when trackApiError is called with different status codes (line 196-203)", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
// Test with 403 Forbidden
mockCache.withCache.mockImplementation(async (fn) => await fn());
const mockStatus = 403;
fetch.mockResolvedValueOnce({
ok: false,
status: mockStatus,
json: async () => ({ error: "Forbidden" }),
} as any);
await getEnterpriseLicense();
// Verify that the API error was logged with correct structure
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
status: mockStatus,
code: "API_ERROR",
timestamp: expect.any(String),
}),
expect.stringContaining("License API error:")
);
});
test("should log info when trackFallbackUsage is called during grace period", async () => {
const { getEnterpriseLicense } = await import("./license");
const fetch = (await import("node-fetch")).default as Mock;
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago
const mockPreviousResult = {
active: true,
features: { removeBranding: true, projects: 5 },
lastChecked: previousTime,
version: 1,
};
mockCache.withCache.mockResolvedValue(null);
mockCache.get.mockImplementation(async (key) => {
if (key.includes(":previous_result")) {
return { ok: true, data: mockPreviousResult };
}
return { ok: true, data: null };
});
fetch.mockResolvedValueOnce({ ok: false, status: 500 } as any);
await getEnterpriseLicense();
// Verify that the fallback info was logged
expect(mockLogger.info).toHaveBeenCalledWith(
expect.objectContaining({
fallbackLevel: "grace",
timestamp: expect.any(String),
}),
expect.stringContaining("Using license fallback level: grace")
);
});
});
@@ -449,8 +712,8 @@ describe("License Core Logic", () => {
const fetch = (await import("node-fetch")).default as Mock;
// Set up cache miss, lock acquired
setupCacheMocksForLockFlow({ cachedLicense: undefined, lockAcquired: true });
// Mock cache.withCache to execute the function (simulating cache miss)
mockCache.withCache.mockImplementation(async (fn) => await fn());
fetch.mockResolvedValueOnce({
ok: true,

View File

@@ -110,16 +110,11 @@ const getCacheIdentifier = () => {
return hashString(env.ENTERPRISE_LICENSE_KEY); // Valid license key
};
const LICENSE_FETCH_LOCK_TTL_MS = 90 * 1000; // 90s lock so only one process fetches when cache is cold
const LICENSE_FETCH_POLL_MS = 12 * 1000; // Wait up to 12s for another process to populate cache
const LICENSE_FETCH_POLL_INTERVAL_MS = 400;
export const getCacheKeys = () => {
const identifier = getCacheIdentifier();
return {
FETCH_LICENSE_CACHE_KEY: createCacheKey.license.status(identifier),
PREVIOUS_RESULT_CACHE_KEY: createCacheKey.license.previous_result(identifier),
FETCH_LOCK_CACHE_KEY: createCacheKey.license.fetch_lock(identifier),
};
};
@@ -290,19 +285,6 @@ const handleInitialFailure = async (currentTime: Date): Promise<TEnterpriseLicen
};
};
/**
* Try to read cached license from Redis. Returns undefined on miss or error.
*/
const getCachedLicense = async (): Promise<TEnterpriseLicenseDetails | null | undefined> => {
const keys = getCacheKeys();
const result = await cache.get<TEnterpriseLicenseDetails | null>(keys.FETCH_LICENSE_CACHE_KEY);
if (!result.ok) return undefined;
if (result.data !== null && result.data !== undefined) return result.data;
const existsResult = await cache.exists(keys.FETCH_LICENSE_CACHE_KEY);
if (existsResult.ok && existsResult.data) return null; // cached null
return undefined;
};
// API functions
let fetchLicensePromise: Promise<TEnterpriseLicenseDetails | null> | null = null;
@@ -406,10 +388,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
}
};
/**
* When Redis license cache is empty, only one process should run the expensive fetch
* (DB count + API call). Others wait for the cache to be populated or fall back after a timeout.
*/
export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null> => {
if (!env.ENTERPRISE_LICENSE_KEY) return null;
@@ -424,37 +402,13 @@ export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null>
}
fetchLicensePromise = (async () => {
const keys = getCacheKeys();
const cached = await getCachedLicense();
if (cached !== undefined) return cached;
const lockResult = await cache.tryLock(keys.FETCH_LOCK_CACHE_KEY, "1", LICENSE_FETCH_LOCK_TTL_MS);
const acquired = lockResult.ok && lockResult.data === true;
if (acquired) {
try {
const fresh = await fetchLicenseFromServerInternal();
await cache.set(keys.FETCH_LICENSE_CACHE_KEY, fresh, CONFIG.CACHE.FETCH_LICENSE_TTL_MS);
return fresh;
} finally {
// Lock expires automatically; no need to release
}
}
const deadline = Date.now() + LICENSE_FETCH_POLL_MS;
while (Date.now() < deadline) {
await sleep(LICENSE_FETCH_POLL_INTERVAL_MS);
const value = await getCachedLicense();
if (value !== undefined) return value;
}
logger.warn(
{ pollMs: LICENSE_FETCH_POLL_MS },
"License cache not populated by holder within poll window; fetching in this process"
return await cache.withCache(
async () => {
return await fetchLicenseFromServerInternal();
},
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
);
const fallback = await fetchLicenseFromServerInternal();
await cache.set(keys.FETCH_LICENSE_CACHE_KEY, fallback, CONFIG.CACHE.FETCH_LICENSE_TTL_MS);
return fallback;
})();
fetchLicensePromise

View File

@@ -188,8 +188,6 @@ export const FollowUpModal = ({
subject: defaultValues?.subject ?? t("environments.surveys.edit.follow_ups_modal_action_subject"),
body: defaultValues?.body ?? getSurveyFollowUpActionDefaultBody(t),
attachResponseData: defaultValues?.attachResponseData ?? false,
includeVariables: defaultValues?.includeVariables ?? false,
includeHiddenFields: defaultValues?.includeHiddenFields ?? false,
},
resolver: zodResolver(ZCreateSurveyFollowUpFormSchema),
mode: "onChange",

View File

@@ -1,107 +0,0 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
test.describe("Survey Follow-Up Create & Edit", async () => {
// 3 minutes
test.setTimeout(1000 * 60 * 3);
test("Create a follow-up without optional toggles and verify it saves", async ({ page, users }) => {
const user = await users.create();
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
await test.step("Create a new survey", async () => {
await page.getByText("Start from scratch").click();
await page.getByRole("button", { name: "Create survey", exact: true }).click();
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit$/);
});
await test.step("Navigate to Follow-ups tab", async () => {
await page.getByText("Follow-ups").click();
// Verify the empty state is shown
await expect(page.getByText("Send automatic follow-ups")).toBeVisible();
});
await test.step("Create a new follow-up without enabling optional toggles", async () => {
// Click the "New follow-up" button in the empty state
await page.getByRole("button", { name: "New follow-up" }).click();
// Verify the modal is open
await expect(page.getByText("Create a new follow-up")).toBeVisible();
// Fill in the follow-up name
await page.getByPlaceholder("Name your follow-up").fill("Test Follow-Up");
// Leave trigger as default ("Respondent completes survey")
// Leave "Attach response data" toggle OFF (the key scenario for the bug)
// Leave "Include variables" and "Include hidden fields" unchecked
// Click Save
await page.getByRole("button", { name: "Save" }).click();
// The success toast should appear — this was the bug: previously save failed silently
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
expect(successToast).toBeTruthy();
});
await test.step("Verify follow-up appears in the list", async () => {
// After creation, the modal closes and the follow-up should appear in the list
await expect(page.getByText("Test Follow-Up")).toBeVisible();
await expect(page.getByText("Any response")).toBeVisible();
await expect(page.getByText("Send email")).toBeVisible();
});
await test.step("Edit the follow-up and verify it saves", async () => {
// Click on the follow-up to edit it
await page.getByText("Test Follow-Up").click();
// Verify the edit modal opens
await expect(page.getByText("Edit this follow-up")).toBeVisible();
// Change the name
const nameInput = page.getByPlaceholder("Name your follow-up");
await nameInput.clear();
await nameInput.fill("Updated Follow-Up");
// Save the edit
await page.getByRole("button", { name: "Save" }).click();
// The success toast should appear
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
expect(successToast).toBeTruthy();
// Verify the updated name appears in the list
await expect(page.getByText("Updated Follow-Up")).toBeVisible();
});
await test.step("Create a second follow-up with optional toggles enabled", async () => {
// Click "+ New follow-up" button (now in the non-empty state header)
await page.getByRole("button", { name: /New follow-up/ }).click();
// Verify the modal is open
await expect(page.getByText("Create a new follow-up")).toBeVisible();
// Fill in the follow-up name
await page.getByPlaceholder("Name your follow-up").fill("Follow-Up With Data");
// Enable "Attach response data" toggle
await page.locator("#attachResponseData").click();
// Check both optional checkboxes
await page.locator("#includeVariables").click();
await page.locator("#includeHiddenFields").click();
// Click Save
await page.getByRole("button", { name: "Save" }).click();
// The success toast should appear
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
expect(successToast).toBeTruthy();
// Verify both follow-ups appear in the list
await expect(page.getByText("Updated Follow-Up")).toBeVisible();
await expect(page.getByText("Follow-Up With Data")).toBeVisible();
});
});
});

View File

@@ -32,7 +32,6 @@ export const createCacheKey = {
status: (organizationId: string): CacheKey => makeCacheKey("license", organizationId, "status"),
previous_result: (organizationId: string): CacheKey =>
makeCacheKey("license", organizationId, "previous_result"),
fetch_lock: (organizationId: string): CacheKey => makeCacheKey("license", organizationId, "fetch_lock"),
},
// Rate limiting and security

View File

@@ -54,7 +54,6 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@formbricks/i18n-utils": "workspace:*",
"@formbricks/types": "workspace:*",
"@preact/preset-vite": "2.10.1",
"@tailwindcss/postcss": "4.1.17",
@@ -70,4 +69,4 @@
"vite-plugin-dts": "4.5.3",
"vite-tsconfig-paths": "5.1.4"
}
}
}

View File

@@ -1,10 +1,10 @@
import { useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { type TSurveyLanguage } from "@formbricks/types/surveys/types";
import { LanguageIcon } from "@/components/icons/language-icon";
import { mixColor } from "@/lib/color";
import { getLanguageDisplayName } from "@/lib/get-language-display-name";
import { getI18nLanguage } from "@/lib/i18n-utils";
import i18n from "@/lib/i18n.config";
import { useClickOutside } from "@/lib/use-click-outside-hook";
@@ -80,7 +80,7 @@ export function LanguageSwitch({
title={t("common.language_switch")}
type="button"
className={cn(
"text-heading relative flex h-8 w-8 items-center justify-center rounded-md focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
"text-heading focus:outline-hidden relative flex h-8 w-8 items-center justify-center rounded-md focus:ring-2 focus:ring-offset-2"
)}
style={{
backgroundColor: isHovered ? hoverColorWithOpacity : "transparent",
@@ -113,7 +113,7 @@ export function LanguageSwitch({
onClick={() => {
changeLanguage(surveyLanguage.language.code);
}}>
{getLanguageLabel(surveyLanguage.language.code, "en-US")}
{getLanguageDisplayName(surveyLanguage.language.code)}
</button>
);
})}

View File

@@ -0,0 +1,14 @@
/**
* Returns the display name for a language code using Intl.DisplayNames (built-in, 0 KB).
* Falls back to the code if the API is unavailable or the tag is unknown.
*/
export function getLanguageDisplayName(code: string): string {
if (typeof Intl === "undefined" || !Intl.DisplayNames) return code;
try {
const displayNames = new Intl.DisplayNames(["en-US"], { type: "language" });
const name = displayNames.of(code);
return name && name !== code ? name : code;
} catch {
return code;
}
}

6
pnpm-lock.yaml generated
View File

@@ -999,9 +999,6 @@ importers:
'@formbricks/eslint-config':
specifier: workspace:*
version: link:../config-eslint
'@formbricks/i18n-utils':
specifier: workspace:*
version: link:../i18n-utils
'@formbricks/types':
specifier: workspace:*
version: link:../types
@@ -7663,6 +7660,7 @@ packages:
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@11.1.0:
@@ -7672,7 +7670,7 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
globals@13.24.0:
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}