Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,27 @@
"contracts:fmt": "make -C contracts/hashport-account fmt",
"prepare": "husky"
},
"dependencies": {
"@stellar/stellar-sdk": "^16.0.1",
"fastify": "^5.10.0",
"ioredis": "^5.11.1",
"pg": "^8.22.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^20.19.43",
"@types/pg": "^8.20.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"husky": "^9.1.7",
"lint-staged": "^17.0.8",
"prettier": "^3.9.5"
"pino-pretty": "^13.1.3",
"prettier": "^3.9.5",
"tsx": "^4.23.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.63.0",
"vitest": "^4.1.10"
},
"lint-staged": {
"apps/backend/**/*.ts": [
Expand Down
73 changes: 68 additions & 5 deletions apps/backend/src/routes/health.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,73 @@
import type { FastifyInstance } from "fastify";
import { Client } from "pg";
import { Redis } from "ioredis";
import { env } from "../config/env.js";

async function checkPostgres(url: string, timeoutMs = 2000): Promise<boolean> {
const client = new Client({ connectionString: url });
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), timeoutMs),
);

try {
await Promise.race([client.connect(), timeoutPromise]);
await Promise.race([client.query("SELECT 1"), timeoutPromise]);
return true;
} catch {
return false;
} finally {
await client.end().catch(() => {});
}
}

async function checkRedis(url: string, timeoutMs = 2000): Promise<boolean> {
const client = new Redis(url, {
lazyConnect: true,
connectTimeout: timeoutMs,
maxRetriesPerRequest: 0,
});

const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), timeoutMs),
);

try {
await Promise.race([client.connect(), timeoutPromise]);
const ping = await Promise.race([client.ping(), timeoutPromise]);
return ping === "PONG";
} catch {
return false;
} finally {
client.disconnect();
}
}

export async function healthRoutes(app: FastifyInstance) {
app.get("/health", async () => ({
status: "ok" as const,
network: env.stellar.network,
uptime: process.uptime(),
}));
app.get("/health", async (request, reply) => {
const checkInfra = process.env.ENABLE_INFRA_HEALTH_CHECK === "true";

let dbStatus: "ok" | "error" = "ok";
let redisStatus: "ok" | "error" = "ok";

if (checkInfra) {
const [dbOk, redisOk] = await Promise.all([
checkPostgres(env.databaseUrl),
checkRedis(env.redisUrl),
]);
dbStatus = dbOk ? "ok" : "error";
redisStatus = redisOk ? "ok" : "error";
}

const overallStatus =
dbStatus === "ok" && redisStatus === "ok" ? "ok" : "error";
const statusCode = overallStatus === "ok" ? 200 : 503;

return reply.status(statusCode).send({
status: overallStatus,
checks: {
db: dbStatus,
redis: redisStatus,
},
});
});
}
45 changes: 42 additions & 3 deletions apps/backend/src/routes/webhook.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,48 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../config/env.js";
import { verifyWebhookSignature } from "../lib/signature.js";
import { parseCommand } from "../lib/commands.js";
import { sendText } from "../services/whatsapp.js";
import { handleCommand } from "../services/router.js";
import type { WhatsAppWebhookPayload } from "../types/index.js";

const whatsappInboundMessageSchema = z.object({
from: z.string(),
id: z.string(),
timestamp: z.string(),
type: z.string(),
text: z
.object({
body: z.string(),
})
.optional(),
});

export const whatsappWebhookPayloadSchema = z.object({
object: z.string(),
entry: z
.array(
z.object({
id: z.string(),
changes: z
.array(
z.object({
field: z.string(),
value: z.object({
messaging_product: z.string(),
metadata: z.object({
display_phone_number: z.string(),
phone_number_id: z.string(),
}),
messages: z.array(whatsappInboundMessageSchema).optional(),
}),
}),
)
.optional(),
}),
)
.optional(),
});

