feat: add cache integration tests and update E2E workflow (#6551)

This commit is contained in:
Victor Hugo dos Santos
2025-09-19 05:44:31 -03:00
committed by GitHub
parent c9016802e7
commit 6bc5f1e168
24 changed files with 1460 additions and 66 deletions

View File

@@ -33,7 +33,7 @@ jobs:
timeout-minutes: 60
services:
postgres:
image: pgvector/pgvector:pg17
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
@@ -166,6 +166,12 @@ jobs:
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
shell: bash
- name: Run Cache Integration Tests
run: |
echo "Running cache integration tests with Redis/Valkey..."
cd packages/cache && pnpm vitest run src/cache-integration.test.ts
shell: bash
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)

View File

@@ -0,0 +1 @@
export { GET } from "@/modules/api/v2/health/route";

View File

@@ -0,0 +1,101 @@
import { getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { type OverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { type ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
/**
* Check if the main database is reachable and responding
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the database health check
*/
export const checkDatabaseHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
// Simple query to check if database is reachable
await prisma.$queryRaw`SELECT 1`;
return ok(true);
} catch (error) {
logger
.withContext({
component: "health_check",
check_type: "main_database",
error,
})
.error("Database health check failed");
return err({
type: "internal_server_error",
details: [{ field: "main_database", issue: "Database health check failed" }],
});
}
};
/**
* Check if the Redis cache is reachable and responding
* @returns Promise<Result<boolean, ApiErrorResponseV2>> - Result of the cache health check
*/
export const checkCacheHealth = async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
const cacheServiceResult = await getCacheService();
if (!cacheServiceResult.ok) {
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Cache service not available" }],
});
}
const isAvailable = await cacheServiceResult.data.isRedisAvailable();
if (isAvailable) {
return ok(true);
}
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Redis not available" }],
});
} catch (error) {
logger
.withContext({
component: "health_check",
check_type: "cache_database",
error,
})
.error("Redis health check failed");
return err({
type: "internal_server_error",
details: [{ field: "cache_database", issue: "Redis health check failed" }],
});
}
};
/**
* Perform all health checks and return the overall status
* Always returns ok() with health status unless the health check endpoint itself fails
* @returns Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> - Overall health status of all dependencies
*/
export const performHealthChecks = async (): Promise<Result<OverallHealthStatus, ApiErrorResponseV2>> => {
try {
const [databaseResult, cacheResult] = await Promise.all([checkDatabaseHealth(), checkCacheHealth()]);
const healthStatus: OverallHealthStatus = {
main_database: databaseResult.ok ? databaseResult.data : false,
cache_database: cacheResult.ok ? cacheResult.data : false,
};
// Always return ok() with the health status - individual dependency failures
// are reflected in the boolean values
return ok(healthStatus);
} catch (error) {
// Only return err() if the health check endpoint itself fails
logger
.withContext({
component: "health_check",
error,
})
.error("Health check endpoint failed");
return err({
type: "internal_server_error",
details: [{ field: "health", issue: "Failed to perform health checks" }],
});
}
};

View File

@@ -0,0 +1,29 @@
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZodOpenApiOperationObject } from "zod-openapi";
export const healthCheckEndpoint: ZodOpenApiOperationObject = {
tags: ["Health"],
summary: "Health Check",
description: "Check the health status of critical application dependencies including database and cache.",
requestParams: {},
operationId: "healthCheck",
security: [],
responses: {
"200": {
description:
"Health check completed successfully. Check individual dependency status in response data.",
content: {
"application/json": {
schema: makePartialSchema(ZOverallHealthStatus),
},
},
},
},
};
export const healthPaths = {
"/health": {
get: healthCheckEndpoint,
},
};

View File

@@ -0,0 +1,288 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { err, ok } from "@formbricks/types/error-handlers";
import { checkCacheHealth, checkDatabaseHealth, performHealthChecks } from "../health-checks";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
$queryRaw: vi.fn(),
},
}));
vi.mock("@formbricks/cache", () => ({
getCacheService: vi.fn(),
ErrorCode: {
RedisConnectionError: "redis_connection_error",
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
withContext: vi.fn(() => ({
error: vi.fn(),
info: vi.fn(),
})),
},
}));
describe("Health Checks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// Helper function to create a mock CacheService
const createMockCacheService = (isRedisAvailable: boolean = true) => ({
getRedisClient: vi.fn(),
withTimeout: vi.fn(),
get: vi.fn(),
exists: vi.fn(),
set: vi.fn(),
del: vi.fn(),
keys: vi.fn(),
withCache: vi.fn(),
flush: vi.fn(),
tryGetCachedValue: vi.fn(),
trySetCache: vi.fn(),
isRedisAvailable: vi.fn().mockResolvedValue(isRedisAvailable),
});
describe("checkDatabaseHealth", () => {
test("should return healthy when database query succeeds", async () => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
const result = await checkDatabaseHealth();
expect(result).toEqual({ ok: true, data: true });
expect(prisma.$queryRaw).toHaveBeenCalledWith(["SELECT 1"]);
});
test("should return unhealthy when database query fails", async () => {
const dbError = new Error("Database connection failed");
vi.mocked(prisma.$queryRaw).mockRejectedValue(dbError);
const result = await checkDatabaseHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "main_database", issue: "Database health check failed" },
]);
}
});
test("should handle different types of database errors", async () => {
const networkError = new Error("ECONNREFUSED");
vi.mocked(prisma.$queryRaw).mockRejectedValue(networkError);
const result = await checkDatabaseHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "main_database", issue: "Database health check failed" },
]);
}
});
});
describe("checkCacheHealth", () => {
test("should return healthy when Redis is available", async () => {
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result).toEqual({ ok: true, data: true });
expect(getCacheService).toHaveBeenCalled();
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
});
test("should return unhealthy when cache service fails to initialize", async () => {
const cacheError = { code: ErrorCode.RedisConnectionError };
vi.mocked(getCacheService).mockResolvedValue(err(cacheError));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Cache service not available" },
]);
}
});
test("should return unhealthy when Redis is not available", async () => {
const mockCacheService = createMockCacheService(false);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "cache_database", issue: "Redis not available" }]);
}
expect(mockCacheService.isRedisAvailable).toHaveBeenCalled();
});
test("should handle Redis availability check exceptions", async () => {
const mockCacheService = createMockCacheService(true);
mockCacheService.isRedisAvailable.mockRejectedValue(new Error("Redis ping failed"));
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Redis health check failed" },
]);
}
});
test("should handle cache service initialization exceptions", async () => {
const serviceException = new Error("Cache service unavailable");
vi.mocked(getCacheService).mockRejectedValue(serviceException);
const result = await checkCacheHealth();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([
{ field: "cache_database", issue: "Redis health check failed" },
]);
}
});
test("should verify isRedisAvailable is called asynchronously", async () => {
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
await checkCacheHealth();
// Verify the async method was called
expect(mockCacheService.isRedisAvailable).toHaveBeenCalledTimes(1);
expect(mockCacheService.isRedisAvailable).toReturnWith(Promise.resolve(true));
});
});
describe("performHealthChecks", () => {
test("should return all healthy when both checks pass", async () => {
// Mock successful database check
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
// Mock successful cache check
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: true,
cache_database: true,
},
});
});
test("should return mixed results when only database is healthy", async () => {
// Mock successful database check
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ "?column?": 1 }]);
// Mock failed cache check
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: true,
cache_database: false,
},
});
});
test("should return mixed results when only cache is healthy", async () => {
// Mock failed database check
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
// Mock successful cache check
const mockCacheService = createMockCacheService(true);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: false,
cache_database: true,
},
});
});
test("should return all unhealthy when both checks fail", async () => {
// Mock failed database check
vi.mocked(prisma.$queryRaw).mockRejectedValue(new Error("DB Error"));
// Mock failed cache check
vi.mocked(getCacheService).mockResolvedValue(err({ code: ErrorCode.RedisConnectionError }));
const result = await performHealthChecks();
expect(result).toEqual({
ok: true,
data: {
main_database: false,
cache_database: false,
},
});
});
test("should run both checks in parallel", async () => {
const dbPromise = new Promise((resolve) => setTimeout(() => resolve([{ "?column?": 1 }]), 100));
const redisPromise = new Promise((resolve) => setTimeout(() => resolve(true), 100));
vi.mocked(prisma.$queryRaw).mockReturnValue(dbPromise as any);
const mockCacheService = createMockCacheService(true);
mockCacheService.isRedisAvailable.mockReturnValue(redisPromise as any);
vi.mocked(getCacheService).mockResolvedValue(ok(mockCacheService as any));
const startTime = Date.now();
await performHealthChecks();
const endTime = Date.now();
// Should complete in roughly 100ms (parallel) rather than 200ms (sequential)
expect(endTime - startTime).toBeLessThan(150);
});
test("should return error only on catastrophic failure (endpoint itself fails)", async () => {
// Mock a catastrophic failure in Promise.all itself
const originalPromiseAll = Promise.all;
vi.spyOn(Promise, "all").mockRejectedValue(new Error("Catastrophic system failure"));
const result = await performHealthChecks();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "health", issue: "Failed to perform health checks" }]);
}
// Restore original Promise.all
Promise.all = originalPromiseAll;
});
});
});

