diff --git a/.env.example b/.env.example index 0ed48c3..916bd85 100644 --- a/.env.example +++ b/.env.example @@ -40,3 +40,11 @@ DATABASE_URL=postgresql://username:password@localhost:5432/stellarflow?sslmode=r # Rate Limiting ADMIN_IP=127.0.0.1 +# Webhook Error Reporting +# Discord webhook URL for critical alerts (primary) +DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... +# Slack webhook URL for critical alerts (fallback) +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... +# Rate limit for webhook alerts (minutes) +WEBHOOK_RATE_LIMIT_MINUTES=5 + diff --git a/src/services/marketRate/coingeckoFetcher.ts b/src/services/marketRate/coingeckoFetcher.ts index 6320a69..d605447 100644 --- a/src/services/marketRate/coingeckoFetcher.ts +++ b/src/services/marketRate/coingeckoFetcher.ts @@ -1,8 +1,10 @@ import axios from "axios"; import { withRetry } from "../../utils/retryUtil.js"; +import { createFetcherLogger } from "../../utils/logger.js"; export class CoinGeckoFetcher { private static readonly API_URL = "https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=usd"; + private static logger = createFetcherLogger("CoinGecko"); /** * Fetches the current XLM/USD price from CoinGecko. @@ -16,8 +18,9 @@ export class CoinGeckoFetcher { maxRetries: 3, retryDelay: 1000, onRetry: (attempt, error, delay) => { - console.debug( - `CoinGecko API retry attempt ${attempt}/3 after ${delay}ms. Error: ${error.message}` + CoinGeckoFetcher.logger.debug( + `API retry attempt ${attempt}/3 after ${delay}ms`, + { error: error.message, attempt, delay } ); }, } @@ -28,8 +31,19 @@ export class CoinGeckoFetcher { response.data.stellar && typeof response.data.stellar.usd === "number" ) { + CoinGeckoFetcher.logger.info( + `Successfully fetched XLM/USD price`, + { price: response.data.stellar.usd } + ); return response.data.stellar.usd; } - throw new Error("Invalid response from CoinGecko API"); + + const error = new Error("Invalid response from CoinGecko API"); + CoinGeckoFetcher.logger.fetcherError( + error, + "API response validation failed", + { responseData: response.data } + ); + throw error; } } diff --git a/src/services/marketRate/ngnFetcher.ts b/src/services/marketRate/ngnFetcher.ts index 92fd0eb..e30bae0 100644 --- a/src/services/marketRate/ngnFetcher.ts +++ b/src/services/marketRate/ngnFetcher.ts @@ -10,6 +10,7 @@ import { getNGNProviderWeight, type NGNProviderWeightKey, } from "../../config/providerWeights.js"; +import { createFetcherLogger } from "../../utils/logger.js"; type CoinGeckoPriceResponse = { stellar?: { @@ -71,6 +72,7 @@ export class NGNRateFetcher implements MarketRateFetcher { "https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=ngn,usd&include_last_updated_at=true"; private readonly usdToNgnUrl = "https://open.er-api.com/v6/latest/USD"; + private logger = createFetcherLogger("NGNRate"); private vtpassBase(): string { return (process.env.VTPASS_API_BASE_URL ?? "https://vtpass.com/api").replace( @@ -170,8 +172,8 @@ export class NGNRateFetcher implements MarketRateFetcher { }); } } - } catch { - console.debug("VTpass + CoinGecko XLM/USD failed"); + } catch (error) { + this.logger.debug("VTpass + CoinGecko XLM/USD failed", { error: error instanceof Error ? error.message : error }); } try { @@ -203,8 +205,8 @@ export class NGNRateFetcher implements MarketRateFetcher { providerKey: "coinGeckoDirectNgn", }); } - } catch { - console.debug("CoinGecko direct NGN failed"); + } catch (error) { + this.logger.debug("CoinGecko direct NGN failed", { error: error instanceof Error ? error.message : error }); } try { @@ -257,12 +259,18 @@ export class NGNRateFetcher implements MarketRateFetcher { }); } } - } catch { - console.debug("CoinGecko + ExchangeRate API (NGN) failed"); + } catch (error) { + this.logger.debug("CoinGecko + ExchangeRate API (NGN) failed", { error: error instanceof Error ? error.message : error }); } if (prices.length === 0) { - throw new Error("All NGN rate sources failed"); + const error = new Error("All NGN rate sources failed"); + this.logger.fetcherError( + error, + "All price sources failed - no rates obtained", + { attemptedSources: 3, pricesLength: prices.length } + ); + throw error; } const filteredRateValues = filterOutliers( @@ -296,8 +304,10 @@ export class NGNRateFetcher implements MarketRateFetcher { async isHealthy(): Promise { try { const rate = await this.fetchRate(); + this.logger.info("Health check passed", { rate: rate.rate, source: rate.source }); return rate.rate > 0; - } catch { + } catch (error) { + this.logger.error("Health check failed", undefined, error instanceof Error ? error : new Error(String(error))); return false; } } diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..eceb646 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,160 @@ +import { webhookReporter } from './webhookReporter'; + +export enum LogLevel { + DEBUG = 'debug', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', + CRITICAL = 'critical', +} + +interface LogEntry { + level: LogLevel; + message: string; + timestamp: Date; + fetcherName?: string; + error?: Error; + metadata?: Record; +} + +class Logger { + private serviceName: string; + + constructor(serviceName = 'StellarFlow') { + this.serviceName = serviceName; + } + + private formatLogEntry(entry: LogEntry): string { + const { level, message, timestamp, fetcherName, error, metadata } = entry; + const timestampStr = timestamp.toISOString(); + const fetcherStr = fetcherName ? ` [${fetcherName}]` : ''; + const metadataStr = metadata ? ` ${JSON.stringify(metadata)}` : ''; + const errorStr = error ? ` Error: ${error.message}` : ''; + + return `[${timestampStr}] ${level.toUpperCase()}${fetcherStr} ${message}${metadataStr}${errorStr}`; + } + + private log(level: LogLevel, message: string, fetcherName?: string, error?: Error, metadata?: Record): void { + const entry: LogEntry = { + level, + message, + timestamp: new Date(), + fetcherName, + error, + metadata, + }; + + const formattedMessage = this.formatLogEntry(entry); + + // Output to console + switch (level) { + case LogLevel.DEBUG: + console.debug(formattedMessage); + break; + case LogLevel.INFO: + console.info(formattedMessage); + break; + case LogLevel.WARN: + console.warn(formattedMessage); + break; + case LogLevel.ERROR: + console.error(formattedMessage); + break; + case LogLevel.CRITICAL: + console.error(formattedMessage); + break; + } + + // Send webhook alert for critical logs + if (level === LogLevel.CRITICAL && (fetcherName || error)) { + const errorToSend = error || message; + const fetcherToSend = fetcherName || 'Unknown'; + + // Send webhook asynchronously without blocking + webhookReporter.sendCriticalAlert(errorToSend, fetcherToSend).catch((webhookError) => { + console.error('Failed to send webhook alert:', webhookError instanceof Error ? webhookError.message : webhookError); + }); + } + } + + public debug(message: string, fetcherName?: string, metadata?: Record): void { + this.log(LogLevel.DEBUG, message, fetcherName, undefined, metadata); + } + + public info(message: string, fetcherName?: string, metadata?: Record): void { + this.log(LogLevel.INFO, message, fetcherName, undefined, metadata); + } + + public warn(message: string, fetcherName?: string, metadata?: Record): void { + this.log(LogLevel.WARN, message, fetcherName, undefined, metadata); + } + + public error(message: string, fetcherName?: string, error?: Error, metadata?: Record): void { + this.log(LogLevel.ERROR, message, fetcherName, error, metadata); + } + + public critical(message: string, fetcherName?: string, error?: Error, metadata?: Record): void { + this.log(LogLevel.CRITICAL, message, fetcherName, error, metadata); + } + + // Convenience method for fetcher failures + public fetcherError(fetcherName: string, error: Error, context?: string, metadata?: Record): void { + const message = context || `Fetcher ${fetcherName} encountered an error`; + this.critical(message, fetcherName, error, metadata); + } + + // Create a logger instance specific to a fetcher + public createFetcherLogger(fetcherName: string): FetcherLogger { + return new FetcherLogger(this, fetcherName); + } +} + +// Specialized logger for fetchers +class FetcherLogger { + private parentLogger: Logger; + private fetcherName: string; + + constructor(parentLogger: Logger, fetcherName: string) { + this.parentLogger = parentLogger; + this.fetcherName = fetcherName; + } + + public debug(message: string, metadata?: Record): void { + this.parentLogger.debug(message, this.fetcherName, metadata); + } + + public info(message: string, metadata?: Record): void { + this.parentLogger.info(message, this.fetcherName, metadata); + } + + public warn(message: string, metadata?: Record): void { + this.parentLogger.warn(message, this.fetcherName, metadata); + } + + public error(message: string, error?: Error, metadata?: Record): void { + this.parentLogger.error(message, this.fetcherName, error, metadata); + } + + public critical(message: string, error?: Error, metadata?: Record): void { + this.parentLogger.critical(message, this.fetcherName, error, metadata); + } + + public fetcherError(error: Error, context?: string, metadata?: Record): void { + this.parentLogger.fetcherError(this.fetcherName, error, context, metadata); + } + + public getFetcherName(): string { + return this.fetcherName; + } +} + +// Default logger instance +export const logger = new Logger(); + +// Export classes for creating custom loggers +export { Logger, FetcherLogger }; + +// Convenience function to create fetcher loggers +export function createFetcherLogger(fetcherName: string): FetcherLogger { + return logger.createFetcherLogger(fetcherName); +} diff --git a/src/utils/webhookReporter.ts b/src/utils/webhookReporter.ts new file mode 100644 index 0000000..3ae3e7d --- /dev/null +++ b/src/utils/webhookReporter.ts @@ -0,0 +1,254 @@ +import axios from 'axios'; + +interface WebhookMessage { + content?: string; + username?: string; + avatar_url?: string; + embeds?: Array<{ + title: string; + description: string; + color: number; + timestamp: string; + fields?: Array<{ + name: string; + value: string; + inline?: boolean; + }>; + }>; +} + +interface SlackMessage { + text: string; + username?: string; + icon_url?: string; + attachments?: Array<{ + color: string; + title: string; + text: string; + fields?: Array<{ + title: string; + value: string; + short?: boolean; + }>; + ts: number; + }>; +} + +interface AlertData { + error: Error | string; + fetcherName: string; + timestamp?: Date; +} + +class WebhookReporter { + private discordWebhookUrl: string | null; + private slackWebhookUrl: string | null; + private rateLimitMinutes: number; + private lastSentTimes: Map = new Map(); + + constructor() { + this.discordWebhookUrl = process.env.DISCORD_WEBHOOK_URL || null; + this.slackWebhookUrl = process.env.SLACK_WEBHOOK_URL || null; + this.rateLimitMinutes = parseInt(process.env.WEBHOOK_RATE_LIMIT_MINUTES || '5'); + } + + private isRateLimited(fetcherName: string): boolean { + const now = Date.now(); + const lastSent = this.lastSentTimes.get(fetcherName) || 0; + const timeDiff = now - lastSent; + const rateLimitMs = this.rateLimitMinutes * 60 * 1000; + + return timeDiff < rateLimitMs; + } + + private updateLastSent(fetcherName: string): void { + this.lastSentTimes.set(fetcherName, Date.now()); + } + + private formatDiscordMessage(data: AlertData): WebhookMessage { + const timestamp = data.timestamp || new Date(); + const errorMessage = data.error instanceof Error ? data.error.message : data.error; + const errorStack = data.error instanceof Error ? data.error.stack : ''; + + return { + username: 'StellarFlow Alert', + avatar_url: 'https://via.placeholder.com/40/FF0000/FFFFFF?text=!', + content: '🚨 **Critical Fetcher Failure**', + embeds: [ + { + title: `❌ ${data.fetcherName} Failed`, + description: 'A critical error occurred in the fetcher service.', + color: 0xFF0000, + timestamp: timestamp.toISOString(), + fields: [ + { + name: '🔧 Fetcher', + value: data.fetcherName, + inline: true, + }, + { + name: '⏰ Time', + value: timestamp.toUTCString(), + inline: true, + }, + { + name: '📝 Error', + value: `\`\`\`${errorMessage}\`\`\``, + inline: false, + }, + ...(errorStack ? [{ + name: '📚 Stack Trace', + value: `\`\`\`${errorStack.substring(0, 1000)}${errorStack.length > 1000 ? '...' : ''}\`\`\``, + inline: false, + }] : []), + ], + }, + ], + }; + } + + private formatSlackMessage(data: AlertData): SlackMessage { + const timestamp = data.timestamp || new Date(); + const errorMessage = data.error instanceof Error ? data.error.message : data.error; + const errorStack = data.error instanceof Error ? data.error.stack : ''; + + return { + text: '🚨 Critical Fetcher Failure', + username: 'StellarFlow Alert', + icon_url: 'https://via.placeholder.com/40/FF0000/FFFFFF?text=!', + attachments: [ + { + color: 'danger', + title: `❌ ${data.fetcherName} Failed`, + text: 'A critical error occurred in the fetcher service.', + fields: [ + { + title: '🔧 Fetcher', + value: data.fetcherName, + short: true, + }, + { + title: '⏰ Time', + value: timestamp.toUTCString(), + short: true, + }, + { + title: '📝 Error', + value: errorMessage, + short: false, + }, + ...(errorStack ? [{ + title: '📚 Stack Trace', + value: errorStack.substring(0, 1000) + (errorStack.length > 1000 ? '...' : ''), + short: false, + }] : []), + ], + ts: Math.floor(timestamp.getTime() / 1000), + }, + ], + }; + } + + private async sendDiscordWebhook(message: WebhookMessage): Promise { + if (!this.discordWebhookUrl) { + return false; + } + + try { + const response = await axios.post(this.discordWebhookUrl, message, { + headers: { + 'Content-Type': 'application/json', + }, + timeout: 10000, + }); + + return response.status >= 200 && response.status < 300; + } catch (error) { + console.error('Discord webhook failed:', error instanceof Error ? error.message : error); + return false; + } + } + + private async sendSlackWebhook(message: SlackMessage): Promise { + if (!this.slackWebhookUrl) { + return false; + } + + try { + const response = await axios.post(this.slackWebhookUrl, message, { + headers: { + 'Content-Type': 'application/json', + }, + timeout: 10000, + }); + + return response.status >= 200 && response.status < 300; + } catch (error) { + console.error('Slack webhook failed:', error instanceof Error ? error.message : error); + return false; + } + } + + public async sendCriticalAlert(error: Error | string, fetcherName: string): Promise { + if (this.isRateLimited(fetcherName)) { + console.log(`Webhook alert for ${fetcherName} rate limited`); + return; + } + + const alertData: AlertData = { + error, + fetcherName, + timestamp: new Date(), + }; + + let success = false; + + // Try Discord first (primary) + if (this.discordWebhookUrl) { + const discordMessage = this.formatDiscordMessage(alertData); + success = await this.sendDiscordWebhook(discordMessage); + } + + // Fallback to Slack if Discord fails + if (!success && this.slackWebhookUrl) { + const slackMessage = this.formatSlackMessage(alertData); + success = await this.sendSlackWebhook(slackMessage); + } + + if (success) { + this.updateLastSent(fetcherName); + console.log(`Critical alert sent for ${fetcherName}`); + } else { + console.error(`Failed to send critical alert for ${fetcherName}`); + } + } + + public clearRateLimit(fetcherName?: string): void { + if (fetcherName) { + this.lastSentTimes.delete(fetcherName); + } else { + this.lastSentTimes.clear(); + } + } + + public getRateLimitStatus(): Record { + const status: Record = {}; + const now = Date.now(); + const rateLimitMs = this.rateLimitMinutes * 60 * 1000; + + for (const [fetcherName, lastSent] of this.lastSentTimes.entries()) { + status[fetcherName] = { + lastSent, + canSend: (now - lastSent) >= rateLimitMs, + }; + } + + return status; + } +} + +// Singleton instance +export const webhookReporter = new WebhookReporter(); + +// Re-export for convenience +export const sendCriticalAlert = webhookReporter.sendCriticalAlert.bind(webhookReporter); diff --git a/tests/webhookReporter.test.ts b/tests/webhookReporter.test.ts new file mode 100644 index 0000000..f53352a --- /dev/null +++ b/tests/webhookReporter.test.ts @@ -0,0 +1,435 @@ +import axios from 'axios'; +import { WebhookReporter } from '../src/utils/webhookReporter'; + +// Mock axios to prevent real HTTP requests +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +// Mock console methods to prevent test output pollution +const originalConsoleError = console.error; +const originalConsoleLog = console.log; + +describe('WebhookReporter', () => { + let reporter: WebhookReporter; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Reset environment variables + originalEnv = { ...process.env }; + + // Mock console methods + console.error = jest.fn(); + console.log = jest.fn(); + + // Clear axios mocks + jest.clearAllMocks(); + + // Create fresh reporter instance + reporter = new WebhookReporter(); + + // Clear rate limits + reporter.clearRateLimit(); + }); + + afterEach(() => { + // Restore environment variables + process.env = originalEnv; + + // Restore console methods + console.error = originalConsoleError; + console.log = originalConsoleLog; + }); + + describe('sendCriticalAlert', () => { + it('should send Discord webhook when Discord URL is configured', async () => { + // Setup + process.env.DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/test'; + process.env.SLACK_WEBHOOK_URL = ''; + process.env.WEBHOOK_RATE_LIMIT_MINUTES = '5'; + + const testError = new Error('Test error message'); + const fetcherName = 'TestFetcher'; + + // Mock successful Discord response + mockedAxios.post.mockResolvedValueOnce({ + status: 204, + }); + + // Execute + await reporter.sendCriticalAlert(testError, fetcherName); + + // Verify Discord webhook was called + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/test', + expect.objectContaining({ + username: 'StellarFlow Alert', + content: '🚨 **Critical Fetcher Failure**', + embeds: expect.arrayContaining([ + expect.objectContaining({ + title: '❌ TestFetcher Failed', + color: 0xFF0000, + fields: expect.arrayContaining([ + expect.objectContaining({ + name: '🔧 Fetcher', + value: 'TestFetcher', + inline: true, + }), + expect.objectContaining({ + name: '📝 Error', + value: '```Test error message```', + inline: false, + }), + ]), + }), + ]), + }), + { + headers: { + 'Content-Type': 'application/json', + }, + timeout: 10000, + } + ); + + // Verify success log + expect(console.log).toHaveBeenCalledWith('Critical alert sent for TestFetcher'); + }); + + it('should fallback to Slack when Discord fails', async () => { + // Setup + process.env.DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/test'; + process.env.SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/test'; + process.env.WEBHOOK_RATE_LIMIT_MINUTES = '5'; + + const testError = new Error('Test error message'); + const fetcherName = 'TestFetcher'; + + // Mock Discord failure and Slack success + mockedAxios.post + .mockRejectedValueOnce(new Error('Discord failed')) + .mockResolvedValueOnce({ + status: 200, + }); + + // Execute + await reporter.sendCriticalAlert(testError, fetcherName); + + // Verify both webhooks were attempted + expect(mockedAxios.post).toHaveBeenCalledTimes(2); + + // Verify Discord was called first + expect(mockedAxios.post).toHaveBeenNthCalledWith(1, + 'https://discord.com/api/webhooks/test', + expect.any(Object), + expect.any(Object) + ); + + // Verify Slack was called as fallback + expect(mockedAxios.post).toHaveBeenNthCalledWith(2, + 'https://hooks.slack.com/services/test', + expect.objectContaining({ + text: '🚨 Critical Fetcher Failure', + username: 'StellarFlow Alert', + attachments: expect.arrayContaining([ + expect.objectContaining({ + color: 'danger', + title: '❌ TestFetcher Failed', + fields: expect.arrayContaining([ + expect.objectContaining({ + title: '🔧 Fetcher', + value: 'TestFetcher', + short: true, + }), + expect.objectContaining({ + title: '📝 Error', + value: 'Test error message', + short: false, + }), + ]), + }), + ]), + }), + expect.any(Object) + ); + + // Verify success log + expect(console.log).toHaveBeenCalledWith('Critical alert sent for TestFetcher'); + }); + + it('should handle string errors', async () => { + // Setup + process.env.DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/test'; + process.env.SLACK_WEBHOOK_URL = ''; + process.env.WEBHOOK_RATE_LIMIT_MINUTES = '5'; + + const testError = 'String error message'; + const fetcherName = 'TestFetcher'; + + // Mock successful Discord response + mockedAxios.post.mockResolvedValueOnce({ + status: 204, + }); + + // Execute + await reporter.sendCriticalAlert(testError, fetcherName); + + // Verify Discord webhook was called with string error + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/test', + expect.objectContaining({ + embeds: expect.arrayContaining([ + expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ + name: '📝 Error', + value: '```String error message```', + inline: false, + }), + ]), + }), + ]), + }), + expect.any(Object) + ); + }); + + it('should respect rate limiting', async () => { + // Setup + process.env.DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/test'; + process.env.SLACK_WEBHOOK_URL = ''; + process.env.WEBHOOK_RATE_LIMIT_MINUTES = '5'; + + const testError = new Error('Test error message'); + const fetcherName = 'TestFetcher'; + + // Mock successful Discord response + mockedAxios.post.mockResolvedValue({ + status: 204, + }); + + // Execute first alert + await reporter.sendCriticalAlert(testError, fetcherName); + + // Execute second alert immediately (should be rate limited) + await reporter.sendCriticalAlert(testError, fetcherName); + + // Verify only one webhook call was made + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + + // Verify rate limit log + expect(console.log).toHaveBeenCalledWith('Webhook alert for TestFetcher rate limited'); + }); + + it('should allow different fetchers to bypass rate limits', async () => { + // Setup + process.env.DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/test'; + process.env.SLACK_WEBHOOK_URL = ''; + process.env.WEBHOOK_RATE_LIMIT_MINUTES = '5'; + + const testError = new Error('Test error message'); + + // Mock successful Discord response + mockedAxios.post.mockResolvedValue({ + status: 204, + }); + + // Execute alerts for different fetchers + await reporter.sendCriticalAlert(testError, 'Fetcher1'); + await reporter.sendCriticalAlert(testError, 'Fetcher2'); + + // Verify both webhooks were called + expect(mockedAxios.post).toHaveBeenCalledTimes(2); + }); + + it('should handle webhook failures gracefully', async () => { + // Setup + process.env.DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/test'; + process.env.SLACK_WEBHOOK_URL = ''; + process.env.WEBHOOK_RATE_LIMIT_MINUTES = '5'; + + const testError = new Error('Test error message'); + const fetcherName = 'TestFetcher'; + + // Mock Discord failure + mockedAxios.post.mockRejectedValueOnce(new Error('Network error')); + + // Execute + await reporter.sendCriticalAlert(testError, fetcherName); + + // Verify error was logged + expect(console.error).toHaveBeenCalledWith('Failed to send critical alert for TestFetcher'); + }); + + it('should work with no webhook URLs configured', async () => { + // Setup + process.env.DISCORD_WEBHOOK_URL = ''; + process.env.SLACK_WEBHOOK_URL = ''; + process.env.WEBHOOK_RATE_LIMIT_MINUTES = '5'; + + const testError = new Error('Test error message'); + const fetcherName = 'TestFetcher'; + + // Execute + await reporter.sendCriticalAlert(testError, fetcherName); + + // Verify no HTTP requests were made + expect(mockedAxios.post).not.toHaveBeenCalled(); + + // Verify error was logged + expect(console.error).toHaveBeenCalledWith('Failed to send critical alert for TestFetcher'); + }); + }); + + describe('rate limiting', () => { + beforeEach(() => { + process.env.WEBHOOK_RATE_LIMIT_MINUTES = '1'; // 1 minute for testing + }); + + it('should allow alerts after rate limit expires', async () => { + // Setup + process.env.DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/test'; + process.env.SLACK_WEBHOOK_URL = ''; + + const testError = new Error('Test error message'); + const fetcherName = 'TestFetcher'; + + // Mock successful Discord response + mockedAxios.post.mockResolvedValue({ + status: 204, + }); + + // Execute first alert + await reporter.sendCriticalAlert(testError, fetcherName); + + // Mock time passage (more than rate limit) + const originalDateNow = Date.now; + Date.now = jest.fn(() => originalDateNow() + 2 * 60 * 1000); // 2 minutes later + + // Execute second alert after rate limit expires + await reporter.sendCriticalAlert(testError, fetcherName); + + // Verify both webhooks were called + expect(mockedAxios.post).toHaveBeenCalledTimes(2); + + // Restore Date.now + Date.now = originalDateNow; + }); + }); + + describe('utility methods', () => { + it('should clear rate limit for specific fetcher', () => { + // Setup + process.env.WEBHOOK_RATE_LIMIT_MINUTES = '5'; + + // Manually set a rate limit + const reporter = new WebhookReporter(); + (reporter as any).lastSentTimes.set('TestFetcher', Date.now()); + + // Clear specific fetcher + reporter.clearRateLimit('TestFetcher'); + + // Verify rate limit was cleared + const status = reporter.getRateLimitStatus(); + expect(status['TestFetcher']).toBeUndefined(); + }); + + it('should clear all rate limits', () => { + // Setup + process.env.WEBHOOK_RATE_LIMIT_MINUTES = '5'; + + // Manually set rate limits + const reporter = new WebhookReporter(); + (reporter as any).lastSentTimes.set('TestFetcher1', Date.now()); + (reporter as any).lastSentTimes.set('TestFetcher2', Date.now()); + + // Clear all rate limits + reporter.clearRateLimit(); + + // Verify all rate limits were cleared + const status = reporter.getRateLimitStatus(); + expect(Object.keys(status)).toHaveLength(0); + }); + + it('should provide rate limit status', () => { + // Setup + process.env.WEBHOOK_RATE_LIMIT_MINUTES = '5'; + + const reporter = new WebhookReporter(); + const now = Date.now(); + (reporter as any).lastSentTimes.set('TestFetcher', now - 60000); // 1 minute ago + + // Get status + const status = reporter.getRateLimitStatus(); + + // Verify status + expect(status['TestFetcher']).toEqual({ + lastSent: now - 60000, + canSend: false, // Still within 5 minute rate limit + }); + }); + }); + + describe('message formatting', () => { + it('should include stack trace in Discord message when available', async () => { + // Setup + process.env.DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/test'; + process.env.SLACK_WEBHOOK_URL = ''; + + const testError = new Error('Test error'); + testError.stack = 'Error: Test error\n at test.js:1:1'; + const fetcherName = 'TestFetcher'; + + // Mock successful Discord response + mockedAxios.post.mockResolvedValueOnce({ + status: 204, + }); + + // Execute + await reporter.sendCriticalAlert(testError, fetcherName); + + // Verify stack trace is included + expect(mockedAxios.post).toHaveBeenCalledWith( + 'https://discord.com/api/webhooks/test', + expect.objectContaining({ + embeds: expect.arrayContaining([ + expect.objectContaining({ + fields: expect.arrayContaining([ + expect.objectContaining({ + name: '📚 Stack Trace', + value: '```Error: Test error\n at test.js:1:1```', + inline: false, + }), + ]), + }), + ]), + }), + expect.any(Object) + ); + }); + + it('should truncate long stack traces', async () => { + // Setup + process.env.DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/test'; + process.env.SLACK_WEBHOOK_URL = ''; + + const testError = new Error('Test error'); + testError.stack = 'Error: Test error\n' + ' at test.js:1:1\n'.repeat(100); // Very long stack + const fetcherName = 'TestFetcher'; + + // Mock successful Discord response + mockedAxios.post.mockResolvedValueOnce({ + status: 204, + }); + + // Execute + await reporter.sendCriticalAlert(testError, fetcherName); + + // Verify stack trace is truncated + const call = mockedAxios.post.mock.calls[0]; + const stackTraceField = call[1].embeds[0].fields.find((f: any) => f.name === '📚 Stack Trace'); + expect(stackTraceField.value).toContain('...'); + expect(stackTraceField.value.length).toBeLessThan(1100); // Should be truncated + }); + }); +});