/**
* WhatsApp Cloud API webhook.
Expand Down Expand Up @@ -37,7 +75,8 @@ export async function webhookRoutes(app: FastifyInstance) {
return reply.code(401).send({ error: "bad signature" });
}

const payload = request.body as WhatsAppWebhookPayload;
const payload = whatsappWebhookPayloadSchema.parse(request.body);

const messages =
payload.entry?.flatMap(
(entry) => entry.changes?.flatMap((change) => change.value.messages ?? []) ?? [],
Expand All @@ -55,4 +94,4 @@ export async function webhookRoutes(app: FastifyInstance) {
// Always 200 quickly — Meta retries and eventually disables slow webhooks.
return reply.code(200).send({ received: true });
});
}
}
149 changes: 149 additions & 0 deletions apps/backend/test/health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

// Define mock instances at the top level
const mockPgClient = {
connect: vi.fn(),
query: vi.fn(),
end: vi.fn(),
};

const mockRedisClient = {
connect: vi.fn(),
ping: vi.fn(),
disconnect: vi.fn(),
};

// Mock modules using ES6 class constructor syntax that returns our mock instances
vi.mock("pg", () => {
return {
Client: class {
constructor() {
return mockPgClient;
}
},
};
});

vi.mock("ioredis", () => {
return {
Redis: class {
constructor() {
return mockRedisClient;
}
},
};
});

import { buildApp } from "../src/app.js";

describe("GET /health", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.ENABLE_INFRA_HEALTH_CHECK;

// Set default success implementations
mockPgClient.connect.mockResolvedValue(undefined);
mockPgClient.query.mockResolvedValue({ rows: [] });
mockPgClient.end.mockResolvedValue(undefined);

mockRedisClient.connect.mockResolvedValue(undefined);
mockRedisClient.ping.mockResolvedValue("PONG");
});

it("returns ok status when ENABLE_INFRA_HEALTH_CHECK is disabled", async () => {
const app = buildApp();
try {
const response = await app.inject({
method: "GET",
url: "/health",
});

expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
status: "ok",
checks: {
db: "ok",
redis: "ok",
},
});
} finally {
await app.close();
}
});

it("returns 200 when both db and redis are healthy with ENABLE_INFRA_HEALTH_CHECK=true", async () => {
process.env.ENABLE_INFRA_HEALTH_CHECK = "true";

const app = buildApp();
try {
const response = await app.inject({
method: "GET",
url: "/health",
});

expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
status: "ok",
checks: {
db: "ok",
redis: "ok",
},
});

expect(mockPgClient.connect).toHaveBeenCalled();
expect(mockPgClient.query).toHaveBeenCalledWith("SELECT 1");
expect(mockRedisClient.connect).toHaveBeenCalled();
expect(mockRedisClient.ping).toHaveBeenCalled();
} finally {
await app.close();
}
});

it("returns 503 when db query fails with ENABLE_INFRA_HEALTH_CHECK=true", async () => {
process.env.ENABLE_INFRA_HEALTH_CHECK = "true";
mockPgClient.query.mockRejectedValue(new Error("Query failed"));

const app = buildApp();
try {
const response = await app.inject({
method: "GET",
url: "/health",
});

expect(response.statusCode).toBe(503);
expect(response.json()).toEqual({
status: "error",
checks: {
db: "error",
redis: "ok",
},
});
} finally {
await app.close();
}
});

it("returns 503 when redis ping fails with ENABLE_INFRA_HEALTH_CHECK=true", async () => {
process.env.ENABLE_INFRA_HEALTH_CHECK = "true";
mockRedisClient.ping.mockRejectedValue(new Error("Ping failed"));

const app = buildApp();
try {
const response = await app.inject({
method: "GET",
url: "/health",
});

expect(response.statusCode).toBe(503);
expect(response.json()).toEqual({
status: "error",
checks: {
db: "ok",
redis: "error",
},
});
} finally {
await app.close();
}
});
});
Loading
Loading