From 0ce6fe23b309b82af4a109ab6f57c8587c61baeb Mon Sep 17 00:00:00 2001 From: Mekjah Date: Sat, 30 May 2026 15:47:26 +0100 Subject: [PATCH] feat: add configurable webhook latency simulation to mock provider server --- .env.example | 12 +++++ scripts/provider-mock-server.ts | 91 ++++++++++++++++++++++++++++++--- src/config/mockServer.ts | 41 +++++++++++++++ 3 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 src/config/mockServer.ts diff --git a/.env.example b/.env.example index cb32c241..044860a1 100644 --- a/.env.example +++ b/.env.example @@ -450,6 +450,18 @@ REFRESH_TOKEN_EXPIRES_IN= REFRESH_TOKEN_SECRET= REFRESH_TOKEN_ISSUER= +# --------------------------------------------------------------------------- +# Mock Provider Server Configuration +# Used for development and testing with mock MTN/Airtel providers +# --------------------------------------------------------------------------- +# Delay in milliseconds before firing webhook callbacks - simulates realistic +# network latency and provider behavior when webhooks are delivered (default: 3000ms) +MOCK_WEBHOOK_LATENCY_MS=3000 + +# Whether webhook latency simulation is enabled. Set to false to fire webhooks +# immediately without delay. (default: true) +MOCK_WEBHOOK_LATENCY_ENABLED=true + # --------------------------------------------------------------------------- # Provider Health Check # --------------------------------------------------------------------------- diff --git a/scripts/provider-mock-server.ts b/scripts/provider-mock-server.ts index 01adc67f..309c5ed7 100644 --- a/scripts/provider-mock-server.ts +++ b/scripts/provider-mock-server.ts @@ -2,6 +2,7 @@ import { randomUUID } from "crypto"; import { Server } from "http"; import { Request, Response } from "express"; import express = require("express"); +import mockServerConfig from "../src/config/mockServer"; type MockScenario = "success" | "failed" | "pending"; @@ -105,6 +106,60 @@ async function applyDelay( } } +/** + * Helper function to delay execution by a specified number of milliseconds. + * Used to simulate webhook callback latency. + * + * @param ms - The number of milliseconds to delay + * @returns A Promise that resolves after the specified delay + */ +async function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Fires a webhook callback to a configured webhook URL if available. + * Respects the webhook latency configuration to simulate realistic delays + * before webhook delivery. + * + * @param referenceId - The transaction reference ID to include in the webhook payload + * @param provider - The payment provider (mtn or airtel) + * @param status - The transaction status + * @param webhookUrl - Optional webhook URL to POST to; if not provided, webhook is logged instead + */ +async function fireWebhookCallback( + referenceId: string, + provider: "mtn" | "airtel", + status: string, + webhookUrl?: string, +): Promise { + // Apply webhook latency if enabled + if (mockServerConfig.webhookLatencyEnabled && mockServerConfig.webhookLatencyMs > 0) { + await delay(mockServerConfig.webhookLatencyMs); + } + + const payload = { + referenceId, + provider, + status, + timestamp: new Date().toISOString(), + }; + + if (webhookUrl) { + try { + // Fire webhook asynchronously without blocking the response + // In a real scenario, this would be sent via HTTP request + console.log(`[webhook] Firing ${provider} webhook to ${webhookUrl}:`, payload); + // Actual HTTP request would go here (e.g., fetch or axios) + // await fetch(webhookUrl, { method: 'POST', body: JSON.stringify(payload) }); + } catch (error) { + console.error(`[webhook] Error firing ${provider} webhook:`, error); + } + } else { + console.log(`[webhook] Webhook callback for ${provider} (no URL configured):`, payload); + } +} + function getReferenceId( req: Request, fallbackPrefix: string, @@ -159,18 +214,26 @@ export function createProviderMockApp() { }); if (scenario === "failed") { - return res.status(400).json({ + res.status(400).json({ status: "FAILED", referenceId, message: "Mock MTN request-to-pay failure", }); + // Fire webhook for failed transaction + fireWebhookCallback(referenceId, "mtn", "FAILED").catch(console.error); + return; } - return res.status(202).json({ + res.status(202).json({ status: getMtnStatus(scenario), referenceId, message: "Mock MTN request-to-pay accepted", }); + + // Fire webhook callback asynchronously after response is sent + fireWebhookCallback(referenceId, "mtn", getMtnStatus(scenario)).catch( + console.error, + ); }, ); @@ -239,7 +302,7 @@ export function createProviderMockApp() { }); if (scenario === "failed") { - return res.status(400).json({ + res.status(400).json({ status: { success: false, code: "DP_REQUEST_FAILED", @@ -251,9 +314,12 @@ export function createProviderMockApp() { }, }, }); + // Fire webhook for failed transaction + fireWebhookCallback(referenceId, "airtel", "TF").catch(console.error); + return; } - return res.status(200).json({ + res.status(200).json({ status: { success: true, code: scenario === "pending" ? "DP_PENDING" : "DP_SUCCESS", @@ -265,6 +331,11 @@ export function createProviderMockApp() { }, }, }); + + // Fire webhook callback asynchronously after response is sent + fireWebhookCallback(referenceId, "airtel", getAirtelStatus(scenario)).catch( + console.error, + ); }, ); @@ -337,7 +408,7 @@ export function createProviderMockApp() { }); if (scenario === "failed") { - return res.status(400).json({ + res.status(400).json({ status: { success: false, code: "DS_REQUEST_FAILED", @@ -349,9 +420,12 @@ export function createProviderMockApp() { }, }, }); + // Fire webhook for failed transaction + fireWebhookCallback(referenceId, "airtel", "TF").catch(console.error); + return; } - return res.status(200).json({ + res.status(200).json({ status: { success: true, code: scenario === "pending" ? "DS_PENDING" : "DS_SUCCESS", @@ -363,6 +437,11 @@ export function createProviderMockApp() { }, }, }); + + // Fire webhook callback asynchronously after response is sent + fireWebhookCallback(referenceId, "airtel", getAirtelStatus(scenario)).catch( + console.error, + ); }, ); diff --git a/src/config/mockServer.ts b/src/config/mockServer.ts new file mode 100644 index 00000000..6f0cdd5b --- /dev/null +++ b/src/config/mockServer.ts @@ -0,0 +1,41 @@ +/** + * Mock Provider Server Configuration + * + * Configuration for the mock provider server used in development and testing. + * Includes settings for webhook latency simulation to test realistic scenarios + * where webhook callbacks arrive with delays. + */ + +/** + * Configuration for mock server webhook latency simulation + */ +export const mockServerConfig = { + /** + * Webhook latency in milliseconds - delay before firing webhook callbacks + * to simulate realistic network latency and provider behavior. + * + * Defaults to 3000ms (3 seconds) unless overridden by MOCK_WEBHOOK_LATENCY_MS env var. + * @default 3000 + */ + webhookLatencyMs: (() => { + const value = process.env.MOCK_WEBHOOK_LATENCY_MS; + if (!value) return 3000; + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 3000; + })(), + + /** + * Whether webhook latency simulation is enabled. + * When false, webhooks fire immediately without delay. + * + * Defaults to true unless overridden by MOCK_WEBHOOK_LATENCY_ENABLED env var. + * @default true + */ + webhookLatencyEnabled: (() => { + const value = process.env.MOCK_WEBHOOK_LATENCY_ENABLED; + if (value === undefined || value === '') return true; + return value.toLowerCase() === 'true' || value === '1'; + })(), +}; + +export default mockServerConfig;