From 6bc5f1e168f72c6797fdf6036e491d4b3988bacc Mon Sep 17 00:00:00 2001 From: Victor Hugo dos Santos <115753265+victorvhs017@users.noreply.github.com> Date: Fri, 19 Sep 2025 05:44:31 -0300 Subject: [PATCH] feat: add cache integration tests and update E2E workflow (#6551) --- .github/workflows/e2e.yml | 8 +- apps/web/app/api/v2/health/route.ts | 1 + .../api/v2/health/lib/health-checks.ts | 101 ++++ apps/web/modules/api/v2/health/lib/openapi.ts | 29 + .../v2/health/lib/tests/health-checks.test.ts | 288 +++++++++ apps/web/modules/api/v2/health/route.ts | 15 + .../api/v2/health/types/health-status.ts | 22 + apps/web/modules/api/v2/lib/response.ts | 30 + apps/web/modules/api/v2/openapi-document.ts | 8 + apps/web/playwright/api/constants.ts | 1 + apps/web/playwright/api/health.spec.ts | 135 +++++ docs/api-v2-reference/openapi.yml | 55 +- packages/cache/.cursor/rules/cache-package.md | 139 +++-- packages/cache/src/cache-integration.test.ts | 550 ++++++++++++++++++ packages/cache/src/cache-keys.test.ts | 2 +- packages/cache/src/client.test.ts | 4 +- packages/cache/src/client.ts | 4 +- packages/cache/src/service.test.ts | 85 +++ packages/cache/src/service.ts | 38 +- packages/cache/src/utils/key.test.ts | 2 +- packages/cache/src/utils/key.ts | 2 +- packages/cache/src/utils/validation.test.ts | 2 +- packages/cache/src/utils/validation.ts | 4 +- packages/config-prettier/prettier-preset.js | 1 + 24 files changed, 1460 insertions(+), 66 deletions(-) create mode 100644 apps/web/app/api/v2/health/route.ts create mode 100644 apps/web/modules/api/v2/health/lib/health-checks.ts create mode 100644 apps/web/modules/api/v2/health/lib/openapi.ts create mode 100644 apps/web/modules/api/v2/health/lib/tests/health-checks.test.ts create mode 100644 apps/web/modules/api/v2/health/route.ts create mode 100644 apps/web/modules/api/v2/health/types/health-status.ts create mode 100644 apps/web/playwright/api/health.spec.ts create mode 100644 packages/cache/src/cache-integration.test.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 74e2d85c4c..addd11f5aa 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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-) diff --git a/apps/web/app/api/v2/health/route.ts b/apps/web/app/api/v2/health/route.ts new file mode 100644 index 0000000000..6702deb95c --- /dev/null +++ b/apps/web/app/api/v2/health/route.ts @@ -0,0 +1 @@ +export { GET } from "@/modules/api/v2/health/route"; diff --git a/apps/web/modules/api/v2/health/lib/health-checks.ts b/apps/web/modules/api/v2/health/lib/health-checks.ts new file mode 100644 index 0000000000..a847198fe9 --- /dev/null +++ b/apps/web/modules/api/v2/health/lib/health-checks.ts @@ -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 of the database health check + */ +export const checkDatabaseHealth = async (): Promise> => { + 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 of the cache health check + */ +export const checkCacheHealth = async (): Promise> => { + 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> - Overall health status of all dependencies + */ +export const performHealthChecks = async (): Promise> => { + 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" }], + }); + } +}; diff --git a/apps/web/modules/api/v2/health/lib/openapi.ts b/apps/web/modules/api/v2/health/lib/openapi.ts new file mode 100644 index 0000000000..a8b82c79ee --- /dev/null +++ b/apps/web/modules/api/v2/health/lib/openapi.ts @@ -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, + }, +}; diff --git a/apps/web/modules/api/v2/health/lib/tests/health-checks.test.ts b/apps/web/modules/api/v2/health/lib/tests/health-checks.test.ts new file mode 100644 index 0000000000..34fe1faf8f --- /dev/null +++ b/apps/web/modules/api/v2/health/lib/tests/health-checks.test.ts @@ -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; + }); + }); +}); diff --git a/apps/web/modules/api/v2/health/route.ts b/apps/web/modules/api/v2/health/route.ts new file mode 100644 index 0000000000..1cb8b3c181 --- /dev/null +++ b/apps/web/modules/api/v2/health/route.ts @@ -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, + }); +}; diff --git a/apps/web/modules/api/v2/health/types/health-status.ts b/apps/web/modules/api/v2/health/types/health-status.ts new file mode 100644 index 0000000000..7f8a49b87d --- /dev/null +++ b/apps/web/modules/api/v2/health/types/health-status.ts @@ -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; diff --git a/apps/web/modules/api/v2/lib/response.ts b/apps/web/modules/api/v2/lib/response.ts index 4aa2689c90..4c12c063f8 100644 --- a/apps/web/modules/api/v2/lib/response.ts +++ b/apps/web/modules/api/v2/lib/response.ts @@ -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, diff --git a/apps/web/modules/api/v2/openapi-document.ts b/apps/web/modules/api/v2/openapi-document.ts index f67c6a3e1d..c08124d3a1 100644 --- a/apps/web/modules/api/v2/openapi-document.ts +++ b/apps/web/modules/api/v2/openapi-document.ts @@ -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, diff --git a/apps/web/playwright/api/constants.ts b/apps/web/playwright/api/constants.ts index 75f7d4de56..569ac5d75d 100644 --- a/apps/web/playwright/api/constants.ts +++ b/apps/web/playwright/api/constants.ts @@ -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) => diff --git a/apps/web/playwright/api/health.spec.ts b/apps/web/playwright/api/health.spec.ts new file mode 100644 index 0000000000..b0b8a1fc91 --- /dev/null +++ b/apps/web/playwright/api/health.spec.ts @@ -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; + } + }); +}); diff --git a/docs/api-v2-reference/openapi.yml b/docs/api-v2-reference/openapi.yml index ed35450d8c..1cca3e2d34 100644 --- a/docs/api-v2-reference/openapi.yml +++ b/docs/api-v2-reference/openapi.yml @@ -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 diff --git a/packages/cache/.cursor/rules/cache-package.md b/packages/cache/.cursor/rules/cache-package.md index 99d23c9851..ca06700d1e 100644 --- a/packages/cache/.cursor/rules/cache-package.md +++ b/packages/cache/.cursor/rules/cache-package.md @@ -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` 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; ``` @@ -137,10 +148,21 @@ await cacheService.exists(key): Promise> // withCache never fails - returns T directly, handles cache errors internally await cacheService.withCache(fn, key, ttlMs): Promise +// Redis availability check with ping test (standardized across codebase) +await cacheService.isRedisAvailable(): Promise + // 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` - 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> | 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>; ``` +### 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` - no throwing +1. **globalThis Singleton**: Use `getCacheService()` - cross-platform singleton +2. **Result Types**: Core ops return `Result` - 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 \ No newline at end of file +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 \ No newline at end of file diff --git a/packages/cache/src/cache-integration.test.ts b/packages/cache/src/cache-integration.test.ts new file mode 100644 index 0000000000..cda34d29e2 --- /dev/null +++ b/packages/cache/src/cache-integration.test.ts @@ -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 => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +// Test Redis availability +async function checkRedisAvailability(): Promise { + 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(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); +}); diff --git a/packages/cache/src/cache-keys.test.ts b/packages/cache/src/cache-keys.test.ts index 9c36bffa39..e1fdb7fa71 100644 --- a/packages/cache/src/cache-keys.test.ts +++ b/packages/cache/src/cache-keys.test.ts @@ -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", () => { diff --git a/packages/cache/src/client.test.ts b/packages/cache/src/client.test.ts index e1ca1fd120..42273cd5cd 100644 --- a/packages/cache/src/client.test.ts +++ b/packages/cache/src/client.test.ts @@ -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 diff --git a/packages/cache/src/client.ts b/packages/cache/src/client.ts index 94747422f1..f4d1e68e1f 100644 --- a/packages/cache/src/client.ts +++ b/packages/cache/src/client.ts @@ -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"; /** diff --git a/packages/cache/src/service.test.ts b/packages/cache/src/service.test.ts index b21db7b74f..24cf65cadf 100644 --- a/packages/cache/src/service.test.ts +++ b/packages/cache/src/service.test.ts @@ -20,6 +20,7 @@ interface MockRedisClient { setEx: ReturnType; del: ReturnType; exists: ReturnType; + ping: ReturnType; 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", 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; diff --git a/packages/cache/src/service.ts b/packages/cache/src/service.ts index 9326a52e92..322a0d8604 100644 --- a/packages/cache/src/service.ts +++ b/packages/cache/src/service.ts @@ -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(key: CacheKey): Promise> { // 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> { // 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> { // 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> { // 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(fn: () => Promise, key: CacheKey, ttlMs: number): Promise { - 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 indicating if Redis is available and responsive + */ + async isRedisAvailable(): Promise { + 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; } } diff --git a/packages/cache/src/utils/key.test.ts b/packages/cache/src/utils/key.test.ts index e7547b4535..b84b252b18 100644 --- a/packages/cache/src/utils/key.test.ts +++ b/packages/cache/src/utils/key.test.ts @@ -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", () => { diff --git a/packages/cache/src/utils/key.ts b/packages/cache/src/utils/key.ts index 69b883fdc3..8c44fc1424 100644 --- a/packages/cache/src/utils/key.ts +++ b/packages/cache/src/utils/key.ts @@ -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 diff --git a/packages/cache/src/utils/validation.test.ts b/packages/cache/src/utils/validation.test.ts index 8271666f14..6af9976570 100644 --- a/packages/cache/src/utils/validation.test.ts +++ b/packages/cache/src/utils/validation.test.ts @@ -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 diff --git a/packages/cache/src/utils/validation.ts b/packages/cache/src/utils/validation.ts index 0a852cc326..6417617aa7 100644 --- a/packages/cache/src/utils/validation.ts +++ b/packages/cache/src/utils/validation.ts @@ -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 diff --git a/packages/config-prettier/prettier-preset.js b/packages/config-prettier/prettier-preset.js index 6f09a0d70f..d012e0eb80 100644 --- a/packages/config-prettier/prettier-preset.js +++ b/packages/config-prettier/prettier-preset.js @@ -14,6 +14,7 @@ module.exports = { "", "^@formbricks/(.*)$", "^~/(.*)$", + "^@/(.*)$", "^[./]", ], importOrderSeparation: false,