mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-08 02:43:06 -05:00
chore: merge rate limiter epic branch into main (#6236)
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com> Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Co-authored-by: Aditya <162564995+Naidu-4444@users.noreply.github.com> Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com> Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com> Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com> Co-authored-by: Suraj <surajsuthar0067@gmail.com> Co-authored-by: Kshitij Sharma <63995641+kshitij-codes@users.noreply.github.com> Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com> Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
This commit is contained in:
committed by
GitHub
parent
bea02ba3b5
commit
ef973c8995
+381
@@ -0,0 +1,381 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createCacheKey, parseCacheKey, validateCacheKey } from "./cacheKeys";
|
||||
|
||||
describe("cacheKeys", () => {
|
||||
describe("createCacheKey", () => {
|
||||
describe("environment keys", () => {
|
||||
test("should create environment state key", () => {
|
||||
const key = createCacheKey.environment.state("env123");
|
||||
expect(key).toBe("fb:env:env123:state");
|
||||
});
|
||||
|
||||
test("should create environment surveys key", () => {
|
||||
const key = createCacheKey.environment.surveys("env456");
|
||||
expect(key).toBe("fb:env:env456:surveys");
|
||||
});
|
||||
|
||||
test("should create environment actionClasses key", () => {
|
||||
const key = createCacheKey.environment.actionClasses("env789");
|
||||
expect(key).toBe("fb:env:env789:action_classes");
|
||||
});
|
||||
|
||||
test("should create environment config key", () => {
|
||||
const key = createCacheKey.environment.config("env101");
|
||||
expect(key).toBe("fb:env:env101:config");
|
||||
});
|
||||
|
||||
test("should create environment segments key", () => {
|
||||
const key = createCacheKey.environment.segments("env202");
|
||||
expect(key).toBe("fb:env:env202:segments");
|
||||
});
|
||||
});
|
||||
|
||||
describe("organization keys", () => {
|
||||
test("should create organization billing key", () => {
|
||||
const key = createCacheKey.organization.billing("org123");
|
||||
expect(key).toBe("fb:org:org123:billing");
|
||||
});
|
||||
|
||||
test("should create organization environments key", () => {
|
||||
const key = createCacheKey.organization.environments("org456");
|
||||
expect(key).toBe("fb:org:org456:environments");
|
||||
});
|
||||
|
||||
test("should create organization config key", () => {
|
||||
const key = createCacheKey.organization.config("org789");
|
||||
expect(key).toBe("fb:org:org789:config");
|
||||
});
|
||||
|
||||
test("should create organization limits key", () => {
|
||||
const key = createCacheKey.organization.limits("org101");
|
||||
expect(key).toBe("fb:org:org101:limits");
|
||||
});
|
||||
});
|
||||
|
||||
describe("license keys", () => {
|
||||
test("should create license status key", () => {
|
||||
const key = createCacheKey.license.status("org123");
|
||||
expect(key).toBe("fb:license:org123:status");
|
||||
});
|
||||
|
||||
test("should create license features key", () => {
|
||||
const key = createCacheKey.license.features("org456");
|
||||
expect(key).toBe("fb:license:org456:features");
|
||||
});
|
||||
|
||||
test("should create license usage key", () => {
|
||||
const key = createCacheKey.license.usage("org789");
|
||||
expect(key).toBe("fb:license:org789:usage");
|
||||
});
|
||||
|
||||
test("should create license check key", () => {
|
||||
const key = createCacheKey.license.check("org123", "feature-x");
|
||||
expect(key).toBe("fb:license:org123:check:feature-x");
|
||||
});
|
||||
|
||||
test("should create license previous_result key", () => {
|
||||
const key = createCacheKey.license.previous_result("org456");
|
||||
expect(key).toBe("fb:license:org456:previous_result");
|
||||
});
|
||||
});
|
||||
|
||||
describe("user keys", () => {
|
||||
test("should create user profile key", () => {
|
||||
const key = createCacheKey.user.profile("user123");
|
||||
expect(key).toBe("fb:user:user123:profile");
|
||||
});
|
||||
|
||||
test("should create user preferences key", () => {
|
||||
const key = createCacheKey.user.preferences("user456");
|
||||
expect(key).toBe("fb:user:user456:preferences");
|
||||
});
|
||||
|
||||
test("should create user organizations key", () => {
|
||||
const key = createCacheKey.user.organizations("user789");
|
||||
expect(key).toBe("fb:user:user789:organizations");
|
||||
});
|
||||
|
||||
test("should create user permissions key", () => {
|
||||
const key = createCacheKey.user.permissions("user123", "org456");
|
||||
expect(key).toBe("fb:user:user123:org:org456:permissions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("project keys", () => {
|
||||
test("should create project config key", () => {
|
||||
const key = createCacheKey.project.config("proj123");
|
||||
expect(key).toBe("fb:project:proj123:config");
|
||||
});
|
||||
|
||||
test("should create project environments key", () => {
|
||||
const key = createCacheKey.project.environments("proj456");
|
||||
expect(key).toBe("fb:project:proj456:environments");
|
||||
});
|
||||
|
||||
test("should create project surveys key", () => {
|
||||
const key = createCacheKey.project.surveys("proj789");
|
||||
expect(key).toBe("fb:project:proj789:surveys");
|
||||
});
|
||||
});
|
||||
|
||||
describe("survey keys", () => {
|
||||
test("should create survey metadata key", () => {
|
||||
const key = createCacheKey.survey.metadata("survey123");
|
||||
expect(key).toBe("fb:survey:survey123:metadata");
|
||||
});
|
||||
|
||||
test("should create survey responses key", () => {
|
||||
const key = createCacheKey.survey.responses("survey456");
|
||||
expect(key).toBe("fb:survey:survey456:responses");
|
||||
});
|
||||
|
||||
test("should create survey stats key", () => {
|
||||
const key = createCacheKey.survey.stats("survey789");
|
||||
expect(key).toBe("fb:survey:survey789:stats");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session keys", () => {
|
||||
test("should create session data key", () => {
|
||||
const key = createCacheKey.session.data("session123");
|
||||
expect(key).toBe("fb:session:session123:data");
|
||||
});
|
||||
|
||||
test("should create session permissions key", () => {
|
||||
const key = createCacheKey.session.permissions("session456");
|
||||
expect(key).toBe("fb:session:session456:permissions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rate limit keys", () => {
|
||||
test("should create rate limit api key", () => {
|
||||
const key = createCacheKey.rateLimit.api("api-key-123", "endpoint-v1");
|
||||
expect(key).toBe("fb:rate_limit:api:api-key-123:endpoint-v1");
|
||||
});
|
||||
|
||||
test("should create rate limit login key", () => {
|
||||
const key = createCacheKey.rateLimit.login("user-ip-hash");
|
||||
expect(key).toBe("fb:rate_limit:login:user-ip-hash");
|
||||
});
|
||||
|
||||
test("should create rate limit core key", () => {
|
||||
const key = createCacheKey.rateLimit.core("auth:login", "user123", 1703174400);
|
||||
expect(key).toBe("fb:rate_limit:auth:login:user123:1703174400");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom keys", () => {
|
||||
test("should create custom key without subResource", () => {
|
||||
const key = createCacheKey.custom("temp", "identifier123");
|
||||
expect(key).toBe("fb:temp:identifier123");
|
||||
});
|
||||
|
||||
test("should create custom key with subResource", () => {
|
||||
const key = createCacheKey.custom("analytics", "user456", "daily-stats");
|
||||
expect(key).toBe("fb:analytics:user456:daily-stats");
|
||||
});
|
||||
|
||||
test("should work with all valid namespaces", () => {
|
||||
const validNamespaces = ["temp", "analytics", "webhook", "integration", "backup"];
|
||||
|
||||
validNamespaces.forEach((namespace) => {
|
||||
const key = createCacheKey.custom(namespace, "test-id");
|
||||
expect(key).toBe(`fb:${namespace}:test-id`);
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for invalid namespace", () => {
|
||||
expect(() => createCacheKey.custom("invalid", "identifier")).toThrow(
|
||||
"Invalid cache namespace: invalid. Use: temp, analytics, webhook, integration, backup"
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error for empty namespace", () => {
|
||||
expect(() => createCacheKey.custom("", "identifier")).toThrow(
|
||||
"Invalid cache namespace: . Use: temp, analytics, webhook, integration, backup"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCacheKey", () => {
|
||||
test("should validate correct cache keys", () => {
|
||||
const validKeys = [
|
||||
"fb:env:env123:state",
|
||||
"fb:user:user456:profile",
|
||||
"fb:org:org789:billing",
|
||||
"fb:rate_limit:api:key123:endpoint",
|
||||
"fb:custom:namespace:identifier:sub:resource",
|
||||
];
|
||||
|
||||
validKeys.forEach((key) => {
|
||||
expect(validateCacheKey(key)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject keys without fb prefix", () => {
|
||||
const invalidKeys = ["env:env123:state", "user:user456:profile", "redis:key:value", "cache:item:data"];
|
||||
|
||||
invalidKeys.forEach((key) => {
|
||||
expect(validateCacheKey(key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject keys with insufficient parts", () => {
|
||||
const invalidKeys = ["fb:", "fb:env", "fb:env:", "fb:user:user123:"];
|
||||
|
||||
invalidKeys.forEach((key) => {
|
||||
expect(validateCacheKey(key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject keys with empty parts", () => {
|
||||
const invalidKeys = ["fb::env123:state", "fb:env::state", "fb:env:env123:", "fb:user::profile"];
|
||||
|
||||
invalidKeys.forEach((key) => {
|
||||
expect(validateCacheKey(key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should validate minimum valid key", () => {
|
||||
expect(validateCacheKey("fb:a:b")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCacheKey", () => {
|
||||
test("should parse basic cache key", () => {
|
||||
const result = parseCacheKey("fb:env:env123:state");
|
||||
|
||||
expect(result).toEqual({
|
||||
prefix: "fb",
|
||||
resource: "env",
|
||||
identifier: "env123",
|
||||
subResource: "state",
|
||||
full: "fb:env:env123:state",
|
||||
});
|
||||
});
|
||||
|
||||
test("should parse key without subResource", () => {
|
||||
const result = parseCacheKey("fb:user:user123");
|
||||
|
||||
expect(result).toEqual({
|
||||
prefix: "fb",
|
||||
resource: "user",
|
||||
identifier: "user123",
|
||||
subResource: undefined,
|
||||
full: "fb:user:user123",
|
||||
});
|
||||
});
|
||||
|
||||
test("should parse key with multiple subResource parts", () => {
|
||||
const result = parseCacheKey("fb:user:user123:org:org456:permissions");
|
||||
|
||||
expect(result).toEqual({
|
||||
prefix: "fb",
|
||||
resource: "user",
|
||||
identifier: "user123",
|
||||
subResource: "org:org456:permissions",
|
||||
full: "fb:user:user123:org:org456:permissions",
|
||||
});
|
||||
});
|
||||
|
||||
test("should parse rate limit key with timestamp", () => {
|
||||
const result = parseCacheKey("fb:rate_limit:auth:login:user123:1703174400");
|
||||
|
||||
expect(result).toEqual({
|
||||
prefix: "fb",
|
||||
resource: "rate_limit",
|
||||
identifier: "auth",
|
||||
subResource: "login:user123:1703174400",
|
||||
full: "fb:rate_limit:auth:login:user123:1703174400",
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for invalid cache key", () => {
|
||||
const invalidKeys = ["invalid:key:format", "fb:env", "fb::env123:state", "redis:user:profile"];
|
||||
|
||||
invalidKeys.forEach((key) => {
|
||||
expect(() => parseCacheKey(key)).toThrow(`Invalid cache key format: ${key}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache key patterns and consistency", () => {
|
||||
test("all environment keys should follow same pattern", () => {
|
||||
const envId = "test-env-123";
|
||||
const envKeys = [
|
||||
createCacheKey.environment.state(envId),
|
||||
createCacheKey.environment.surveys(envId),
|
||||
createCacheKey.environment.actionClasses(envId),
|
||||
createCacheKey.environment.config(envId),
|
||||
createCacheKey.environment.segments(envId),
|
||||
];
|
||||
|
||||
envKeys.forEach((key) => {
|
||||
expect(key).toMatch(/^fb:env:test-env-123:.+$/);
|
||||
expect(validateCacheKey(key)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("all organization keys should follow same pattern", () => {
|
||||
const orgId = "test-org-456";
|
||||
const orgKeys = [
|
||||
createCacheKey.organization.billing(orgId),
|
||||
createCacheKey.organization.environments(orgId),
|
||||
createCacheKey.organization.config(orgId),
|
||||
createCacheKey.organization.limits(orgId),
|
||||
];
|
||||
|
||||
orgKeys.forEach((key) => {
|
||||
expect(key).toMatch(/^fb:org:test-org-456:.+$/);
|
||||
expect(validateCacheKey(key)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("all generated keys should be parseable", () => {
|
||||
const testKeys = [
|
||||
createCacheKey.environment.state("env123"),
|
||||
createCacheKey.user.profile("user456"),
|
||||
createCacheKey.organization.billing("org789"),
|
||||
createCacheKey.survey.metadata("survey101"),
|
||||
createCacheKey.session.data("session202"),
|
||||
createCacheKey.rateLimit.core("auth:login", "user303", 1703174400),
|
||||
createCacheKey.custom("temp", "temp404", "cleanup"),
|
||||
];
|
||||
|
||||
testKeys.forEach((key) => {
|
||||
expect(() => parseCacheKey(key)).not.toThrow();
|
||||
|
||||
const parsed = parseCacheKey(key);
|
||||
expect(parsed.prefix).toBe("fb");
|
||||
expect(parsed.full).toBe(key);
|
||||
expect(parsed.resource).toBeTruthy();
|
||||
expect(parsed.identifier).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test("keys should be unique across different resources", () => {
|
||||
const keys = [
|
||||
createCacheKey.environment.state("same-id"),
|
||||
createCacheKey.user.profile("same-id"),
|
||||
createCacheKey.organization.billing("same-id"),
|
||||
createCacheKey.project.config("same-id"),
|
||||
createCacheKey.survey.metadata("same-id"),
|
||||
];
|
||||
|
||||
const uniqueKeys = new Set(keys);
|
||||
expect(uniqueKeys.size).toBe(keys.length);
|
||||
});
|
||||
|
||||
test("namespace validation should prevent collisions", () => {
|
||||
// These should not throw (valid namespaces)
|
||||
expect(() => createCacheKey.custom("temp", "id")).not.toThrow();
|
||||
expect(() => createCacheKey.custom("analytics", "id")).not.toThrow();
|
||||
|
||||
// These should throw (reserved/invalid namespaces)
|
||||
expect(() => createCacheKey.custom("env", "id")).toThrow();
|
||||
expect(() => createCacheKey.custom("user", "id")).toThrow();
|
||||
expect(() => createCacheKey.custom("org", "id")).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
+3
@@ -11,6 +11,7 @@ import "server-only";
|
||||
* - Predictable invalidation patterns
|
||||
* - Multi-tenant safe
|
||||
*/
|
||||
|
||||
export const createCacheKey = {
|
||||
// Environment-related keys
|
||||
environment: {
|
||||
@@ -71,6 +72,8 @@ export const createCacheKey = {
|
||||
rateLimit: {
|
||||
api: (identifier: string, endpoint: string) => `fb:rate_limit:api:${identifier}:${endpoint}`,
|
||||
login: (identifier: string) => `fb:rate_limit:login:${identifier}`,
|
||||
core: (namespace: string, identifier: string, windowStart: number) =>
|
||||
`fb:rate_limit:${namespace}:${identifier}:${windowStart}`,
|
||||
},
|
||||
|
||||
// Custom keys with validation
|
||||
|
||||
Vendored
+261
@@ -0,0 +1,261 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the redis client
|
||||
const mockRedisClient = {
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
on: vi.fn(),
|
||||
isReady: true,
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
expire: vi.fn(),
|
||||
ttl: vi.fn(),
|
||||
keys: vi.fn(),
|
||||
flushall: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("redis", () => ({
|
||||
createClient: vi.fn(() => mockRedisClient),
|
||||
}));
|
||||
|
||||
// Mock crypto for UUID generation
|
||||
vi.mock("crypto", () => ({
|
||||
randomUUID: vi.fn(() => "test-uuid-123"),
|
||||
}));
|
||||
|
||||
describe("Redis module", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset environment variable
|
||||
process.env.REDIS_URL = "redis://localhost:6379";
|
||||
|
||||
// Reset isReady state
|
||||
mockRedisClient.isReady = true;
|
||||
|
||||
// Make connect resolve successfully
|
||||
mockRedisClient.connect.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
process.env.REDIS_URL = undefined;
|
||||
});
|
||||
|
||||
describe("Module initialization", () => {
|
||||
test("should create Redis client when REDIS_URL is set", async () => {
|
||||
const { createClient } = await import("redis");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
expect(createClient).toHaveBeenCalledWith({
|
||||
url: "redis://localhost:6379",
|
||||
socket: {
|
||||
reconnectStrategy: expect.any(Function),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should not create Redis client when REDIS_URL is not set", async () => {
|
||||
delete process.env.REDIS_URL;
|
||||
|
||||
const { createClient } = await import("redis");
|
||||
|
||||
// Clear the module cache and re-import
|
||||
vi.resetModules();
|
||||
await import("./redis");
|
||||
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should set up event listeners", async () => {
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
expect(mockRedisClient.on).toHaveBeenCalledWith("error", expect.any(Function));
|
||||
expect(mockRedisClient.on).toHaveBeenCalledWith("connect", expect.any(Function));
|
||||
expect(mockRedisClient.on).toHaveBeenCalledWith("reconnecting", expect.any(Function));
|
||||
expect(mockRedisClient.on).toHaveBeenCalledWith("ready", expect.any(Function));
|
||||
});
|
||||
|
||||
test("should attempt initial connection", async () => {
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
expect(mockRedisClient.connect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRedisClient", () => {
|
||||
test("should return client when ready", async () => {
|
||||
mockRedisClient.isReady = true;
|
||||
|
||||
const { getRedisClient } = await import("./redis");
|
||||
const client = getRedisClient();
|
||||
|
||||
expect(client).toBe(mockRedisClient);
|
||||
});
|
||||
|
||||
test("should return null when client is not ready", async () => {
|
||||
mockRedisClient.isReady = false;
|
||||
|
||||
const { getRedisClient } = await import("./redis");
|
||||
const client = getRedisClient();
|
||||
|
||||
expect(client).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null when no REDIS_URL is set", async () => {
|
||||
delete process.env.REDIS_URL;
|
||||
|
||||
vi.resetModules();
|
||||
const { getRedisClient } = await import("./redis");
|
||||
const client = getRedisClient();
|
||||
|
||||
expect(client).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("disconnectRedis", () => {
|
||||
test("should disconnect the client", async () => {
|
||||
const { disconnectRedis } = await import("./redis");
|
||||
|
||||
await disconnectRedis();
|
||||
|
||||
expect(mockRedisClient.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle case when client is null", async () => {
|
||||
delete process.env.REDIS_URL;
|
||||
|
||||
vi.resetModules();
|
||||
const { disconnectRedis } = await import("./redis");
|
||||
|
||||
await expect(disconnectRedis()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reconnection strategy", () => {
|
||||
test("should configure reconnection strategy properly", async () => {
|
||||
const { createClient } = await import("redis");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
const createClientCall = vi.mocked(createClient).mock.calls[0];
|
||||
const config = createClientCall[0] as any;
|
||||
|
||||
expect(config.socket.reconnectStrategy).toBeDefined();
|
||||
expect(typeof config.socket.reconnectStrategy).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Event handlers", () => {
|
||||
test("should log error events", async () => {
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
// Find the error event handler
|
||||
const errorCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "error");
|
||||
const errorHandler = errorCall?.[1];
|
||||
|
||||
const testError = new Error("Test error");
|
||||
errorHandler?.(testError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith("Redis client error:", testError);
|
||||
});
|
||||
|
||||
test("should log connect events", async () => {
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
// Find the connect event handler
|
||||
const connectCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "connect");
|
||||
const connectHandler = connectCall?.[1];
|
||||
|
||||
connectHandler?.();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith("Redis client connected");
|
||||
});
|
||||
|
||||
test("should log reconnecting events", async () => {
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
// Find the reconnecting event handler
|
||||
const reconnectingCall = vi
|
||||
.mocked(mockRedisClient.on)
|
||||
.mock.calls.find((call) => call[0] === "reconnecting");
|
||||
const reconnectingHandler = reconnectingCall?.[1];
|
||||
|
||||
reconnectingHandler?.();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith("Redis client reconnecting");
|
||||
});
|
||||
|
||||
test("should log ready events", async () => {
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
// Find the ready event handler
|
||||
const readyCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "ready");
|
||||
const readyHandler = readyCall?.[1];
|
||||
|
||||
readyHandler?.();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith("Redis client ready");
|
||||
});
|
||||
|
||||
test("should log end events", async () => {
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
// Find the end event handler
|
||||
const endCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "end");
|
||||
const endHandler = endCall?.[1];
|
||||
|
||||
endHandler?.();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith("Redis client disconnected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Connection failure handling", () => {
|
||||
test("should handle initial connection failure", async () => {
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
const connectionError = new Error("Connection failed");
|
||||
mockRedisClient.connect.mockRejectedValue(connectionError);
|
||||
|
||||
vi.resetModules();
|
||||
await import("./redis");
|
||||
|
||||
// Wait for the connection promise to resolve
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith("Initial Redis connection failed:", connectionError);
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+64
-6
@@ -1,11 +1,69 @@
|
||||
import { REDIS_URL } from "@/lib/constants";
|
||||
import Redis from "ioredis";
|
||||
import { createClient } from "redis";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
const redis = REDIS_URL ? new Redis(REDIS_URL) : null;
|
||||
type RedisClient = ReturnType<typeof createClient>;
|
||||
|
||||
if (!redis) {
|
||||
logger.info("REDIS_URL is not set");
|
||||
const REDIS_URL = process.env.REDIS_URL;
|
||||
|
||||
let client: RedisClient | null = null;
|
||||
|
||||
if (REDIS_URL) {
|
||||
client = createClient({
|
||||
url: REDIS_URL,
|
||||
socket: {
|
||||
reconnectStrategy: (retries) => {
|
||||
logger.info(`Redis reconnection attempt ${retries}`);
|
||||
|
||||
// For the first 5 attempts, use exponential backoff with max 5 second delay
|
||||
if (retries <= 5) {
|
||||
return Math.min(retries * 1000, 5000);
|
||||
}
|
||||
|
||||
// After 5 attempts, use a longer delay but never give up
|
||||
// This ensures the client keeps trying to reconnect when Redis comes back online
|
||||
logger.info("Redis reconnection using extended delay (30 seconds)");
|
||||
return 30000; // 30 second delay for persistent reconnection attempts
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
logger.error("Redis client error:", err);
|
||||
});
|
||||
|
||||
client.on("connect", () => {
|
||||
logger.info("Redis client connected");
|
||||
});
|
||||
|
||||
client.on("reconnecting", () => {
|
||||
logger.info("Redis client reconnecting");
|
||||
});
|
||||
|
||||
client.on("ready", () => {
|
||||
logger.info("Redis client ready");
|
||||
});
|
||||
|
||||
client.on("end", () => {
|
||||
logger.info("Redis client disconnected");
|
||||
});
|
||||
|
||||
// Connect immediately
|
||||
client.connect().catch((err) => {
|
||||
logger.error("Initial Redis connection failed:", err);
|
||||
});
|
||||
}
|
||||
|
||||
export default redis;
|
||||
export const getRedisClient = (): RedisClient | null => {
|
||||
if (!client?.isReady) {
|
||||
logger.warn("Redis client not ready, operations will be skipped");
|
||||
return null;
|
||||
}
|
||||
return client;
|
||||
};
|
||||
|
||||
export const disconnectRedis = async (): Promise<void> => {
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
client = null;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user