mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 16:16:21 -06:00
feat: add cache integration tests and update E2E workflow (#6551)
This commit is contained in:
committed by
GitHub
parent
c9016802e7
commit
6bc5f1e168
8
.github/workflows/e2e.yml
vendored
8
.github/workflows/e2e.yml
vendored
@@ -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-)
|
||||
|
||||
1
apps/web/app/api/v2/health/route.ts
Normal file
1
apps/web/app/api/v2/health/route.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { GET } from "@/modules/api/v2/health/route";
|
||||
101
apps/web/modules/api/v2/health/lib/health-checks.ts
Normal file
101
apps/web/modules/api/v2/health/lib/health-checks.ts
Normal 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" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
29
apps/web/modules/api/v2/health/lib/openapi.ts
Normal file
29
apps/web/modules/api/v2/health/lib/openapi.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
288
apps/web/modules/api/v2/health/lib/tests/health-checks.test.ts
Normal file
288
apps/web/modules/api/v2/health/lib/tests/health-checks.test.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
15
apps/web/modules/api/v2/health/route.ts
Normal file
15
apps/web/modules/api/v2/health/route.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
22
apps/web/modules/api/v2/health/types/health-status.ts
Normal file
22
apps/web/modules/api/v2/health/types/health-status.ts
Normal 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>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
135
apps/web/playwright/api/health.spec.ts
Normal file
135
apps/web/playwright/api/health.spec.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
139
packages/cache/.cursor/rules/cache-package.md
vendored
139
packages/cache/.cursor/rules/cache-package.md
vendored
@@ -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
|
||||
550
packages/cache/src/cache-integration.test.ts
vendored
Normal file
550
packages/cache/src/cache-integration.test.ts
vendored
Normal 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);
|
||||
});
|
||||
2
packages/cache/src/cache-keys.test.ts
vendored
2
packages/cache/src/cache-keys.test.ts
vendored
@@ -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", () => {
|
||||
|
||||
4
packages/cache/src/client.test.ts
vendored
4
packages/cache/src/client.test.ts
vendored
@@ -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
|
||||
|
||||
4
packages/cache/src/client.ts
vendored
4
packages/cache/src/client.ts
vendored
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
85
packages/cache/src/service.test.ts
vendored
85
packages/cache/src/service.test.ts
vendored
@@ -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;
|
||||
|
||||
38
packages/cache/src/service.ts
vendored
38
packages/cache/src/service.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
2
packages/cache/src/utils/key.test.ts
vendored
2
packages/cache/src/utils/key.test.ts
vendored
@@ -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", () => {
|
||||
|
||||
2
packages/cache/src/utils/key.ts
vendored
2
packages/cache/src/utils/key.ts
vendored
@@ -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
|
||||
|
||||
2
packages/cache/src/utils/validation.test.ts
vendored
2
packages/cache/src/utils/validation.test.ts
vendored
@@ -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
|
||||
|
||||
4
packages/cache/src/utils/validation.ts
vendored
4
packages/cache/src/utils/validation.ts
vendored
@@ -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
|
||||
|
||||
@@ -14,6 +14,7 @@ module.exports = {
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@formbricks/(.*)$",
|
||||
"^~/(.*)$",
|
||||
"^@/(.*)$",
|
||||
"^[./]",
|
||||
],
|
||||
importOrderSeparation: false,
|
||||
|
||||
Reference in New Issue
Block a user