mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-24 03:21:20 -05:00
feat: add cache integration tests and update E2E workflow (#6551)
This commit is contained in:
committed by
GitHub
parent
c9016802e7
commit
6bc5f1e168
@@ -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" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user