View File

@@ -0,0 +1,15 @@
import { responses } from "@/modules/api/v2/lib/response";
import { performHealthChecks } from "./lib/health-checks";
export const GET = async () => {
const healthStatusResult = await performHealthChecks();
if (!healthStatusResult.ok) {
return responses.serviceUnavailableResponse({
details: healthStatusResult.error.details,
});
}
return responses.successResponse({
data: healthStatusResult.data,
});
};

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOverallHealthStatus = z
.object({
main_database: z.boolean().openapi({
description: "Main database connection status - true if database is reachable and running",
example: true,
}),
cache_database: z.boolean().openapi({
description: "Cache database connection status - true if cache database is reachable and running",
example: true,
}),
})
.openapi({
title: "Health Check Response",
description: "Health check status for critical application dependencies",
});
export type OverallHealthStatus = z.infer<typeof ZOverallHealthStatus>;

View File

@@ -232,6 +232,35 @@ const internalServerErrorResponse = ({
);
};
const serviceUnavailableResponse = ({
details = [],
cors = false,
cache = "private, no-store",
}: {
details?: ApiErrorDetails;
cors?: boolean;
cache?: string;
} = {}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
error: {
code: 503,
message: "Service Unavailable",
details,
},
},
{
status: 503,
headers,
}
);
};
const successResponse = ({
data,
meta,
@@ -325,6 +354,7 @@ export const responses = {
unprocessableEntityResponse,
tooManyRequestsResponse,
internalServerErrorResponse,
serviceUnavailableResponse,
successResponse,
createdResponse,
multiStatusResponse,

View File

@@ -1,3 +1,5 @@
import { healthPaths } from "@/modules/api/v2/health/lib/openapi";
import { ZOverallHealthStatus } from "@/modules/api/v2/health/types/health-status";
import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
import { surveyContactLinksBySegmentPaths } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/lib/openapi";
@@ -35,6 +37,7 @@ const document = createDocument({
version: "2.0.0",
},
paths: {
...healthPaths,
...rolePaths,
...mePaths,
...responsePaths,
@@ -55,6 +58,10 @@ const document = createDocument({
},
],
tags: [
{
name: "Health",
description: "Operations for checking critical application dependencies health status.",
},
{
name: "Roles",
description: "Operations for managing roles.",
@@ -114,6 +121,7 @@ const document = createDocument({
},
},
schemas: {
health: ZOverallHealthStatus,
role: ZRoles,
me: ZApiKeyData,
response: ZResponse,

View File

@@ -3,6 +3,7 @@ export const SURVEYS_API_URL = `/api/v1/management/surveys`;
export const WEBHOOKS_API_URL = `/api/v2/management/webhooks`;
export const ROLES_API_URL = `/api/v2/roles`;
export const ME_API_URL = `/api/v2/me`;
export const HEALTH_API_URL = `/api/v2/health`;
export const TEAMS_API_URL = (organizationId: string) => `/api/v2/organizations/${organizationId}/teams`;
export const PROJECT_TEAMS_API_URL = (organizationId: string) =>

View File

@@ -0,0 +1,135 @@
import { expect } from "@playwright/test";
import { logger } from "@formbricks/logger";
import { test } from "../lib/fixtures";
import { HEALTH_API_URL } from "./constants";
test.describe("API Tests for Health Endpoint", () => {
test("Health check returns 200 with dependency status", async ({ request }) => {
try {
// Make request to health endpoint (no authentication required)
const response = await request.get(HEALTH_API_URL);
// Should always return 200 if the health check endpoint can execute
expect(response.status()).toBe(200);
const responseBody = await response.json();
// Verify response structure
expect(responseBody).toHaveProperty("data");
expect(responseBody.data).toHaveProperty("main_database");
expect(responseBody.data).toHaveProperty("cache_database");
// Verify data types are boolean
expect(typeof responseBody.data.main_database).toBe("boolean");
expect(typeof responseBody.data.cache_database).toBe("boolean");
// Log the health status for debugging
logger.info(
{
main_database: responseBody.data.main_database,
cache_database: responseBody.data.cache_database,
},
"Health check status"
);
// In a healthy system, we expect both to be true
// But we don't fail the test if they're false - that's what the health check is for
if (!responseBody.data.main_database) {
logger.warn("Main database is unhealthy");
}
if (!responseBody.data.cache_database) {
logger.warn("Cache database is unhealthy");
}
} catch (error) {
logger.error(error, "Error during health check API test");
throw error;
}
});
test("Health check response time is reasonable", async ({ request }) => {
try {
const startTime = Date.now();
const response = await request.get(HEALTH_API_URL);
const endTime = Date.now();
const responseTime = endTime - startTime;
expect(response.status()).toBe(200);
// Health check should respond within 5 seconds
expect(responseTime).toBeLessThan(5000);
logger.info({ responseTime }, "Health check response time");
} catch (error) {
logger.error(error, "Error during health check performance test");
throw error;
}
});
test("Health check is accessible without authentication", async ({ request }) => {
try {
// Make request without any headers or authentication
const response = await request.get(HEALTH_API_URL, {
headers: {
// Explicitly no x-api-key or other auth headers
},
});
// Should be accessible without authentication
expect(response.status()).toBe(200);
const responseBody = await response.json();
expect(responseBody).toHaveProperty("data");
} catch (error) {
logger.error(error, "Error during unauthenticated health check test");
throw error;
}
});
test("Health check handles CORS properly", async ({ request }) => {
try {
// Test with OPTIONS request (preflight)
const optionsResponse = await request.fetch(HEALTH_API_URL, {
method: "OPTIONS",
});
// OPTIONS should succeed or at least not be a server error
expect(optionsResponse.status()).not.toBe(500);
// Test regular GET request
const getResponse = await request.get(HEALTH_API_URL);
expect(getResponse.status()).toBe(200);
} catch (error) {
logger.error(error, "Error during CORS health check test");
throw error;
}
});
test("Health check OpenAPI schema compliance", async ({ request }) => {
try {
const response = await request.get(HEALTH_API_URL);
expect(response.status()).toBe(200);
const responseBody = await response.json();
// Verify it matches our OpenAPI schema
expect(responseBody).toMatchObject({
data: {
main_database: expect.any(Boolean),
cache_database: expect.any(Boolean),
},
});
// Ensure no extra properties in the response data
const dataKeys = Object.keys(responseBody.data);
expect(dataKeys).toHaveLength(2);
expect(dataKeys).toContain("main_database");
expect(dataKeys).toContain("cache_database");
} catch (error) {
logger.error(error, "Error during OpenAPI schema compliance test");
throw error;
}
});
});

View File

@@ -7,6 +7,8 @@ servers:
- url: https://app.formbricks.com/api/v2
description: Formbricks Cloud
tags:
- name: Health
description: Operations for checking critical application dependencies health status.
- name: Roles
description: Operations for managing roles.
- name: Me
@@ -391,6 +393,36 @@ paths:
servers:
- url: https://app.formbricks.com/api/v2
description: Formbricks API Server
/health:
get:
tags:
- Health
summary: Health Check
description: Check the health status of critical application dependencies
including database and cache.
operationId: healthCheck
security: []
responses:
"200":
description: Health check completed successfully. Check individual dependency
status in response data.
content:
application/json:
schema:
type: object
properties:
main_database:
type: boolean
description: Main database connection status - true if database is reachable and
running
example: true
cache_database:
type: boolean
description: Cache database connection status - true if cache database is
reachable and running
example: true
title: Health Check Response
description: Health check status for critical application dependencies
/roles:
get:
operationId: getRoles
@@ -3500,6 +3532,24 @@ components:
name: x-api-key
description: Use your Formbricks x-api-key to authenticate.
schemas:
health:
type: object
properties:
main_database:
type: boolean
description: Main database connection status - true if database is reachable and
running
example: true
cache_database:
type: boolean
description: Cache database connection status - true if cache database is
reachable and running
example: true
required:
- main_database
- cache_database
title: Health Check Response
description: Health check status for critical application dependencies
role:
type: object
properties:
@@ -3835,15 +3885,12 @@ components:
type: string
enum:
- link
- web
- website
- app
description: The type of the survey
status:
type: string
enum:
- draft
- scheduled
- inProgress
- paused
- completed
@@ -4347,7 +4394,6 @@ components:
- createdBy
- environmentId
- endings
- thankYouCard
- hiddenFields
- variables
- displayOption
@@ -4365,7 +4411,6 @@ components:
- isSingleResponsePerEmailEnabled
- inlineTriggers
- isBackButtonHidden
- verifyEmail
- recaptcha
- metadata
- displayPercentage

View File

@@ -4,9 +4,10 @@
### Redis-Only Architecture
- **Mandatory Redis**: All deployments MUST use Redis via `REDIS_URL` environment variable
- **Singleton Client**: Use `getCacheService()` - returns singleton instance per process
- **Singleton Client**: Use `getCacheService()` - returns singleton instance per process using `globalThis`
- **Result Types**: Core operations return `Result<T, CacheError>` for explicit error handling
- **Never-Failing Wrappers**: `withCache()` always returns function result, handling cache errors internally
- **Cross-Platform**: Uses `globalThis` for Edge Runtime, Lambda, and HMR compatibility
### Type Safety & Validation
- **Branded Cache Keys**: Use `CacheKey` type to prevent raw string usage
@@ -17,35 +18,41 @@
```text
src/
├── index.ts # Main exports (getCacheService, createCacheKey, types)
├── client.ts # Singleton cache service client with Redis connection
├── service.ts # Core CacheService class with Result types + withCache helpers
├── cache-keys.ts # Cache key generators with branded types
├── index.ts # Main exports
├── client.ts # globalThis singleton with getCacheService()
├── service.ts # CacheService class with Result types + withCache
├── cache-keys.ts # Cache key generators with branded types
├── cache-integration.test.ts # E2E tests exercising Redis operations
├── utils/
│ ├── validation.ts # Zod validation utilities
│ └── key.ts # makeCacheKey utility (not exported)
└── *.test.ts # Unit tests
│ ├── validation.ts # Zod validation utilities
│ └── key.ts # makeCacheKey utility (not exported)
└── *.test.ts # Unit tests
types/
├── keys.ts # Branded CacheKey type & CustomCacheNamespace
├── client.ts # RedisClient type definition
├── service.ts # Zod schemas and validateInputs function
├── error.ts # Result type system and error definitions
└── *.test.ts # Type tests
├── keys.ts # Branded CacheKey type & CustomCacheNamespace
├── client.ts # RedisClient type definition
├── service.ts # Zod schemas and validateInputs function
├── error.ts # Result type system and error definitions
└── *.test.ts # Type tests
```
## Required Patterns
### Singleton Client Pattern
### globalThis Singleton Pattern
```typescript
// ✅ GOOD - Use singleton client
// ✅ GOOD - Use globalThis singleton client
import { getCacheService } from "@formbricks/cache";
const result = await getCacheService();
if (!result.ok) {
// Handle initialization error
// Handle initialization error - Redis connection failed
logger.error({ error: result.error }, "Cache service unavailable");
throw new Error(`Cache failed: ${result.error.code}`);
}
const cacheService = result.data;
// ✅ GOOD - Production validation (index.ts)
import { validateRedisConfig } from "@formbricks/cache";
validateRedisConfig(); // Throws if REDIS_URL missing in production
// ❌ BAD - CacheService class not exported for direct instantiation
import { CacheService } from "@formbricks/cache"; // Won't work!
```
@@ -71,6 +78,10 @@ const environmentData = await cacheService.withCache(
createCacheKey.environment.state(environmentId),
60000
); // Returns T directly, handles cache errors internally
// ✅ GOOD - Structured logging with context first
logger.error({ error, key, operation: "cache_get" }, "Cache operation failed");
logger.warn({ error }, "Cache unavailable; executing function directly");
```
### Core Validation & Error Types
@@ -91,7 +102,7 @@ export const ZCacheKey = z.string().min(1).refine(k => k.trim().length > 0);
// TTL validation: min 1000ms for Redis seconds conversion
export const ZTtlMs = z.number().int().min(1000).finite();
// Generic validation function
// Generic validation function (returns array of validated values)
export function validateInputs(...pairs: [unknown, ZodType][]): Result<unknown[], CacheError>;
```
@@ -137,10 +148,21 @@ await cacheService.exists(key): Promise<Result<boolean, CacheError>>
// withCache never fails - returns T directly, handles cache errors internally
await cacheService.withCache<T>(fn, key, ttlMs): Promise<T>
// Redis availability check with ping test (standardized across codebase)
await cacheService.isRedisAvailable(): Promise<boolean>
// Direct Redis access for advanced operations (rate limiting, etc.)
cacheService.getRedisClient(): RedisClient | null
```
### Redis Availability Method
Standardized Redis connectivity check across the codebase.
**Method Implementation:**
- `isRedisAvailable()`: Checks client state (`isReady && isOpen`) + Redis ping test
- Returns `Promise<boolean>` - true if Redis is available and responsive
- Used for health monitoring, status checks, and external validation
### Service Implementation - Cognitive Complexity Reduction
The `withCache` method is split into helper methods to reduce cognitive complexity:
@@ -223,13 +245,42 @@ return await fn(); // Always return function result
## Testing Patterns
### Key Test Areas
### Unit Tests (*.test.ts)
- **Result error cases**: Validation, Redis, corruption errors
- **Null vs undefined**: Caching behavior differences
- **withCache fallbacks**: Cache failures gracefully handled
- **Edge cases**: Empty arrays, invalid TTLs, malformed keys
- **Mock dependencies**: Redis client, logger with all levels
### Integration Tests (cache-integration.test.ts)
- **End-to-End Redis Operations**: Tests against live Redis instance
- **Auto-Skip Logic**: Automatically skips when Redis unavailable (`REDIS_URL` not set)
- **Comprehensive Coverage**: All cache operations through real code paths
- **CI Integration**: Runs in E2E workflow with Redis/Valkey service
- **Logger Integration**: Uses `@formbricks/logger` with structured logging
```typescript
// ✅ Integration test pattern
describe("Cache Integration Tests", () => {
beforeAll(async () => {
isRedisAvailable = await checkRedisAvailability();
if (!isRedisAvailable) {
logger.info("🟡 Tests skipped - Redis not available");
return;
}
logger.info("🟢 Tests will run - Redis available");
});
test("withCache miss/hit pattern", async () => {
if (!isRedisAvailable) {
logger.info("Skipping test: Redis not available");
return;
}
// Test cache miss -> hit behavior with real Redis
});
});
```
## Web App Integration Pattern
### Cache Facade (apps/web/lib/cache/index.ts)
@@ -255,37 +306,41 @@ const redis = await cache.getRedisClient();
```
### Proxy Implementation
- **No Singleton Management**: Calls `getCacheService()` for each operation
- **Proxy Pattern**: Transparent method forwarding to underlying cache service
- **Graceful Degradation**: withCache falls back to direct execution on cache failure
- **Lazy Initialization**: Calls `getCacheService()` for each operation via Proxy
- **Graceful Degradation**: `withCache` falls back to direct execution on cache failure
- **Server-Only**: Uses "server-only" import to prevent client-side usage
- **Production Validation**: Validates `REDIS_URL` at module initialization
## Import/Export Standards
## Architecture Updates
### globalThis Singleton (client.ts)
```typescript
// ✅ GOOD - Package root exports (index.ts)
export { getCacheService } from "./client";
export type { CacheService } from "./service";
export { createCacheKey } from "./cache-keys";
export type { CacheKey } from "../types/keys";
export type { Result, CacheError } from "../types/error";
export { CacheErrorClass, ErrorCode } from "../types/error";
// Cross-platform singleton using globalThis (not global)
const globalForCache = globalThis as unknown as {
formbricksCache: CacheService | undefined;
formbricksCacheInitializing: Promise<Result<CacheService, CacheError>> | undefined;
};
// ❌ BAD - Don't export these (encapsulation)
// export { createRedisClientFromEnv } from "./client"; // Internal only
// export type { RedisClient } from "../types/client"; // Internal only
// export { CacheService } from "./service"; // Only type exported
// Prevents multiple Redis connections in HMR/serverless/Edge Runtime
export async function getCacheService(): Promise<Result<CacheService, CacheError>>;
```
### Fast-Fail Connection Strategy
- **No Reconnection in Factory**: Redis client uses fast-fail connection
- **Background Reconnection**: Handled by Redis client's built-in retry logic
- **Early Checks**: `isReady` check at method start to avoid 1-second timeouts
- **Graceful Degradation**: `withCache` executes function when cache unavailable
## Key Rules Summary
1. **Singleton Client**: Use `getCacheService()` - returns singleton per process
2. **Result Types**: Core ops return `Result<T, CacheError>` - no throwing
1. **globalThis Singleton**: Use `getCacheService()` - cross-platform singleton
2. **Result Types**: Core ops return `Result<T, CacheError>` - no throwing
3. **Never-Failing withCache**: Returns `T` directly, handles cache errors internally
4. **Validation**: Use `validateInputs()` function for all input validation
5. **Error Interface**: Single `CacheError` interface with just `code` field
6. **Logging**: Rich logging at source, clean Results for consumers
7. **TTL Minimum**: 1000ms minimum for Redis conversion (ms → seconds)
8. **Type Safety**: Branded `CacheKey` type prevents raw string usage
9. **Encapsulation**: RedisClient and createRedisClientFromEnv are internal only
10. **Cognitive Complexity**: Split complex methods into focused helper methods
4. **Standardized Redis Check**: Use `isRedisAvailable()` method with ping test
5. **Structured Logging**: Context object first, then message string
6. **Fast-Fail Strategy**: Early Redis availability checks, no blocking timeouts
7. **Integration Testing**: E2E tests with auto-skip logic for development
8. **Production Validation**: Mandatory `REDIS_URL` with startup validation
9. **Cross-Platform**: Uses `globalThis` for Edge Runtime/Lambda compatibility
10. **CI Integration**: Cache tests run in E2E workflow with Redis service
11. **Cognitive Complexity**: Split complex methods into focused helper methods

View File

@@ -0,0 +1,550 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-non-null-assertion, @typescript-eslint/require-await -- Test file needs template expressions for test output */
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { logger } from "@formbricks/logger";
import { createCacheKey } from "./cache-keys";
import { getCacheService } from "./client";
import type { CacheService } from "./service";
// Check if Redis is available
let isRedisAvailable = false;
let cacheService: CacheService | null = null;
// Helper to reduce nesting depth
const delay = (ms: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
// Test Redis availability
async function checkRedisAvailability(): Promise<boolean> {
try {
const cacheServiceResult = await getCacheService();
if (!cacheServiceResult.ok) {
logger.info("Cache service unavailable - Redis not available");
return false;
}
const isAvailable = await cacheServiceResult.data.isRedisAvailable();
if (isAvailable) {
logger.info("Redis availability check successful - Redis is available");
cacheService = cacheServiceResult.data;
return true;
}
logger.info("Redis availability check failed - Redis not available");
return false;
} catch (error) {
logger.error({ error }, "Error checking Redis availability");
return false;
}
}
/**
* Cache Integration Tests - End-to-End Redis Operations
*
* This test suite verifies that cache operations work correctly through the actual
* CacheService API against a live Redis instance. These tests exercise real code paths
* that the application uses in production.
*
* Prerequisites:
* - Redis server must be running and accessible
* - REDIS_URL environment variable must be set to a valid Redis connection string
* - Tests will be automatically skipped if REDIS_URL is empty or Redis client is not available
*
* Running the tests:
* Local development: cd packages/cache && npx vitest run src/cache-integration.test.ts
* CI Environment: Tests run automatically in E2E workflow with Redis/Valkey service
*
* Test Scenarios:
*
* 1. Basic Cache Operations
* - Purpose: Verify basic get/set/del operations work correctly
* - Method: Set a value, get it, delete it, verify deletion
* - Expected: All operations succeed with correct return values
* - Failure Indicates: Basic Redis connectivity or operation issues
*
* 2. withCache Miss/Hit Pattern
* - Purpose: Verify cache-aside pattern implementation
* - Method: Call withCache twice with expensive function
* - Expected: First call executes function (miss), second call returns cached value (hit)
* - Failure Indicates: Cache miss/hit logic not working correctly
*
* 3. Cache Invalidation
* - Purpose: Verify that del() clears cache and forces recomputation
* - Method: Cache a value, invalidate it, call withCache again
* - Expected: Function executes again after invalidation
* - Failure Indicates: Cache invalidation not working
*
* 4. TTL Expiry Behavior
* - Purpose: Verify automatic cache expiration
* - Method: Set value with short TTL, wait for expiration, verify gone
* - Expected: Value expires automatically and subsequent calls recompute
* - Failure Indicates: TTL not working correctly
*
* 5. Concurrent Cache Operations
* - Purpose: Test thread safety of cache operations
* - Method: Multiple concurrent get/set operations on same key
* - Expected: No corruption, consistent behavior
* - Failure Indicates: Race conditions in cache operations
*
* 6. Different Data Types
* - Purpose: Verify serialization works for various data types
* - Method: Store objects, arrays, primitives, complex nested data
* - Expected: Data round-trips correctly without corruption
* - Failure Indicates: Serialization/deserialization issues
*
* 7. Error Handling
* - Purpose: Verify graceful error handling when Redis is unavailable
* - Method: Test operations when Redis connection is lost
* - Expected: Graceful degradation, proper error types returned
* - Failure Indicates: Poor error handling
*
* Success Indicators:
* ✅ All cache operations complete successfully
* ✅ Cache hits/misses behave as expected
* ✅ TTL expiration works correctly
* ✅ Data integrity maintained across operations
* ✅ Proper error handling when Redis unavailable
*
* Failure Indicators:
* ❌ Cache operations fail unexpectedly
* ❌ Cache hits don't work (always executing expensive operations)
* ❌ TTL not expiring keys
* ❌ Data corruption or serialization issues
* ❌ Poor error handling
*/
describe("Cache Integration Tests - End-to-End Redis Operations", () => {
beforeAll(async () => {
// Check Redis availability first
isRedisAvailable = await checkRedisAvailability();
if (!isRedisAvailable) {
logger.info("🟡 Cache Integration Tests: Redis not available - tests will be skipped");
logger.info(" To run these tests locally, ensure Redis is running and REDIS_URL is set");
return;
}
logger.info("🟢 Cache Integration Tests: Redis available - tests will run");
// Clear any existing test keys
if (cacheService) {
const redis = cacheService.getRedisClient();
if (redis) {
const testKeys = await redis.keys("fb:cache:test:*");
if (testKeys.length > 0) {
await redis.del(testKeys);
}
}
}
});
afterAll(async () => {
// Clean up test keys
if (!isRedisAvailable || !cacheService) {
logger.info("Skipping cleanup: Redis not available");
return;
}
const redis = cacheService.getRedisClient();
if (redis) {
const testKeys = await redis.keys("fb:cache:test:*");
if (testKeys.length > 0) {
await redis.del(testKeys);
}
}
});
test("Basic cache operations: set, get, exists, del", async () => {
if (!isRedisAvailable || !cacheService) {
logger.info("Skipping test: Redis not available");
return;
}
const key = createCacheKey.environment.state("basic-ops-test");
const testValue = { message: "Hello Cache!", timestamp: Date.now(), count: 42 };
// Test set operation
const setResult = await cacheService.set(key, testValue, 60000); // 60 seconds TTL
expect(setResult.ok).toBe(true);
logger.info("✅ Set operation successful");
// Test exists operation
const existsResult = await cacheService.exists(key);
expect(existsResult.ok).toBe(true);
if (existsResult.ok) {
expect(existsResult.data).toBe(true);
}
logger.info("✅ Exists operation confirmed key exists");
// Test get operation
const getResult = await cacheService.get<typeof testValue>(key);
expect(getResult.ok).toBe(true);
if (getResult.ok) {
expect(getResult.data).toEqual(testValue);
}
logger.info("✅ Get operation returned correct value");
// Test del operation
const delResult = await cacheService.del([key]);
expect(delResult.ok).toBe(true);
logger.info("✅ Del operation successful");
// Verify key no longer exists
const existsAfterDelResult = await cacheService.exists(key);
expect(existsAfterDelResult.ok).toBe(true);
if (existsAfterDelResult.ok) {
expect(existsAfterDelResult.data).toBe(false);
}
logger.info("✅ Key confirmed deleted");
// Verify get returns null after deletion
const getAfterDelResult = await cacheService.get(key);
expect(getAfterDelResult.ok).toBe(true);
if (getAfterDelResult.ok) {
expect(getAfterDelResult.data).toBe(null);
}
logger.info("✅ Get after deletion returns null");
}, 10000);
test("withCache miss/hit pattern: first call miss, second call hit", async () => {
if (!isRedisAvailable || !cacheService) {
logger.info("Skipping test: Redis not available");
return;
}
const key = createCacheKey.environment.state("miss-hit-test");
let executionCount = 0;
// Expensive function that we want to cache
const expensiveFunction = async (): Promise<{ result: string; timestamp: number; execution: number }> => {
executionCount++;
// Simulate expensive operation
await delay(10);
return {
result: "expensive computation result",
timestamp: Date.now(),
execution: executionCount,
};
};
// Clear any existing cache for this key
await cacheService.del([key]);
logger.info("First call (cache miss expected)...");
const firstCall = await cacheService.withCache(expensiveFunction, key, 60000);
expect(firstCall.execution).toBe(1);
expect(executionCount).toBe(1);
logger.info(`✅ First call executed function: execution=${firstCall.execution}`);
logger.info("Second call (cache hit expected)...");
const secondCall = await cacheService.withCache(expensiveFunction, key, 60000);
expect(secondCall.execution).toBe(1); // Should be the cached value from first call
expect(executionCount).toBe(1); // Function should not have been called again
expect(secondCall.result).toBe(firstCall.result);
logger.info(`✅ Second call returned cached value: execution=${secondCall.execution}`);
// Verify the values are identical (cache hit)
expect(secondCall).toEqual(firstCall);
logger.info("✅ Cache hit confirmed - identical values returned");
}, 15000);
test("Cache invalidation: del() clears cache and forces recomputation", async () => {
if (!isRedisAvailable || !cacheService) {
logger.info("Skipping test: Redis not available");
return;
}
const key = createCacheKey.environment.state("invalidation-test");
let executionCount = 0;
const expensiveFunction = async (): Promise<{ value: string; execution: number }> => {
executionCount++;
return {
value: `computation-${executionCount}`,
execution: executionCount,
};
};
// Clear any existing cache
await cacheService.del([key]);
logger.info("First call - populate cache...");
const firstResult = await cacheService.withCache(expensiveFunction, key, 60000);
expect(firstResult.execution).toBe(1);
expect(executionCount).toBe(1);
logger.info(`✅ Cache populated: ${firstResult.value}`);
logger.info("Second call - should hit cache...");
const secondResult = await cacheService.withCache(expensiveFunction, key, 60000);
expect(secondResult.execution).toBe(1); // Same as first call (cached)
expect(executionCount).toBe(1); // Function not executed again
expect(secondResult).toEqual(firstResult);
logger.info(`✅ Cache hit confirmed: ${secondResult.value}`);
logger.info("Invalidating cache...");
const delResult = await cacheService.del([key]);
expect(delResult.ok).toBe(true);
logger.info("✅ Cache invalidated");
logger.info("Third call after invalidation - should miss cache and recompute...");
const thirdResult = await cacheService.withCache(expensiveFunction, key, 60000);
expect(thirdResult.execution).toBe(2); // New execution
expect(executionCount).toBe(2); // Function executed again
expect(thirdResult.value).toBe("computation-2");
expect(thirdResult).not.toEqual(firstResult);
logger.info(`✅ Cache miss after invalidation confirmed: ${thirdResult.value}`);
logger.info("Fourth call - should hit cache again...");
const fourthResult = await cacheService.withCache(expensiveFunction, key, 60000);
expect(fourthResult.execution).toBe(2); // Same as third call (cached)
expect(executionCount).toBe(2); // Function not executed again
expect(fourthResult).toEqual(thirdResult);
logger.info(`✅ Cache repopulated and hit: ${fourthResult.value}`);
}, 15000);
test("TTL expiry behavior: cache expires automatically", async () => {
if (!isRedisAvailable || !cacheService) {
logger.info("Skipping test: Redis not available");
return;
}
const key = createCacheKey.environment.state("ttl-expiry-test");
let executionCount = 0;
const expensiveFunction = async (): Promise<{ value: string; execution: number }> => {
executionCount++;
return {
value: `ttl-computation-${executionCount}`,
execution: executionCount,
};
};
// Clear any existing cache
await cacheService.del([key]);
logger.info("First call with short TTL (2 seconds)...");
const firstResult = await cacheService.withCache(expensiveFunction, key, 2000); // 2 second TTL
expect(firstResult.execution).toBe(1);
expect(executionCount).toBe(1);
logger.info(`✅ Cache populated with TTL: ${firstResult.value}`);
logger.info("Second call within TTL - should hit cache...");
const secondResult = await cacheService.withCache(expensiveFunction, key, 2000);
expect(secondResult.execution).toBe(1); // Same as first call (cached)
expect(executionCount).toBe(1); // Function not executed again
expect(secondResult).toEqual(firstResult);
logger.info(`✅ Cache hit within TTL: ${secondResult.value}`);
logger.info("Waiting for TTL expiry (3 seconds)...");
await delay(3000);
logger.info("Third call after TTL expiry - should miss cache and recompute...");
const thirdResult = await cacheService.withCache(expensiveFunction, key, 2000);
expect(thirdResult.execution).toBe(2); // New execution
expect(executionCount).toBe(2); // Function executed again
expect(thirdResult.value).toBe("ttl-computation-2");
expect(thirdResult).not.toEqual(firstResult);
logger.info(`✅ Cache miss after TTL expiry confirmed: ${thirdResult.value}`);
// Verify the key was automatically removed by Redis TTL
const redis = cacheService.getRedisClient();
if (redis) {
// The old key should be gone, but there might be a new one from the third call
const currentKeys = await redis.keys(`fb:cache:${key}*`);
logger.info(`Current cache keys: ${currentKeys.length > 0 ? currentKeys.join(", ") : "none"}`);
// We expect either 0 keys (if TTL expired) or 1 key (new one from third call)
expect(currentKeys.length).toBeLessThanOrEqual(1);
}
logger.info("✅ TTL expiry working correctly");
}, 20000);
test("Concurrent cache operations: thread safety", async () => {
if (!isRedisAvailable || !cacheService) {
logger.info("Skipping test: Redis not available");
return;
}
const baseKey = "concurrent-test";
let globalExecutionCount = 0;
const expensiveFunction = async (
id: number
): Promise<{ id: number; execution: number; timestamp: number }> => {
globalExecutionCount++;
// Simulate expensive operation with variable delay
await delay(Math.random() * 50 + 10);
return {
id,
execution: globalExecutionCount,
timestamp: Date.now(),
};
};
// Clear any existing cache keys
const redis = cacheService.getRedisClient();
if (redis) {
const existingKeys = await redis.keys(`fb:cache:${baseKey}*`);
if (existingKeys.length > 0) {
await redis.del(existingKeys);
}
}
logger.info("Starting concurrent cache operations...");
// Create multiple concurrent operations on different keys
const concurrentOperations = Array.from({ length: 10 }, async (_, i) => {
const key = createCacheKey.environment.state(`${baseKey}-${i}`);
// Each "thread" makes the same call twice - first should miss, second should hit
const firstCall = await cacheService!.withCache(() => expensiveFunction(i), key, 30000);
const secondCall = await cacheService!.withCache(() => expensiveFunction(i), key, 30000);
return { i, firstCall, secondCall };
});
const results = await Promise.all(concurrentOperations);
logger.info(`Completed ${results.length} concurrent operations`);
// Verify each operation behaved correctly
results.forEach(({ i, firstCall, secondCall }) => {
// First call should have executed the function
expect(firstCall.id).toBe(i);
// Second call should return the cached value (identical to first)
expect(secondCall).toEqual(firstCall);
logger.info(`Operation ${i}: first=${firstCall.execution}, second=${secondCall.execution} (cached)`);
});
// Verify we executed exactly 10 functions (one per unique key)
expect(globalExecutionCount).toBe(10);
logger.info(
`✅ Concurrent operations completed successfully - ${globalExecutionCount} function executions`
);
}, 30000);
test("Different data types: serialization correctness", async () => {
if (!isRedisAvailable || !cacheService) {
logger.info("Skipping test: Redis not available");
return;
}
const testCases = [
{ name: "string", value: "Hello, World!" },
{ name: "number", value: 42.5 },
{ name: "boolean", value: true },
{ name: "null", value: null },
{ name: "array", value: [1, "two", { three: 3 }, null, true] },
{
name: "object",
value: {
id: 123,
name: "Test Object",
nested: {
array: [1, 2, 3],
date: new Date().toISOString(),
bool: false,
},
},
},
{
name: "complex",
value: {
users: [
{ id: 1, name: "Alice", roles: ["admin", "user"] },
{ id: 2, name: "Bob", roles: ["user"] },
],
metadata: {
version: "1.0.0",
created: new Date().toISOString(),
features: {
cache: true,
rateLimit: true,
audit: false,
},
},
},
},
];
logger.info(`Testing serialization for ${testCases.length} data types...`);
for (const testCase of testCases) {
const key = createCacheKey.environment.state(`serialization-${testCase.name}`);
logger.info(`Testing ${testCase.name} type...`);
// Set the value
const setResult = await cacheService.set(key, testCase.value, 30000);
expect(setResult.ok).toBe(true);
// Get the value back
const getResult = await cacheService.get(key);
expect(getResult.ok).toBe(true);
if (getResult.ok) {
expect(getResult.data).toEqual(testCase.value);
}
// Test through withCache as well
let functionCalled = false;
const cachedResult = await cacheService.withCache(
async () => {
functionCalled = true;
return testCase.value;
},
key,
30000
);
// Should hit cache, not call function
expect(functionCalled).toBe(false);
expect(cachedResult).toEqual(testCase.value);
logger.info(`${testCase.name} serialization successful`);
}
logger.info("✅ All data types serialized correctly");
}, 20000);
test("Error handling: graceful degradation when operations fail", async () => {
if (!isRedisAvailable || !cacheService) {
logger.info("Skipping test: Redis not available");
return;
}
// Test with invalid TTL (should handle gracefully)
const validKey = createCacheKey.environment.state("error-test");
const invalidTtl = -1000; // Negative TTL should be invalid
logger.info("Testing error handling with invalid inputs...");
const setResult = await cacheService.set(validKey, "test", invalidTtl);
expect(setResult.ok).toBe(false);
if (!setResult.ok) {
expect(setResult.error.code).toBeDefined();
logger.info(`✅ Set with invalid TTL handled gracefully: ${setResult.error.code}`);
}
// Test withCache error handling with invalid TTL
let functionCalled = false;
const withCacheResult = await cacheService.withCache(
async () => {
functionCalled = true;
return "test result";
},
validKey,
invalidTtl
);
// Function should still be called even if cache fails
expect(functionCalled).toBe(true);
expect(withCacheResult).toBe("test result");
logger.info("✅ withCache gracefully degraded to function execution when cache failed");
logger.info("✅ Error handling tests completed successfully");
}, 15000);
});

View File

@@ -1,5 +1,5 @@
import type { CacheKey } from "@/types/keys";
import { describe, expect, test } from "vitest";
import type { CacheKey } from "@/types/keys";
import { createCacheKey } from "./cache-keys";
describe("@formbricks/cache cacheKeys", () => {

View File

@@ -1,7 +1,7 @@
import type { RedisClient } from "@/types/client";
import { ErrorCode } from "@/types/error";
import { createClient } from "redis";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { RedisClient } from "@/types/client";
import { ErrorCode } from "@/types/error";
import { createRedisClientFromEnv, getCacheService, resetCacheFactory } from "./client";
// Mock the redis module

View File

@@ -1,7 +1,7 @@
import type { RedisClient } from "@/types/client";
import { type CacheError, ErrorCode, type Result, err, ok } from "@/types/error";
import { createClient } from "redis";
import { logger } from "@formbricks/logger";
import type { RedisClient } from "@/types/client";
import { type CacheError, ErrorCode, type Result, err, ok } from "@/types/error";
import { CacheService } from "./service";
/**

View File

@@ -20,6 +20,7 @@ interface MockRedisClient {
setEx: ReturnType<typeof vi.fn>;
del: ReturnType<typeof vi.fn>;
exists: ReturnType<typeof vi.fn>;
ping: ReturnType<typeof vi.fn>;
isReady: boolean;
isOpen: boolean;
}
@@ -34,6 +35,7 @@ describe("CacheService", () => {
setEx: vi.fn(),
del: vi.fn(),
exists: vi.fn(),
ping: vi.fn().mockResolvedValue("PONG"),
isReady: true,
isOpen: true,
};
@@ -469,6 +471,89 @@ describe("CacheService", () => {
});
});
describe("isRedisAvailable", () => {
test("should return true when Redis is ready, open, and ping succeeds", async () => {
mockRedis.ping.mockResolvedValue("PONG");
const result = await cacheService.isRedisAvailable();
expect(result).toBe(true);
expect(mockRedis.ping).toHaveBeenCalledOnce();
});
test("should return false when Redis is not ready", async () => {
mockRedis.isReady = false;
mockRedis.ping.mockResolvedValue("PONG");
const result = await cacheService.isRedisAvailable();
expect(result).toBe(false);
expect(mockRedis.ping).not.toHaveBeenCalled();
});
test("should return false when Redis is not open", async () => {
mockRedis.isOpen = false;
mockRedis.ping.mockResolvedValue("PONG");
const result = await cacheService.isRedisAvailable();
expect(result).toBe(false);
expect(mockRedis.ping).not.toHaveBeenCalled();
});
test("should return false when Redis ping fails", async () => {
mockRedis.ping.mockRejectedValue(new Error("Connection lost"));
const result = await cacheService.isRedisAvailable();
expect(result).toBe(false);
expect(mockRedis.ping).toHaveBeenCalledOnce();
expect(logger.debug).toHaveBeenCalledWith(
{ error: expect.any(Error) }, // eslint-disable-line @typescript-eslint/no-unsafe-assignment -- Testing error handling with any Error type
"Redis ping failed during availability check"
);
});
test("should return false when ping times out", async () => {
// Mock ping to hang indefinitely
const hangingPromise = new Promise(() => {
// This promise never resolves to simulate timeout
});
mockRedis.ping.mockImplementation(() => hangingPromise);
const result = await cacheService.isRedisAvailable();
expect(result).toBe(false);
expect(mockRedis.ping).toHaveBeenCalledOnce();
});
test("should handle different ping responses correctly", async () => {
// Test with standard PONG response
mockRedis.ping.mockResolvedValue("PONG");
let result = await cacheService.isRedisAvailable();
expect(result).toBe(true);
// Test with custom ping message
mockRedis.ping.mockResolvedValue("custom-message");
result = await cacheService.isRedisAvailable();
expect(result).toBe(true);
// Test with empty response (still success if no error thrown)
mockRedis.ping.mockResolvedValue("");
result = await cacheService.isRedisAvailable();
expect(result).toBe(true);
});
test("should be async and return Promise<boolean>", async () => {
mockRedis.ping.mockResolvedValue("PONG");
const result = cacheService.isRedisAvailable();
expect(result).toBeInstanceOf(Promise);
expect(await result).toBe(true);
});
});
describe("withCache", () => {
test("should return cached value when available", async () => {
const key = "test:key" as CacheKey;

View File

@@ -1,9 +1,9 @@
import { logger } from "@formbricks/logger";
import type { RedisClient } from "@/types/client";
import { type CacheError, CacheErrorClass, ErrorCode, type Result, err, ok } from "@/types/error";
import type { CacheKey } from "@/types/keys";
import { ZCacheKey } from "@/types/keys";
import { ZTtlMs } from "@/types/service";
import { logger } from "@formbricks/logger";
import { validateInputs } from "./utils/validation";
/**
@@ -32,7 +32,7 @@ export class CacheService {
* @returns The Redis client instance or null if not ready
*/
getRedisClient(): RedisClient | null {
if (!this.isRedisAvailable()) {
if (!this.isRedisClientReady()) {
return null;
}
return this.redis;
@@ -45,7 +45,7 @@ export class CacheService {
*/
async get<T>(key: CacheKey): Promise<Result<T | null, CacheError>> {
// Check Redis availability first
if (!this.isRedisAvailable()) {
if (!this.isRedisClientReady()) {
return err({
code: ErrorCode.RedisConnectionError,
});
@@ -90,7 +90,7 @@ export class CacheService {
*/
async exists(key: CacheKey): Promise<Result<boolean, CacheError>> {
// Check Redis availability first
if (!this.isRedisAvailable()) {
if (!this.isRedisClientReady()) {
return err({
code: ErrorCode.RedisConnectionError,
});
@@ -121,7 +121,7 @@ export class CacheService {
*/
async set(key: CacheKey, value: unknown, ttlMs: number): Promise<Result<void, CacheError>> {
// Check Redis availability first
if (!this.isRedisAvailable()) {
if (!this.isRedisClientReady()) {
return err({
code: ErrorCode.RedisConnectionError,
});
@@ -155,7 +155,7 @@ export class CacheService {
*/
async del(keys: CacheKey[]): Promise<Result<void, CacheError>> {
// Check Redis availability first
if (!this.isRedisAvailable()) {
if (!this.isRedisClientReady()) {
return err({
code: ErrorCode.RedisConnectionError,
});
@@ -192,7 +192,7 @@ export class CacheService {
* @returns Cached value if present, otherwise fresh result from fn()
*/
async withCache<T>(fn: () => Promise<T>, key: CacheKey, ttlMs: number): Promise<T> {
if (!this.isRedisAvailable()) {
if (!this.isRedisClientReady()) {
return await fn();
}
@@ -257,7 +257,29 @@ export class CacheService {
}
}
private isRedisAvailable(): boolean {
/**
* Check if Redis is available and healthy by testing connectivity with ping
* @returns Promise<boolean> indicating if Redis is available and responsive
*/
async isRedisAvailable(): Promise<boolean> {
if (!this.isRedisClientReady()) {
return false;
}
try {
await this.withTimeout(this.redis.ping());
return true;
} catch (error) {
logger.debug({ error }, "Redis ping failed during availability check");
return false;
}
}
/**
* Fast synchronous check of Redis client state for internal use
* @returns Boolean indicating if Redis client is ready and connected
*/
private isRedisClientReady(): boolean {
return this.redis.isReady && this.redis.isOpen;
}
}

View File

@@ -1,5 +1,5 @@
import type { CacheKey } from "@/types/keys";
import { describe, expect, test } from "vitest";
import type { CacheKey } from "@/types/keys";
import { makeCacheKey } from "./key";
describe("@formbricks/cache utils/key", () => {

View File

@@ -1,5 +1,5 @@
import type { CacheKey } from "@/types/keys";
import { logger } from "@formbricks/logger";
import type { CacheKey } from "@/types/keys";
/**
* Helper function to create cache keys with runtime validation

View File

@@ -1,6 +1,6 @@
import { ErrorCode } from "@/types/error";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { ErrorCode } from "@/types/error";
import { validateInputs } from "./validation";
// Mock logger

View File

@@ -1,7 +1,7 @@
import type { CacheError, Result } from "@/types/error";
import { ErrorCode, err, ok } from "@/types/error";
import type { z } from "zod";
import { logger } from "@formbricks/logger";
import type { CacheError, Result } from "@/types/error";
import { ErrorCode, err, ok } from "@/types/error";
/**
* Generic validation function using Zod schemas with Result types

View File

@@ -14,6 +14,7 @@ module.exports = {
"<THIRD_PARTY_MODULES>",
"^@formbricks/(.*)$",
"^~/(.*)$",
"^@/(.*)$",
"^[./]",
],
importOrderSeparation: false,