diff --git a/.gitignore b/.gitignore index d723b8f..6a0d3a8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,14 @@ test-results/ dist/ *.tsbuildinfo +# TypeScript compiled test files +test/**/*.d.ts +test/**/*.d.ts.map +test/**/*.js.map +# Don't ignore the actual test files +!test/**/*.test.js +!test/**/*.spec.js + # Temporary files tmp/ temp/ diff --git a/jest.config.js b/jest.config.js index 662b477..ffcfc94 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,8 +8,7 @@ module.exports = { ], transform: { '^.+\\.ts$': ['ts-jest', { - useESM: false, - tsconfig: 'tsconfig.json' + isolatedModules: true }], '^.+\\.js$': 'babel-jest' }, diff --git a/package-lock.json b/package-lock.json index 103ec84..e9ffa68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@types/express": "^5.0.2", "@types/jest": "^29.5.14", "@types/node": "^22.15.23", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^8.33.0", "@typescript-eslint/parser": "^8.33.0", "babel-jest": "^29.7.0", @@ -3122,6 +3123,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -3215,6 +3223,13 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3269,6 +3284,30 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", diff --git a/package.json b/package.json index 4880f47..75a5308 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@types/express": "^5.0.2", "@types/jest": "^29.5.14", "@types/node": "^22.15.23", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^8.33.0", "@typescript-eslint/parser": "^8.33.0", "babel-jest": "^29.7.0", diff --git a/src/controllers/chatbotController.js b/src/controllers/chatbotController.js index 2be5fb5..935bf6d 100644 --- a/src/controllers/chatbotController.js +++ b/src/controllers/chatbotController.js @@ -12,7 +12,7 @@ const logger = createLogger('chatbotController'); async function handleChatbotWebhook(req, res, providerName) { try { const startTime = Date.now(); - + logger.info( { provider: providerName, @@ -80,7 +80,7 @@ async function handleChatbotWebhook(req, res, providerName) { let messageContext; try { messageContext = provider.parseWebhookPayload(req.body); - + logger.info( { provider: providerName, @@ -202,15 +202,15 @@ async function handleChatbotWebhook(req, res, providerName) { // Extract repository and branch from message context (for Discord slash commands) const repoFullName = messageContext.repo || null; const branchName = messageContext.branch || 'main'; - + // Validate required repository parameter if (!repoFullName) { const errorMessage = sanitizeBotMentions( '❌ **Repository Required**: Please specify a repository using the `repo` parameter.\n\n' + - '**Example:** `/claude repo:owner/repository command:fix this issue`' + '**Example:** `/claude repo:owner/repository command:fix this issue`' ); await provider.sendResponse(messageContext, errorMessage); - + return res.status(400).json({ success: false, error: 'Repository parameter is required', @@ -348,7 +348,6 @@ async function handleDiscordWebhook(req, res) { return await handleChatbotWebhook(req, res, 'discord'); } - /** * Get provider status and statistics */ @@ -385,4 +384,4 @@ module.exports = { handleChatbotWebhook, handleDiscordWebhook, getProviderStats -}; \ No newline at end of file +}; diff --git a/src/index.ts b/src/index.ts index 8019878..f87b8e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,7 +44,7 @@ const webhookRateLimit = rateLimit({ }, standardHeaders: true, legacyHeaders: false, - skip: (_req) => { + skip: _req => { // Skip rate limiting in test environment return process.env['NODE_ENV'] === 'test'; } diff --git a/src/providers/ChatbotProvider.js b/src/providers/ChatbotProvider.js index baf63f8..e667ab1 100644 --- a/src/providers/ChatbotProvider.js +++ b/src/providers/ChatbotProvider.js @@ -81,9 +81,10 @@ class ChatbotProvider { isUserAuthorized(userId) { if (!userId) return false; - const authorizedUsers = this.config.authorizedUsers || - process.env.AUTHORIZED_USERS?.split(',').map(u => u.trim()) || - [process.env.DEFAULT_AUTHORIZED_USER || 'admin']; + const authorizedUsers = this.config.authorizedUsers || + process.env.AUTHORIZED_USERS?.split(',').map(u => u.trim()) || [ + process.env.DEFAULT_AUTHORIZED_USER || 'admin' + ]; return authorizedUsers.includes(userId); } @@ -105,4 +106,4 @@ class ChatbotProvider { } } -module.exports = ChatbotProvider; \ No newline at end of file +module.exports = ChatbotProvider; diff --git a/src/providers/DiscordProvider.js b/src/providers/DiscordProvider.js index 4ac6055..2615d30 100644 --- a/src/providers/DiscordProvider.js +++ b/src/providers/DiscordProvider.js @@ -24,8 +24,10 @@ class DiscordProvider extends ChatbotProvider { async initialize() { try { this.botToken = secureCredentials.get('DISCORD_BOT_TOKEN') || process.env.DISCORD_BOT_TOKEN; - this.publicKey = secureCredentials.get('DISCORD_PUBLIC_KEY') || process.env.DISCORD_PUBLIC_KEY; - this.applicationId = secureCredentials.get('DISCORD_APPLICATION_ID') || process.env.DISCORD_APPLICATION_ID; + this.publicKey = + secureCredentials.get('DISCORD_PUBLIC_KEY') || process.env.DISCORD_PUBLIC_KEY; + this.applicationId = + secureCredentials.get('DISCORD_APPLICATION_ID') || process.env.DISCORD_APPLICATION_ID; if (!this.botToken || !this.publicKey) { throw new Error('Discord bot token and public key are required'); @@ -90,49 +92,50 @@ class DiscordProvider extends ChatbotProvider { try { // Handle Discord interaction types switch (payload.type) { - case 1: // PING - return { - type: 'ping', - shouldRespond: true, - responseData: { type: 1 } // PONG - }; + case 1: // PING + return { + type: 'ping', + shouldRespond: true, + responseData: { type: 1 } // PONG + }; - case 2: { // APPLICATION_COMMAND - const repoInfo = this.extractRepoAndBranch(payload.data); - return { - type: 'command', - command: payload.data?.name, - options: payload.data?.options || [], - channelId: payload.channel_id, - guildId: payload.guild_id, - userId: payload.member?.user?.id || payload.user?.id, - username: payload.member?.user?.username || payload.user?.username, - content: this.buildCommandContent(payload.data), - interactionToken: payload.token, - interactionId: payload.id, - repo: repoInfo.repo, - branch: repoInfo.branch - }; - } + case 2: { + // APPLICATION_COMMAND + const repoInfo = this.extractRepoAndBranch(payload.data); + return { + type: 'command', + command: payload.data?.name, + options: payload.data?.options || [], + channelId: payload.channel_id, + guildId: payload.guild_id, + userId: payload.member?.user?.id || payload.user?.id, + username: payload.member?.user?.username || payload.user?.username, + content: this.buildCommandContent(payload.data), + interactionToken: payload.token, + interactionId: payload.id, + repo: repoInfo.repo, + branch: repoInfo.branch + }; + } - case 3: // MESSAGE_COMPONENT - return { - type: 'component', - customId: payload.data?.custom_id, - channelId: payload.channel_id, - guildId: payload.guild_id, - userId: payload.member?.user?.id || payload.user?.id, - username: payload.member?.user?.username || payload.user?.username, - interactionToken: payload.token, - interactionId: payload.id - }; + case 3: // MESSAGE_COMPONENT + return { + type: 'component', + customId: payload.data?.custom_id, + channelId: payload.channel_id, + guildId: payload.guild_id, + userId: payload.member?.user?.id || payload.user?.id, + username: payload.member?.user?.username || payload.user?.username, + interactionToken: payload.token, + interactionId: payload.id + }; - default: - logger.warn({ type: payload.type }, 'Unknown Discord interaction type'); - return { - type: 'unknown', - shouldRespond: false - }; + default: + logger.warn({ type: payload.type }, 'Unknown Discord interaction type'); + return { + type: 'unknown', + shouldRespond: false + }; } } catch (error) { logger.error({ err: error }, 'Error parsing Discord webhook payload'); @@ -148,9 +151,7 @@ class DiscordProvider extends ChatbotProvider { let content = commandData.name; if (commandData.options && commandData.options.length > 0) { - const args = commandData.options - .map(option => `${option.name}:${option.value}`) - .join(' '); + const args = commandData.options.map(option => `${option.name}:${option.value}`).join(' '); content += ` ${args}`; } return content; @@ -169,7 +170,7 @@ class DiscordProvider extends ChatbotProvider { // Only default to 'main' if we have a repo but no branch const repo = repoOption ? repoOption.value : null; - const branch = branchOption ? branchOption.value : (repo ? 'main' : null); + const branch = branchOption ? branchOption.value : repo ? 'main' : null; return { repo, branch }; } @@ -233,20 +234,24 @@ class DiscordProvider extends ChatbotProvider { */ async sendFollowUpMessage(interactionToken, content) { const url = `https://discord.com/api/v10/webhooks/${this.applicationId}/${interactionToken}`; - + // Split long messages to respect Discord's 2000 character limit const messages = this.splitLongMessage(content, 2000); - + for (const message of messages) { - await axios.post(url, { - content: message, - flags: 0 // Make message visible to everyone - }, { - headers: { - 'Authorization': `Bot ${this.botToken}`, - 'Content-Type': 'application/json' + await axios.post( + url, + { + content: message, + flags: 0 // Make message visible to everyone + }, + { + headers: { + Authorization: `Bot ${this.botToken}`, + 'Content-Type': 'application/json' + } } - }); + ); } } @@ -255,19 +260,23 @@ class DiscordProvider extends ChatbotProvider { */ async sendChannelMessage(channelId, content) { const url = `https://discord.com/api/v10/channels/${channelId}/messages`; - + // Split long messages to respect Discord's 2000 character limit const messages = this.splitLongMessage(content, 2000); - + for (const message of messages) { - await axios.post(url, { - content: message - }, { - headers: { - 'Authorization': `Bot ${this.botToken}`, - 'Content-Type': 'application/json' + await axios.post( + url, + { + content: message + }, + { + headers: { + Authorization: `Bot ${this.botToken}`, + 'Content-Type': 'application/json' + } } - }); + ); } } @@ -328,10 +337,12 @@ class DiscordProvider extends ChatbotProvider { */ formatErrorMessage(error, errorId) { const timestamp = new Date().toISOString(); - return '🚫 **Error Processing Command**\n\n' + - `**Reference ID:** \`${errorId}\`\n` + - `**Time:** ${timestamp}\n\n` + - 'Please contact an administrator with the reference ID above.'; + return ( + '🚫 **Error Processing Command**\n\n' + + `**Reference ID:** \`${errorId}\`\n` + + `**Time:** ${timestamp}\n\n` + + 'Please contact an administrator with the reference ID above.' + ); } /** @@ -343,4 +354,4 @@ class DiscordProvider extends ChatbotProvider { } } -module.exports = DiscordProvider; \ No newline at end of file +module.exports = DiscordProvider; diff --git a/src/providers/ProviderFactory.js b/src/providers/ProviderFactory.js index 2c7d234..457a108 100644 --- a/src/providers/ProviderFactory.js +++ b/src/providers/ProviderFactory.js @@ -12,7 +12,7 @@ class ProviderFactory { this.providers = new Map(); this.providerClasses = new Map(); this.defaultConfig = {}; - + // Register built-in providers this.registerProvider('discord', DiscordProvider); } @@ -35,7 +35,7 @@ class ProviderFactory { */ async createProvider(name, config = {}) { const providerName = name.toLowerCase(); - + // Check if provider is already created if (this.providers.has(providerName)) { return this.providers.get(providerName); @@ -53,7 +53,7 @@ class ProviderFactory { try { // Merge with default config const finalConfig = { ...this.defaultConfig, ...config }; - + // Create and initialize provider const provider = new ProviderClass(finalConfig); await provider.initialize(); @@ -62,20 +62,20 @@ class ProviderFactory { this.providers.set(providerName, provider); logger.info( - { + { provider: name, config: Object.keys(finalConfig) - }, + }, 'Created and initialized chatbot provider' ); return provider; } catch (error) { logger.error( - { + { err: error, - provider: name - }, + provider: name + }, 'Failed to create provider' ); throw new Error(`Failed to create ${name} provider: ${error.message}`); @@ -113,10 +113,7 @@ class ProviderFactory { */ setDefaultConfig(config) { this.defaultConfig = { ...config }; - logger.info( - { configKeys: Object.keys(config) }, - 'Set default provider configuration' - ); + logger.info({ configKeys: Object.keys(config) }, 'Set default provider configuration'); } /** @@ -127,7 +124,7 @@ class ProviderFactory { */ async updateProviderConfig(name, config) { const providerName = name.toLowerCase(); - + // Remove existing provider to force recreation with new config if (this.providers.has(providerName)) { this.providers.delete(providerName); @@ -146,7 +143,7 @@ class ProviderFactory { async createFromEnvironment(name) { const providerName = name.toLowerCase(); const config = this.getEnvironmentConfig(providerName); - + return await this.createProvider(name, config); } @@ -157,18 +154,22 @@ class ProviderFactory { */ getEnvironmentConfig(providerName) { const config = {}; - + // Provider-specific environment variables switch (providerName) { - case 'discord': - config.botToken = process.env.DISCORD_BOT_TOKEN; - config.publicKey = process.env.DISCORD_PUBLIC_KEY; - config.applicationId = process.env.DISCORD_APPLICATION_ID; - config.authorizedUsers = process.env.DISCORD_AUTHORIZED_USERS?.split(',').map(u => u.trim()); - config.botMention = process.env.DISCORD_BOT_MENTION; - break; - default: - throw new Error(`Unsupported provider: ${providerName}. Only 'discord' is currently supported.`); + case 'discord': + config.botToken = process.env.DISCORD_BOT_TOKEN; + config.publicKey = process.env.DISCORD_PUBLIC_KEY; + config.applicationId = process.env.DISCORD_APPLICATION_ID; + config.authorizedUsers = process.env.DISCORD_AUTHORIZED_USERS?.split(',').map(u => + u.trim() + ); + config.botMention = process.env.DISCORD_BOT_MENTION; + break; + default: + throw new Error( + `Unsupported provider: ${providerName}. Only 'discord' is currently supported.` + ); } // Remove undefined values @@ -197,20 +198,17 @@ class ProviderFactory { } catch (error) { errors.push({ provider: name, error: error.message }); logger.error( - { + { err: error, - provider: name - }, + provider: name + }, 'Failed to create provider in batch' ); } } if (errors.length > 0) { - logger.warn( - { errors, successCount: results.size }, - 'Some providers failed to initialize' - ); + logger.warn({ errors, successCount: results.size }, 'Some providers failed to initialize'); } return results; @@ -220,11 +218,8 @@ class ProviderFactory { * Clean up all providers */ async cleanup() { - logger.info( - { providerCount: this.providers.size }, - 'Cleaning up chatbot providers' - ); - + logger.info({ providerCount: this.providers.size }, 'Cleaning up chatbot providers'); + this.providers.clear(); logger.info('All providers cleaned up'); } @@ -248,4 +243,4 @@ class ProviderFactory { // Create singleton instance const factory = new ProviderFactory(); -module.exports = factory; \ No newline at end of file +module.exports = factory; diff --git a/src/routes/chatbot.js b/src/routes/chatbot.js index 9f70874..cc4a2a6 100644 --- a/src/routes/chatbot.js +++ b/src/routes/chatbot.js @@ -15,7 +15,7 @@ const chatbotLimiter = rateLimit({ }, standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers - skip: (_req) => { + skip: _req => { // Skip rate limiting in test environment return process.env.NODE_ENV === 'test'; } @@ -27,4 +27,4 @@ router.post('/discord', chatbotLimiter, chatbotController.handleDiscordWebhook); // Provider statistics endpoint router.get('/stats', chatbotController.getProviderStats); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/src/services/claudeService.ts b/src/services/claudeService.ts index e2eb482..1429a28 100644 --- a/src/services/claudeService.ts +++ b/src/services/claudeService.ts @@ -227,12 +227,12 @@ For real functionality, please configure valid GitHub and Claude API tokens.`; */ function getEntrypointScript(operationType: OperationType): string { switch (operationType) { - case 'auto-tagging': - return '/scripts/runtime/claudecode-tagging-entrypoint.sh'; - case 'pr-review': - case 'default': - default: - return '/scripts/runtime/claudecode-entrypoint.sh'; + case 'auto-tagging': + return '/scripts/runtime/claudecode-tagging-entrypoint.sh'; + case 'pr-review': + case 'default': + default: + return '/scripts/runtime/claudecode-entrypoint.sh'; } } diff --git a/src/services/githubService.ts b/src/services/githubService.ts index 8a8e2d4..ad8e90e 100644 --- a/src/services/githubService.ts +++ b/src/services/githubService.ts @@ -596,10 +596,10 @@ export async function getCheckSuitesForRef({ conclusion: suite.conclusion, app: suite.app ? { - id: suite.app.id, - slug: suite.app.slug, - name: suite.app.name - } + id: suite.app.id, + slug: suite.app.slug, + name: suite.app.name + } : null, pull_requests: null, // Simplified for our use case created_at: suite.created_at, diff --git a/src/utils/awsCredentialProvider.ts b/src/utils/awsCredentialProvider.ts index 523c842..18665b2 100644 --- a/src/utils/awsCredentialProvider.ts +++ b/src/utils/awsCredentialProvider.ts @@ -217,7 +217,9 @@ class AWSCredentialProvider { const escapedProfileName = profileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const profileRegex = new RegExp(`\\[${escapedProfileName}\\]([^\\[]*)`); const credentialsMatch = credentialsContent.match(profileRegex); - const configMatch = configContent.match(new RegExp(`\\[profile ${escapedProfileName}\\]([^\\[]*)`)); + const configMatch = configContent.match( + new RegExp(`\\[profile ${escapedProfileName}\\]([^\\[]*)`) + ); if (!credentialsMatch && !configMatch) { const error = new Error(`Profile '${profileName}' not found`) as AWSCredentialError; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 5feec5d..951ba89 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -18,33 +18,33 @@ const logFileName = path.join(logsDir, 'app.log'); // Configure different transports based on environment const transport = isProduction ? { - targets: [ - // File transport for production - { - target: 'pino/file', - options: { destination: logFileName, mkdir: true } - }, - // Console pretty transport - { - target: 'pino-pretty', - options: { - colorize: true, - levelFirst: true, - translateTime: 'SYS:standard' + targets: [ + // File transport for production + { + target: 'pino/file', + options: { destination: logFileName, mkdir: true } }, - level: 'info' - } - ] - } - : { - // Just use pretty logs in development - target: 'pino-pretty', - options: { - colorize: true, - levelFirst: true, - translateTime: 'SYS:standard' + // Console pretty transport + { + target: 'pino-pretty', + options: { + colorize: true, + levelFirst: true, + translateTime: 'SYS:standard' + }, + level: 'info' + } + ] } - }; + : { + // Just use pretty logs in development + target: 'pino-pretty', + options: { + colorize: true, + levelFirst: true, + translateTime: 'SYS:standard' + } + }; // Configure the logger const logger = pino({ diff --git a/test/e2e/scenarios/chatbot-integration.test.js b/test/e2e/scenarios/chatbot-integration.test.js index 88dbda7..d3141e1 100644 --- a/test/e2e/scenarios/chatbot-integration.test.js +++ b/test/e2e/scenarios/chatbot-integration.test.js @@ -16,14 +16,16 @@ describe('Chatbot Integration Tests', () => { beforeEach(() => { app = express(); - + // Middleware to capture raw body for signature verification - app.use(bodyParser.json({ - verify: (req, res, buf) => { - req.rawBody = buf; - } - })); - + app.use( + bodyParser.json({ + verify: (req, res, buf) => { + req.rawBody = buf; + } + }) + ); + // Mount chatbot routes app.use('/api/webhooks/chatbot', chatbotRoutes); @@ -51,7 +53,7 @@ describe('Chatbot Integration Tests', () => { it('should handle Discord slash command webhook', async () => { chatbotController.handleDiscordWebhook.mockImplementation((req, res) => { - res.status(200).json({ + res.status(200).json({ success: true, message: 'Command processed successfully', context: { @@ -113,10 +115,7 @@ describe('Chatbot Integration Tests', () => { id: 'interaction_id' }; - await request(app) - .post('/api/webhooks/chatbot/discord') - .send(componentPayload) - .expect(200); + await request(app).post('/api/webhooks/chatbot/discord').send(componentPayload).expect(200); expect(chatbotController.handleDiscordWebhook).toHaveBeenCalledTimes(1); }); @@ -128,15 +127,12 @@ describe('Chatbot Integration Tests', () => { res.status(200).json({ success: true }); }); - await request(app) - .post('/api/webhooks/chatbot/discord') - .send({ type: 1 }); + await request(app).post('/api/webhooks/chatbot/discord').send({ type: 1 }); expect(chatbotController.handleDiscordWebhook).toHaveBeenCalledTimes(1); }); }); - describe('Provider stats endpoint', () => { it('should return provider statistics', async () => { chatbotController.getProviderStats.mockImplementation((req, res) => { @@ -159,9 +155,7 @@ describe('Chatbot Integration Tests', () => { }); }); - const response = await request(app) - .get('/api/webhooks/chatbot/stats') - .expect(200); + const response = await request(app).get('/api/webhooks/chatbot/stats').expect(200); expect(chatbotController.getProviderStats).toHaveBeenCalledTimes(1); expect(response.body.success).toBe(true); @@ -177,9 +171,7 @@ describe('Chatbot Integration Tests', () => { }); }); - const response = await request(app) - .get('/api/webhooks/chatbot/stats') - .expect(500); + const response = await request(app).get('/api/webhooks/chatbot/stats').expect(500); expect(response.body.error).toBe('Failed to get provider statistics'); }); @@ -206,7 +198,6 @@ describe('Chatbot Integration Tests', () => { expect(response.body.provider).toBe('discord'); }); - it('should handle invalid JSON payloads', async () => { // This test ensures that malformed JSON is handled by Express const response = await request(app) @@ -255,17 +246,16 @@ describe('Chatbot Integration Tests', () => { type: 2, data: { name: 'claude', - options: [{ - name: 'command', - value: 'A'.repeat(2000) // Large command - }] + options: [ + { + name: 'command', + value: 'A'.repeat(2000) // Large command + } + ] } }; - await request(app) - .post('/api/webhooks/chatbot/discord') - .send(largePayload) - .expect(200); + await request(app).post('/api/webhooks/chatbot/discord').send(largePayload).expect(200); }); }); -}); \ No newline at end of file +}); diff --git a/test/e2e/utils/containerExecutor.js b/test/e2e/utils/containerExecutor.js index 57759d4..89edff9 100644 --- a/test/e2e/utils/containerExecutor.js +++ b/test/e2e/utils/containerExecutor.js @@ -202,7 +202,7 @@ class ContainerExecutor { return this.exec({ entrypoint: '/bin/bash', command: - 'echo \'=== AWS files ===\'; ls -la /home/node/.aws/; echo \'=== Config content ===\'; cat /home/node/.aws/config; echo \'=== Test AWS profile ===\'; export AWS_PROFILE=claude-webhook; export AWS_CONFIG_FILE=/home/node/.aws/config; export AWS_SHARED_CREDENTIALS_FILE=/home/node/.aws/credentials; aws sts get-caller-identity --profile claude-webhook', + "echo '=== AWS files ==='; ls -la /home/node/.aws/; echo '=== Config content ==='; cat /home/node/.aws/config; echo '=== Test AWS profile ==='; export AWS_PROFILE=claude-webhook; export AWS_CONFIG_FILE=/home/node/.aws/config; export AWS_SHARED_CREDENTIALS_FILE=/home/node/.aws/credentials; aws sts get-caller-identity --profile claude-webhook", volumes: [`${homeDir}/.aws:/home/node/.aws:ro`], ...options }); diff --git a/test/unit/controllers/chatbotController.test.js b/test/unit/controllers/chatbotController.test.js index 44b01c8..3ad9603 100644 --- a/test/unit/controllers/chatbotController.test.js +++ b/test/unit/controllers/chatbotController.test.js @@ -52,7 +52,11 @@ describe('chatbotController', () => { sendResponse: jest.fn().mockResolvedValue(), getUserId: jest.fn(), isUserAuthorized: jest.fn().mockReturnValue(true), - formatErrorMessage: jest.fn().mockReturnValue('🚫 **Error Processing Command**\n\n**Reference ID:** `test-error-id`\n**Time:** 2023-01-01T00:00:00.000Z\n\nPlease contact an administrator with the reference ID above.'), + formatErrorMessage: jest + .fn() + .mockReturnValue( + '🚫 **Error Processing Command**\n\n**Reference ID:** `test-error-id`\n**Time:** 2023-01-01T00:00:00.000Z\n\nPlease contact an administrator with the reference ID above.' + ), getProviderName: jest.fn().mockReturnValue('DiscordProvider'), getBotMention: jest.fn().mockReturnValue('@claude') }; @@ -111,10 +115,12 @@ describe('chatbotController', () => { }); expect(mockProvider.sendResponse).toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - success: true, - message: 'Command processed successfully' - })); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + message: 'Command processed successfully' + }) + ); }); it('should return 401 for invalid webhook signature', async () => { @@ -224,7 +230,7 @@ describe('chatbotController', () => { content: 'help me', userId: 'user123', username: 'testuser', - repo: null, // No repo provided + repo: null, // No repo provided branch: null }); mockProvider.extractBotCommand.mockReturnValue({ @@ -239,10 +245,12 @@ describe('chatbotController', () => { expect.stringContaining('Repository Required') ); expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - success: false, - error: 'Repository parameter is required' - })); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: 'Repository parameter is required' + }) + ); expect(claudeService.processCommand).not.toHaveBeenCalled(); }); @@ -259,7 +267,7 @@ describe('chatbotController', () => { command: 'help me' }); mockProvider.getUserId.mockReturnValue('user123'); - + claudeService.processCommand.mockRejectedValue(new Error('Claude service error')); await chatbotController.handleChatbotWebhook(req, res, 'discord'); @@ -269,10 +277,12 @@ describe('chatbotController', () => { expect.stringContaining('🚫 **Error Processing Command**') ); expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - success: false, - error: 'Failed to process command' - })); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: 'Failed to process command' + }) + ); }); it('should handle provider initialization failure', async () => { @@ -310,10 +320,12 @@ describe('chatbotController', () => { await chatbotController.handleChatbotWebhook(req, res, 'discord'); expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - error: 'Provider initialization failed', - message: 'Unexpected error' - })); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Provider initialization failed', + message: 'Unexpected error' + }) + ); }); }); @@ -333,7 +345,6 @@ describe('chatbotController', () => { }); }); - describe('getProviderStats', () => { it('should return provider statistics successfully', async () => { await chatbotController.getProviderStats(req, res); @@ -371,4 +382,4 @@ describe('chatbotController', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/test/unit/index-simple.test.ts b/test/unit/index-simple.test.ts new file mode 100644 index 0000000..bea8553 --- /dev/null +++ b/test/unit/index-simple.test.ts @@ -0,0 +1,97 @@ +// Test the Express app initialization and error handling +import express from 'express'; +import request from 'supertest'; + +describe('Express App Error Handling', () => { + let app: express.Application; + const mockLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn() + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create a minimal app with error handling + app = express(); + app.use(express.json()); + + // Add test route that can trigger errors + app.get('/test-error', (_req, _res, next) => { + next(new Error('Test error')); + }); + + // Add the error handler from index.ts + app.use( + (err: Error, req: express.Request, res: express.Response, _next: express.NextFunction) => { + mockLogger.error( + { + err: { + message: err.message, + stack: err.stack + }, + method: req.method, + url: req.url + }, + 'Request error' + ); + res.status(500).json({ error: 'Internal server error' }); + } + ); + }); + + it('should handle errors with error middleware', async () => { + const response = await request(app).get('/test-error'); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'Internal server error' }); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: { + message: 'Test error', + stack: expect.any(String) + }, + method: 'GET', + url: '/test-error' + }), + 'Request error' + ); + }); + + it('should handle JSON parsing errors', async () => { + const response = await request(app) + .post('/api/test') + .set('Content-Type', 'application/json') + .send('invalid json'); + + expect(response.status).toBe(400); + }); +}); + +describe('Express App Docker Checks', () => { + const mockExecSync = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mock('child_process', () => ({ + execSync: mockExecSync + })); + }); + + it('should handle docker check errors properly', () => { + mockExecSync.mockImplementation((cmd: string) => { + if (cmd.includes('docker ps')) { + throw new Error('Docker daemon not running'); + } + if (cmd.includes('docker image inspect')) { + throw new Error(''); + } + return Buffer.from(''); + }); + + // Test Docker error is caught + expect(() => mockExecSync('docker ps')).toThrow('Docker daemon not running'); + }); +}); diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts new file mode 100644 index 0000000..e9d3ba5 --- /dev/null +++ b/test/unit/index.test.ts @@ -0,0 +1,296 @@ +import express from 'express'; +import type { Request, Response } from 'express'; +import request from 'supertest'; + +// Mock all dependencies before any imports +jest.mock('dotenv/config', () => ({})); +jest.mock('../../src/utils/logger', () => ({ + createLogger: jest.fn(() => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn() + })) +})); +jest.mock('../../src/utils/startup-metrics', () => ({ + StartupMetrics: jest.fn().mockImplementation(() => ({ + startTime: Date.now(), + milestones: [], + ready: false, + recordMilestone: jest.fn(), + metricsMiddleware: jest.fn(() => (req: any, res: any, next: any) => next()), + markReady: jest.fn(() => 150), + getMetrics: jest.fn(() => ({ + isReady: true, + totalElapsed: 1000, + milestones: {}, + startTime: Date.now() - 1000 + })) + })) +})); +jest.mock('../../src/routes/github', () => { + const router = express.Router(); + router.post('/', (req: Request, res: Response) => res.status(200).send('github')); + return router; +}); +jest.mock('../../src/routes/claude', () => { + const router = express.Router(); + router.post('/', (req: Request, res: Response) => res.status(200).send('claude')); + return router; +}); +jest.mock('child_process', () => ({ + execSync: jest.fn() +})); + +describe('Express Application', () => { + let app: express.Application; + const originalEnv = process.env; + const mockExecSync = require('child_process').execSync as jest.Mock; + const mockLogger = require('../../src/utils/logger').createLogger(); + const mockStartupMetrics = new (require('../../src/utils/startup-metrics').StartupMetrics)(); + + // Mock express listen to prevent actual server start + const mockListen = jest.fn((port: number, callback?: () => void) => { + if (callback) { + setTimeout(callback, 0); + } + return { + close: jest.fn((cb?: () => void) => cb && cb()), + listening: true + }; + }); + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + process.env.NODE_ENV = 'test'; + process.env.PORT = '3004'; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + const getApp = () => { + // Clear the module cache + jest.resetModules(); + + // Re-mock modules for fresh import + jest.mock('../../src/utils/logger', () => ({ + createLogger: jest.fn(() => mockLogger) + })); + jest.mock('../../src/utils/startup-metrics', () => ({ + StartupMetrics: jest.fn(() => mockStartupMetrics) + })); + + // Mock express.application.listen + const express = require('express'); + express.application.listen = mockListen; + + // Import the app + require('../../src/index'); + + // Get the app instance from the mocked listen call + return mockListen.mock.contexts[0] as express.Application; + }; + + describe('Initialization', () => { + it('should initialize with default port when PORT is not set', () => { + delete process.env.PORT; + getApp(); + + expect(mockListen).toHaveBeenCalledWith(3003, expect.any(Function)); + expect(mockStartupMetrics.recordMilestone).toHaveBeenCalledWith( + 'env_loaded', + 'Environment variables loaded' + ); + }); + + it('should record startup milestones', () => { + getApp(); + + expect(mockStartupMetrics.recordMilestone).toHaveBeenCalledWith( + 'env_loaded', + 'Environment variables loaded' + ); + expect(mockStartupMetrics.recordMilestone).toHaveBeenCalledWith( + 'express_initialized', + 'Express app initialized' + ); + expect(mockStartupMetrics.recordMilestone).toHaveBeenCalledWith( + 'middleware_configured', + 'Express middleware configured' + ); + expect(mockStartupMetrics.recordMilestone).toHaveBeenCalledWith( + 'routes_configured', + 'API routes configured' + ); + }); + }); + + describe('Middleware', () => { + it('should log requests', async () => { + app = getApp(); + await request(app).get('/health'); + + // Wait for response to complete + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: '/health', + statusCode: 200, + responseTime: expect.stringMatching(/\d+ms/) + }), + 'GET /health' + ); + }); + + it('should apply rate limiting configuration', () => { + app = getApp(); + // Rate limiting is configured but skipped in test mode + expect(app).toBeDefined(); + }); + }); + + describe('Routes', () => { + it('should mount GitHub webhook routes', async () => { + app = getApp(); + const response = await request(app).post('/api/webhooks/github').send({}); + + expect(response.status).toBe(200); + expect(response.text).toBe('github'); + }); + + it('should mount Claude API routes', async () => { + app = getApp(); + const response = await request(app).post('/api/claude').send({}); + + expect(response.status).toBe(200); + expect(response.text).toBe('claude'); + }); + }); + + describe('Health Check Endpoint', () => { + it('should return health status when everything is working', async () => { + mockExecSync.mockImplementation(() => Buffer.from('')); + mockStartupMetrics.getMetrics.mockReturnValue({ + isReady: true, + totalElapsed: 1000, + milestones: {}, + startTime: Date.now() - 1000 + }); + + app = getApp(); + const response = await request(app).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + status: 'ok', + timestamp: expect.any(String), + docker: { + available: true, + error: null, + checkTime: expect.any(Number) + }, + claudeCodeImage: { + available: true, + error: null, + checkTime: expect.any(Number) + } + }); + }); + + it('should return degraded status when Docker is not available', async () => { + mockExecSync.mockImplementation((cmd: string) => { + if (cmd.includes('docker ps')) { + throw new Error('Docker not available'); + } + return Buffer.from(''); + }); + + app = getApp(); + const response = await request(app).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + status: 'degraded', + docker: { + available: false, + error: 'Docker not available' + } + }); + }); + + it('should return degraded status when Claude image is not available', async () => { + mockExecSync.mockImplementation((cmd: string) => { + if (cmd.includes('docker image inspect')) { + throw new Error('Image not found'); + } + return Buffer.from(''); + }); + + app = getApp(); + const response = await request(app).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + status: 'degraded', + claudeCodeImage: { + available: false, + error: 'Image not found' + } + }); + }); + }); + + describe('Test Tunnel Endpoint', () => { + it('should return tunnel test response', async () => { + app = getApp(); + const response = await request(app) + .get('/api/test-tunnel') + .set('X-Test-Header', 'test-value'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + status: 'success', + message: 'CF tunnel is working!', + timestamp: expect.any(String), + headers: expect.objectContaining({ + 'x-test-header': 'test-value' + }) + }); + + expect(mockLogger.info).toHaveBeenCalledWith('Test tunnel endpoint hit'); + }); + }); + + describe('Error Handling', () => { + it('should handle 404 errors', async () => { + app = getApp(); + const response = await request(app).get('/non-existent-route'); + + expect(response.status).toBe(404); + }); + }); + + describe('Server Startup', () => { + it('should start server and record ready milestone', done => { + getApp(); + + // Wait for the callback to be executed + setTimeout(() => { + expect(mockStartupMetrics.recordMilestone).toHaveBeenCalledWith( + 'server_listening', + expect.stringContaining('Server listening on port') + ); + expect(mockStartupMetrics.markReady).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('Server running on port') + ); + done(); + }, 100); + }); + }); +}); diff --git a/test/unit/providers/ChatbotProvider.test.js b/test/unit/providers/ChatbotProvider.test.js index 83b1bf4..571e448 100644 --- a/test/unit/providers/ChatbotProvider.test.js +++ b/test/unit/providers/ChatbotProvider.test.js @@ -25,23 +25,33 @@ describe('ChatbotProvider', () => { describe('abstract methods', () => { it('should throw error for initialize()', async () => { - await expect(provider.initialize()).rejects.toThrow('initialize() must be implemented by subclass'); + await expect(provider.initialize()).rejects.toThrow( + 'initialize() must be implemented by subclass' + ); }); it('should throw error for verifyWebhookSignature()', () => { - expect(() => provider.verifyWebhookSignature({})).toThrow('verifyWebhookSignature() must be implemented by subclass'); + expect(() => provider.verifyWebhookSignature({})).toThrow( + 'verifyWebhookSignature() must be implemented by subclass' + ); }); it('should throw error for parseWebhookPayload()', () => { - expect(() => provider.parseWebhookPayload({})).toThrow('parseWebhookPayload() must be implemented by subclass'); + expect(() => provider.parseWebhookPayload({})).toThrow( + 'parseWebhookPayload() must be implemented by subclass' + ); }); it('should throw error for extractBotCommand()', () => { - expect(() => provider.extractBotCommand('')).toThrow('extractBotCommand() must be implemented by subclass'); + expect(() => provider.extractBotCommand('')).toThrow( + 'extractBotCommand() must be implemented by subclass' + ); }); it('should throw error for sendResponse()', async () => { - await expect(provider.sendResponse({}, '')).rejects.toThrow('sendResponse() must be implemented by subclass'); + await expect(provider.sendResponse({}, '')).rejects.toThrow( + 'sendResponse() must be implemented by subclass' + ); }); it('should throw error for getUserId()', () => { @@ -53,9 +63,9 @@ describe('ChatbotProvider', () => { it('should format error message with reference ID and timestamp', () => { const error = new Error('Test error'); const errorId = 'test-123'; - + const message = provider.formatErrorMessage(error, errorId); - + expect(message).toContain('❌ An error occurred'); expect(message).toContain('Reference: test-123'); expect(message).toContain('Please check with an administrator'); @@ -81,28 +91,28 @@ describe('ChatbotProvider', () => { it('should use environment variables when no config provided', () => { const originalEnv = process.env.AUTHORIZED_USERS; process.env.AUTHORIZED_USERS = 'envuser1,envuser2'; - + const envProvider = new ChatbotProvider(); - + expect(envProvider.isUserAuthorized('envuser1')).toBe(true); expect(envProvider.isUserAuthorized('envuser2')).toBe(true); expect(envProvider.isUserAuthorized('unauthorized')).toBe(false); - + process.env.AUTHORIZED_USERS = originalEnv; }); it('should use default authorized user when no config or env provided', () => { const originalUsers = process.env.AUTHORIZED_USERS; const originalDefault = process.env.DEFAULT_AUTHORIZED_USER; - + delete process.env.AUTHORIZED_USERS; process.env.DEFAULT_AUTHORIZED_USER = 'defaultuser'; - + const defaultProvider = new ChatbotProvider(); - + expect(defaultProvider.isUserAuthorized('defaultuser')).toBe(true); expect(defaultProvider.isUserAuthorized('other')).toBe(false); - + process.env.AUTHORIZED_USERS = originalUsers; process.env.DEFAULT_AUTHORIZED_USER = originalDefault; }); @@ -110,15 +120,15 @@ describe('ChatbotProvider', () => { it('should fallback to admin when no config provided', () => { const originalUsers = process.env.AUTHORIZED_USERS; const originalDefault = process.env.DEFAULT_AUTHORIZED_USER; - + delete process.env.AUTHORIZED_USERS; delete process.env.DEFAULT_AUTHORIZED_USER; - + const fallbackProvider = new ChatbotProvider(); - + expect(fallbackProvider.isUserAuthorized('admin')).toBe(true); expect(fallbackProvider.isUserAuthorized('other')).toBe(false); - + process.env.AUTHORIZED_USERS = originalUsers; process.env.DEFAULT_AUTHORIZED_USER = originalDefault; }); @@ -138,22 +148,22 @@ describe('ChatbotProvider', () => { it('should return bot mention from environment variable', () => { const originalEnv = process.env.BOT_USERNAME; process.env.BOT_USERNAME = '@envbot'; - + const envProvider = new ChatbotProvider(); - + expect(envProvider.getBotMention()).toBe('@envbot'); - + process.env.BOT_USERNAME = originalEnv; }); it('should return default bot mention when no config provided', () => { const originalEnv = process.env.BOT_USERNAME; delete process.env.BOT_USERNAME; - + const defaultProvider = new ChatbotProvider(); - + expect(defaultProvider.getBotMention()).toBe('@ClaudeBot'); - + process.env.BOT_USERNAME = originalEnv; }); }); @@ -223,4 +233,4 @@ describe('ChatbotProvider inheritance', () => { expect(testProvider.isUserAuthorized).toBeDefined(); expect(testProvider.formatErrorMessage).toBeDefined(); }); -}); \ No newline at end of file +}); diff --git a/test/unit/providers/DiscordProvider.test.js b/test/unit/providers/DiscordProvider.test.js index 56afa32..41f20d6 100644 --- a/test/unit/providers/DiscordProvider.test.js +++ b/test/unit/providers/DiscordProvider.test.js @@ -24,13 +24,13 @@ describe('DiscordProvider', () => { beforeEach(() => { originalEnv = { ...process.env }; - + // Mock credentials - mockSecureCredentials.get.mockImplementation((key) => { + mockSecureCredentials.get.mockImplementation(key => { const mockCreds = { - 'DISCORD_BOT_TOKEN': 'mock_bot_token', - 'DISCORD_PUBLIC_KEY': '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', - 'DISCORD_APPLICATION_ID': '123456789012345678' + DISCORD_BOT_TOKEN: 'mock_bot_token', + DISCORD_PUBLIC_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + DISCORD_APPLICATION_ID: '123456789012345678' }; return mockCreds[key]; }); @@ -52,7 +52,9 @@ describe('DiscordProvider', () => { it('should initialize successfully with valid credentials', async () => { await expect(provider.initialize()).resolves.toBeUndefined(); expect(provider.botToken).toBe('mock_bot_token'); - expect(provider.publicKey).toBe('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'); + expect(provider.publicKey).toBe( + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + ); expect(provider.applicationId).toBe('123456789012345678'); }); @@ -74,7 +76,9 @@ describe('DiscordProvider', () => { delete process.env.DISCORD_BOT_TOKEN; delete process.env.DISCORD_PUBLIC_KEY; - await expect(provider.initialize()).rejects.toThrow('Discord bot token and public key are required'); + await expect(provider.initialize()).rejects.toThrow( + 'Discord bot token and public key are required' + ); }); }); @@ -89,14 +93,14 @@ describe('DiscordProvider', () => { }); it('should return false when only timestamp is present', () => { - const req = { + const req = { headers: { 'x-signature-timestamp': '1234567890' } }; expect(provider.verifyWebhookSignature(req)).toBe(false); }); it('should return false when only signature is present', () => { - const req = { + const req = { headers: { 'x-signature-ed25519': 'some_signature' } }; expect(provider.verifyWebhookSignature(req)).toBe(false); @@ -104,7 +108,7 @@ describe('DiscordProvider', () => { it('should return true in test mode', () => { process.env.NODE_ENV = 'test'; - const req = { + const req = { headers: { 'x-signature-ed25519': 'invalid_signature', 'x-signature-timestamp': '1234567890' @@ -117,8 +121,8 @@ describe('DiscordProvider', () => { // Temporarily override NODE_ENV to ensure signature verification runs const originalNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; - - const req = { + + const req = { headers: { 'x-signature-ed25519': 'invalid_signature_format', 'x-signature-timestamp': '1234567890' @@ -126,10 +130,10 @@ describe('DiscordProvider', () => { rawBody: Buffer.from('test body'), body: { test: 'data' } }; - + // This should not throw, but return false due to invalid signature expect(provider.verifyWebhookSignature(req)).toBe(false); - + // Restore original NODE_ENV process.env.NODE_ENV = originalNodeEnv; }); @@ -150,9 +154,7 @@ describe('DiscordProvider', () => { type: 2, data: { name: 'help', - options: [ - { name: 'topic', value: 'discord' } - ] + options: [{ name: 'topic', value: 'discord' }] }, channel_id: '123456789', guild_id: '987654321', @@ -212,7 +214,9 @@ describe('DiscordProvider', () => { expect(result.options).toHaveLength(3); expect(result.repo).toBe('owner/myrepo'); expect(result.branch).toBe('feature-branch'); - expect(result.content).toBe('claude repo:owner/myrepo branch:feature-branch command:fix this bug'); + expect(result.content).toBe( + 'claude repo:owner/myrepo branch:feature-branch command:fix this bug' + ); }); it('should parse APPLICATION_COMMAND with repo but no branch (defaults to main)', () => { @@ -390,7 +394,7 @@ describe('DiscordProvider', () => { { content: 'test response', flags: 0 }, { headers: { - 'Authorization': `Bot ${provider.botToken}`, + Authorization: `Bot ${provider.botToken}`, 'Content-Type': 'application/json' } } @@ -410,7 +414,7 @@ describe('DiscordProvider', () => { { content: 'test response' }, { headers: { - 'Authorization': `Bot ${provider.botToken}`, + Authorization: `Bot ${provider.botToken}`, 'Content-Type': 'application/json' } } @@ -419,13 +423,15 @@ describe('DiscordProvider', () => { it('should handle axios errors', async () => { axios.post.mockRejectedValue(new Error('Network error')); - + const context = { type: 'command', channelId: '123456789' }; - await expect(provider.sendResponse(context, 'test response')).rejects.toThrow('Network error'); + await expect(provider.sendResponse(context, 'test response')).rejects.toThrow( + 'Network error' + ); }); }); @@ -462,9 +468,9 @@ describe('DiscordProvider', () => { it('should format Discord-specific error message', () => { const error = new Error('Test error'); const errorId = 'test-123'; - + const message = provider.formatErrorMessage(error, errorId); - + expect(message).toContain('🚫 **Error Processing Command**'); expect(message).toContain('**Reference ID:** `test-123`'); expect(message).toContain('Please contact an administrator'); @@ -482,4 +488,4 @@ describe('DiscordProvider', () => { expect(provider.getBotMention()).toBe('claude'); }); }); -}); \ No newline at end of file +}); diff --git a/test/unit/providers/ProviderFactory.test.js b/test/unit/providers/ProviderFactory.test.js index 92cb198..5931e62 100644 --- a/test/unit/providers/ProviderFactory.test.js +++ b/test/unit/providers/ProviderFactory.test.js @@ -19,7 +19,7 @@ const ChatbotProvider = require('../../../src/providers/ChatbotProvider'); // Mock DiscordProvider to avoid initialization issues in tests jest.mock('../../../src/providers/DiscordProvider', () => { - const mockImplementation = jest.fn().mockImplementation((config) => { + const mockImplementation = jest.fn().mockImplementation(config => { const instance = { initialize: jest.fn().mockResolvedValue(), config, @@ -37,12 +37,12 @@ describe('ProviderFactory', () => { beforeEach(() => { originalEnv = { ...process.env }; - + // Clear the factory singleton and create fresh instance for each test jest.resetModules(); const ProviderFactoryClass = require('../../../src/providers/ProviderFactory').constructor; factory = new ProviderFactoryClass(); - + // Mock DiscordProvider DiscordProvider.mockImplementation(() => ({ initialize: jest.fn().mockResolvedValue(), @@ -69,11 +69,19 @@ describe('ProviderFactory', () => { describe('registerProvider', () => { class TestProvider extends ChatbotProvider { async initialize() {} - verifyWebhookSignature() { return true; } - parseWebhookPayload() { return {}; } - extractBotCommand() { return null; } + verifyWebhookSignature() { + return true; + } + parseWebhookPayload() { + return {}; + } + extractBotCommand() { + return null; + } async sendResponse() {} - getUserId() { return 'test'; } + getUserId() { + return 'test'; + } } it('should register new provider', () => { @@ -92,7 +100,7 @@ describe('ProviderFactory', () => { const provider = await factory.createProvider('discord'); expect(provider).toBeInstanceOf(DiscordProvider); expect(DiscordProvider).toHaveBeenCalledWith({}); - + // Should return cached instance on second call const provider2 = await factory.createProvider('discord'); expect(provider2).toBe(provider); @@ -102,16 +110,16 @@ describe('ProviderFactory', () => { it('should create provider with custom config', async () => { const config = { botMention: '@custombot', authorizedUsers: ['user1'] }; await factory.createProvider('discord', config); - + expect(DiscordProvider).toHaveBeenCalledWith(config); }); it('should merge with default config', async () => { factory.setDefaultConfig({ globalSetting: true }); const config = { botMention: '@custombot' }; - + await factory.createProvider('discord', config); - + expect(DiscordProvider).toHaveBeenCalledWith({ globalSetting: true, botMention: '@custombot' @@ -191,7 +199,6 @@ describe('ProviderFactory', () => { }); }); - it('should remove undefined values from config', () => { // Only set some env vars process.env.DISCORD_BOT_TOKEN = 'test_token'; @@ -223,11 +230,19 @@ describe('ProviderFactory', () => { describe('createMultipleProviders', () => { class MockTestProvider extends ChatbotProvider { async initialize() {} - verifyWebhookSignature() { return true; } - parseWebhookPayload() { return {}; } - extractBotCommand() { return null; } + verifyWebhookSignature() { + return true; + } + parseWebhookPayload() { + return {}; + } + extractBotCommand() { + return null; + } async sendResponse() {} - getUserId() { return 'test'; } + getUserId() { + return 'test'; + } } beforeEach(() => { @@ -274,7 +289,7 @@ describe('ProviderFactory', () => { describe('getStats', () => { it('should return provider statistics', async () => { await factory.createProvider('discord'); - + const stats = factory.getStats(); expect(stats).toEqual({ @@ -302,8 +317,8 @@ describe('ProviderFactory', () => { // This tests the actual exported singleton const factory1 = require('../../../src/providers/ProviderFactory'); const factory2 = require('../../../src/providers/ProviderFactory'); - + expect(factory1).toBe(factory2); }); }); -}); \ No newline at end of file +}); diff --git a/test/unit/providers/discord-payloads.test.js b/test/unit/providers/discord-payloads.test.js index 85bf575..97d9a24 100644 --- a/test/unit/providers/discord-payloads.test.js +++ b/test/unit/providers/discord-payloads.test.js @@ -505,4 +505,4 @@ describe('Discord Payload Processing Tests', () => { expect(result).toBe('claude count:42 rate:3.14'); }); }); -}); \ No newline at end of file +}); diff --git a/test/unit/routes/chatbot.test.js b/test/unit/routes/chatbot.test.js new file mode 100644 index 0000000..5ef1b0f --- /dev/null +++ b/test/unit/routes/chatbot.test.js @@ -0,0 +1,50 @@ +const express = require('express'); +const request = require('supertest'); + +// Mock the controller +jest.mock('../../../src/controllers/chatbotController', () => ({ + handleChatbotWebhook: jest.fn((req, res) => { + res.status(200).json({ success: true }); + }), + handleDiscordWebhook: jest.fn((req, res) => { + res.status(200).json({ provider: 'discord' }); + }), + getProviderStats: jest.fn((req, res) => { + res.status(200).json({ stats: {} }); + }) +})); + +describe('Chatbot Routes', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use(express.json()); + + // Import the router fresh + const chatbotRouter = require('../../../src/routes/chatbot'); + app.use('/webhooks', chatbotRouter); + }); + + it('should handle generic chatbot webhook', async () => { + const response = await request(app).post('/webhooks/chatbot').send({ test: 'data' }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + + it('should handle Discord webhook', async () => { + const response = await request(app).post('/webhooks/discord').send({ type: 1 }); + + expect(response.status).toBe(200); + expect(response.body.provider).toBe('discord'); + }); + + it('should get provider stats', async () => { + const response = await request(app).get('/webhooks/stats'); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('stats'); + }); +}); diff --git a/test/unit/routes/claude-simple.test.ts b/test/unit/routes/claude-simple.test.ts new file mode 100644 index 0000000..a43f42c --- /dev/null +++ b/test/unit/routes/claude-simple.test.ts @@ -0,0 +1,135 @@ +import express from 'express'; +import request from 'supertest'; + +// Mock dependencies first +jest.mock('../../../src/services/claudeService', () => ({ + processCommand: jest.fn().mockResolvedValue('Mock response') +})); + +jest.mock('../../../src/utils/logger', () => ({ + createLogger: jest.fn(() => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() + })) +})); + +describe('Claude Routes - Simple Coverage', () => { + let app: express.Application; + const mockProcessCommand = require('../../../src/services/claudeService').processCommand; + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + process.env.BOT_USERNAME = '@TestBot'; // Required by claudeService + app = express(); + app.use(express.json()); + + // Import the router fresh + jest.isolateModules(() => { + const claudeRouter = require('../../../src/routes/claude').default; + app.use('/api/claude', claudeRouter); + }); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should handle a basic request', async () => { + const response = await request(app).post('/api/claude').send({ + repository: 'test/repo', + command: 'test command' + }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Command processed successfully'); + }); + + it('should handle missing repository', async () => { + const response = await request(app).post('/api/claude').send({ + command: 'test command' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Repository name is required'); + }); + + it('should handle missing command', async () => { + const response = await request(app).post('/api/claude').send({ + repository: 'test/repo' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Command is required'); + }); + + it('should validate authentication when required', async () => { + process.env.CLAUDE_API_AUTH_REQUIRED = '1'; + process.env.CLAUDE_API_AUTH_TOKEN = 'secret-token'; + + const response = await request(app).post('/api/claude').send({ + repository: 'test/repo', + command: 'test command' + }); + + expect(response.status).toBe(401); + expect(response.body.error).toBe('Invalid authentication token'); + }); + + it('should accept valid authentication', async () => { + process.env.CLAUDE_API_AUTH_REQUIRED = '1'; + process.env.CLAUDE_API_AUTH_TOKEN = 'secret-token'; + + const response = await request(app).post('/api/claude').send({ + repository: 'test/repo', + command: 'test command', + authToken: 'secret-token' + }); + + expect(response.status).toBe(200); + }); + + it('should handle empty response from Claude', async () => { + mockProcessCommand.mockResolvedValueOnce(''); + + const response = await request(app).post('/api/claude').send({ + repository: 'test/repo', + command: 'test command' + }); + + expect(response.status).toBe(200); + expect(response.body.response).toBe( + 'No output received from Claude container. This is a placeholder response.' + ); + }); + + it('should handle Claude processing error', async () => { + mockProcessCommand.mockRejectedValueOnce(new Error('Processing failed')); + + const response = await request(app).post('/api/claude').send({ + repository: 'test/repo', + command: 'test command' + }); + + expect(response.status).toBe(200); + expect(response.body.response).toBe('Error: Processing failed'); + }); + + it('should handle unexpected errors', async () => { + mockProcessCommand.mockImplementationOnce(() => { + throw new Error('Unexpected error'); + }); + + const response = await request(app).post('/api/claude').send({ + repository: 'test/repo', + command: 'test command' + }); + + expect(response.status).toBe(500); + expect(response.body.error).toBe('Failed to process command'); + expect(response.body.message).toBe('Unexpected error'); + }); +}); diff --git a/test/unit/routes/claude.test.ts b/test/unit/routes/claude.test.ts new file mode 100644 index 0000000..388f73d --- /dev/null +++ b/test/unit/routes/claude.test.ts @@ -0,0 +1,307 @@ +/* eslint-disable no-redeclare */ +import request from 'supertest'; +import express from 'express'; + +// Mock dependencies before imports +jest.mock('../../../src/services/claudeService'); +jest.mock('../../../src/utils/logger'); + +const mockProcessCommand = jest.fn<() => Promise>(); +jest.mocked(require('../../../src/services/claudeService')).processCommand = mockProcessCommand; + +interface MockLogger { + info: jest.Mock; + warn: jest.Mock; + error: jest.Mock; + debug: jest.Mock; +} + +const mockLogger: MockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() +}; +jest.mocked(require('../../../src/utils/logger')).createLogger = jest.fn(() => mockLogger); + +// Import router after mocks are set up +import claudeRouter from '../../../src/routes/claude'; + +describe('Claude Routes', () => { + let app: express.Application; + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + + app = express(); + app.use(express.json()); + app.use('/api/claude', claudeRouter); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('POST /api/claude', () => { + it('should process valid Claude request with repository and command', async () => { + mockProcessCommand.mockResolvedValue('Claude response'); + + const response = await request(app).post('/api/claude').send({ + repository: 'owner/repo', + command: 'Test command' + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + message: 'Command processed successfully', + response: 'Claude response' + }); + + expect(mockProcessCommand).toHaveBeenCalledWith({ + repoFullName: 'owner/repo', + issueNumber: null, + command: 'Test command', + isPullRequest: false, + branchName: null + }); + + expect(mockLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ request: expect.any(Object) }), + 'Received direct Claude request' + ); + }); + + it('should handle repoFullName parameter as alternative to repository', async () => { + mockProcessCommand.mockResolvedValue('Claude response'); + + const response = await request(app).post('/api/claude').send({ + repoFullName: 'owner/repo', + command: 'Test command' + }); + + expect(response.status).toBe(200); + expect(mockProcessCommand).toHaveBeenCalledWith( + expect.objectContaining({ + repoFullName: 'owner/repo' + }) + ); + }); + + it('should process request with all optional parameters', async () => { + mockProcessCommand.mockResolvedValue('Claude response'); + + const response = await request(app).post('/api/claude').send({ + repository: 'owner/repo', + command: 'Test command', + useContainer: true, + issueNumber: 42, + isPullRequest: true, + branchName: 'feature-branch' + }); + + expect(response.status).toBe(200); + expect(mockProcessCommand).toHaveBeenCalledWith({ + repoFullName: 'owner/repo', + issueNumber: 42, + command: 'Test command', + isPullRequest: true, + branchName: 'feature-branch' + }); + + expect(mockLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ + repo: 'owner/repo', + commandLength: 12, + useContainer: true, + issueNumber: 42, + isPullRequest: true + }), + 'Processing direct Claude command' + ); + }); + + it('should return 400 when repository is missing', async () => { + const response = await request(app).post('/api/claude').send({ + command: 'Test command' + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: 'Repository name is required' + }); + + expect(mockLogger.warn).toHaveBeenCalledWith('Missing repository name in request'); + expect(mockProcessCommand).not.toHaveBeenCalled(); + }); + + it('should return 400 when command is missing', async () => { + const response = await request(app).post('/api/claude').send({ + repository: 'owner/repo' + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + error: 'Command is required' + }); + + expect(mockLogger.warn).toHaveBeenCalledWith('Missing command in request'); + expect(mockProcessCommand).not.toHaveBeenCalled(); + }); + + it('should validate authentication when required', async () => { + process.env.CLAUDE_API_AUTH_REQUIRED = '1'; + process.env.CLAUDE_API_AUTH_TOKEN = 'secret-token'; + + const response = await request(app).post('/api/claude').send({ + repository: 'owner/repo', + command: 'Test command', + authToken: 'wrong-token' + }); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ + error: 'Invalid authentication token' + }); + + expect(mockLogger.warn).toHaveBeenCalledWith('Invalid authentication token'); + expect(mockProcessCommand).not.toHaveBeenCalled(); + }); + + it('should accept valid authentication token', async () => { + process.env.CLAUDE_API_AUTH_REQUIRED = '1'; + process.env.CLAUDE_API_AUTH_TOKEN = 'secret-token'; + mockProcessCommand.mockResolvedValue('Authenticated response'); + + const response = await request(app).post('/api/claude').send({ + repository: 'owner/repo', + command: 'Test command', + authToken: 'secret-token' + }); + + expect(response.status).toBe(200); + expect(response.body.response).toBe('Authenticated response'); + }); + + it('should skip authentication when not required', async () => { + process.env.CLAUDE_API_AUTH_REQUIRED = '0'; + mockProcessCommand.mockResolvedValue('Response'); + + const response = await request(app).post('/api/claude').send({ + repository: 'owner/repo', + command: 'Test command' + }); + + expect(response.status).toBe(200); + }); + + it('should handle empty Claude response with default message', async () => { + mockProcessCommand.mockResolvedValue(''); + + const response = await request(app).post('/api/claude').send({ + repository: 'owner/repo', + command: 'Test command' + }); + + expect(response.status).toBe(200); + expect(response.body.response).toBe( + 'No output received from Claude container. This is a placeholder response.' + ); + }); + + it('should handle whitespace-only Claude response', async () => { + mockProcessCommand.mockResolvedValue(' \n\t '); + + const response = await request(app).post('/api/claude').send({ + repository: 'owner/repo', + command: 'Test command' + }); + + expect(response.status).toBe(200); + expect(response.body.response).toBe( + 'No output received from Claude container. This is a placeholder response.' + ); + }); + + it('should handle Claude processing errors gracefully', async () => { + const error = new Error('Claude processing failed'); + mockProcessCommand.mockRejectedValue(error); + + const response = await request(app).post('/api/claude').send({ + repository: 'owner/repo', + command: 'Test command' + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + message: 'Command processed successfully', + response: 'Error: Claude processing failed' + }); + + expect(mockLogger.error).toHaveBeenCalledWith({ error }, 'Error during Claude processing'); + }); + + it('should handle unexpected errors', async () => { + mockProcessCommand.mockImplementation(() => { + throw new Error('Unexpected error'); + }); + + const response = await request(app).post('/api/claude').send({ + repository: 'owner/repo', + command: 'Test command' + }); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ + error: 'Failed to process command', + message: 'Unexpected error' + }); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + err: { + message: 'Unexpected error', + stack: expect.any(String) + } + }), + 'Error processing direct Claude command' + ); + }); + + it('should log debug information about Claude response', async () => { + mockProcessCommand.mockResolvedValue('Test response content'); + + const response = await request(app).post('/api/claude').send({ + repository: 'owner/repo', + command: 'Test command' + }); + + expect(response.status).toBe(200); + expect(mockLogger.debug).toHaveBeenCalledWith( + { + responseType: 'string', + responseLength: 20 + }, + 'Raw Claude response received' + ); + }); + + it('should log successful completion', async () => { + mockProcessCommand.mockResolvedValue('Response'); + + const response = await request(app).post('/api/claude').send({ + repository: 'owner/repo', + command: 'Test command' + }); + + expect(response.status).toBe(200); + expect(mockLogger.info).toHaveBeenCalledWith( + { + responseLength: 8 + }, + 'Successfully processed Claude command' + ); + }); + }); +}); diff --git a/test/unit/routes/github-simple.test.ts b/test/unit/routes/github-simple.test.ts new file mode 100644 index 0000000..82b5e15 --- /dev/null +++ b/test/unit/routes/github-simple.test.ts @@ -0,0 +1,32 @@ +import express from 'express'; +import request from 'supertest'; + +// Mock the controller +jest.mock('../../../src/controllers/githubController', () => ({ + handleWebhook: jest.fn((req: any, res: any) => { + res.status(200).json({ success: true }); + }) +})); + +describe('GitHub Routes - Simple Coverage', () => { + let app: express.Application; + + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use(express.json()); + + // Import the router fresh + jest.isolateModules(() => { + const githubRouter = require('../../../src/routes/github').default; + app.use('/github', githubRouter); + }); + }); + + it('should handle webhook POST request', async () => { + const response = await request(app).post('/github').send({ test: 'data' }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); +}); diff --git a/test/unit/routes/github.test.ts b/test/unit/routes/github.test.ts new file mode 100644 index 0000000..3707c07 --- /dev/null +++ b/test/unit/routes/github.test.ts @@ -0,0 +1,136 @@ +/* eslint-disable no-redeclare */ +import request from 'supertest'; +import express from 'express'; +import type { Request, Response } from 'express'; + +// Mock the controller before importing the router +jest.mock('../../../src/controllers/githubController'); + +const mockHandleWebhook = jest.fn<(req: Request, res: Response) => void>(); +jest.mocked(require('../../../src/controllers/githubController')).handleWebhook = mockHandleWebhook; + +// Import router after mocks are set up +import githubRouter from '../../../src/routes/github'; + +describe('GitHub Routes', () => { + let app: express.Application; + + beforeEach(() => { + jest.clearAllMocks(); + + app = express(); + app.use(express.json()); + app.use('/api/webhooks/github', githubRouter); + }); + + describe('POST /api/webhooks/github', () => { + it('should route webhook requests to the controller', async () => { + mockHandleWebhook.mockImplementation((_req: Request, res: Response) => { + res.status(200).json({ message: 'Webhook processed' }); + }); + + const webhookPayload = { + action: 'opened', + issue: { + number: 123, + title: 'Test issue' + } + }; + + const response = await request(app) + .post('/api/webhooks/github') + .send(webhookPayload) + .set('X-GitHub-Event', 'issues') + .set('X-GitHub-Delivery', 'test-delivery-id'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ message: 'Webhook processed' }); + expect(mockHandleWebhook).toHaveBeenCalledTimes(1); + expect(mockHandleWebhook).toHaveBeenCalledWith( + expect.objectContaining({ + body: webhookPayload, + headers: expect.objectContaining({ + 'x-github-event': 'issues', + 'x-github-delivery': 'test-delivery-id' + }) + }), + expect.any(Object), + expect.any(Function) + ); + }); + + it('should handle controller errors', async () => { + mockHandleWebhook.mockImplementation((_req: Request, res: Response) => { + res.status(500).json({ error: 'Internal server error' }); + }); + + const response = await request(app).post('/api/webhooks/github').send({ test: 'data' }); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'Internal server error' }); + }); + + it('should pass through all HTTP methods to controller', async () => { + mockHandleWebhook.mockImplementation((_req: Request, res: Response) => { + res.status(200).send('OK'); + }); + + // The router only defines POST, so other methods should return 404 + const getResponse = await request(app).get('/api/webhooks/github'); + + expect(getResponse.status).toBe(404); + expect(mockHandleWebhook).not.toHaveBeenCalled(); + + // POST should work + jest.clearAllMocks(); + const postResponse = await request(app).post('/api/webhooks/github').send({}); + + expect(postResponse.status).toBe(200); + expect(mockHandleWebhook).toHaveBeenCalledTimes(1); + }); + + it('should handle different content types', async () => { + mockHandleWebhook.mockImplementation((req: Request, res: Response) => { + res.status(200).json({ + contentType: req.get('content-type'), + body: req.body + }); + }); + + // Test with JSON + const jsonResponse = await request(app) + .post('/api/webhooks/github') + .send({ type: 'json' }) + .set('Content-Type', 'application/json'); + + expect(jsonResponse.status).toBe(200); + expect(jsonResponse.body.contentType).toBe('application/json'); + + // Test with form data + const formResponse = await request(app) + .post('/api/webhooks/github') + .send('type=form') + .set('Content-Type', 'application/x-www-form-urlencoded'); + + expect(formResponse.status).toBe(200); + }); + + it('should preserve raw body for signature verification', async () => { + mockHandleWebhook.mockImplementation((req: Request, res: Response) => { + // Check if rawBody is available (would be set by body parser in main app) + res.status(200).json({ + hasRawBody: 'rawBody' in req, + bodyType: typeof req.body + }); + }); + + const response = await request(app) + .post('/api/webhooks/github') + .send({ test: 'data' }) + .set('X-Hub-Signature-256', 'sha256=test'); + + expect(response.status).toBe(200); + expect(mockHandleWebhook).toHaveBeenCalled(); + }); + }); +}); diff --git a/test/unit/security/signature-verification.test.js b/test/unit/security/signature-verification.test.js index 7c4aa22..c0624c4 100644 --- a/test/unit/security/signature-verification.test.js +++ b/test/unit/security/signature-verification.test.js @@ -21,9 +21,9 @@ describe.skip('Signature Verification Security Tests', () => { let provider; const validPublicKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; const _validPrivateKey = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'; - + // Helper function to run test with production NODE_ENV - const withProductionEnv = (testFn) => { + const withProductionEnv = testFn => { const originalNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; try { @@ -34,11 +34,11 @@ describe.skip('Signature Verification Security Tests', () => { }; beforeEach(() => { - mockSecureCredentials.get.mockImplementation((key) => { + mockSecureCredentials.get.mockImplementation(key => { const mockCreds = { - 'DISCORD_BOT_TOKEN': 'mock_bot_token', - 'DISCORD_PUBLIC_KEY': validPublicKey, - 'DISCORD_APPLICATION_ID': '123456789012345678' + DISCORD_BOT_TOKEN: 'mock_bot_token', + DISCORD_PUBLIC_KEY: validPublicKey, + DISCORD_APPLICATION_ID: '123456789012345678' }; return mockCreds[key]; }); @@ -108,7 +108,7 @@ describe.skip('Signature Verification Security Tests', () => { it('should handle invalid public key format gracefully', async () => { // Override with invalid key format - mockSecureCredentials.get.mockImplementation((key) => { + mockSecureCredentials.get.mockImplementation(key => { if (key === 'DISCORD_PUBLIC_KEY') return 'invalid_key_format'; return 'mock_value'; }); @@ -118,7 +118,8 @@ describe.skip('Signature Verification Security Tests', () => { const req = { headers: { - 'x-signature-ed25519': '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'x-signature-ed25519': + '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'x-signature-timestamp': '1234567890' }, rawBody: Buffer.from('test body'), @@ -155,7 +156,8 @@ describe.skip('Signature Verification Security Tests', () => { const req = { headers: { - 'x-signature-ed25519': '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'x-signature-ed25519': + '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'x-signature-timestamp': '1234567890' }, rawBody: Buffer.from('test body'), @@ -176,7 +178,8 @@ describe.skip('Signature Verification Security Tests', () => { const req = { headers: { - 'x-signature-ed25519': '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'x-signature-ed25519': + '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'x-signature-timestamp': timestamp }, rawBody: Buffer.from(body), @@ -194,7 +197,7 @@ describe.skip('Signature Verification Security Tests', () => { 'ed25519', Buffer.from(expectedMessage), expect.any(Buffer), // public key buffer - expect.any(Buffer) // signature buffer + expect.any(Buffer) // signature buffer ); crypto.verify = originalVerify; @@ -207,7 +210,8 @@ describe.skip('Signature Verification Security Tests', () => { const req = { headers: { - 'x-signature-ed25519': '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'x-signature-ed25519': + '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'x-signature-timestamp': timestamp }, rawBody: Buffer.from(rawBodyContent), @@ -238,7 +242,8 @@ describe.skip('Signature Verification Security Tests', () => { const req = { headers: { - 'x-signature-ed25519': '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'x-signature-ed25519': + '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'x-signature-timestamp': timestamp }, // No rawBody provided @@ -283,7 +288,8 @@ describe.skip('Signature Verification Security Tests', () => { it('should handle empty timestamp gracefully', () => { const req = { headers: { - 'x-signature-ed25519': '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'x-signature-ed25519': + '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'x-signature-timestamp': '' }, rawBody: Buffer.from('test body'), @@ -323,7 +329,8 @@ describe.skip('Signature Verification Security Tests', () => { it('should handle unicode characters in timestamp', () => { const req = { headers: { - 'x-signature-ed25519': '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'x-signature-ed25519': + '64byte_hex_signature_placeholder_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'x-signature-timestamp': '123😀567890' }, rawBody: Buffer.from('test body'), @@ -350,7 +357,7 @@ describe.skip('Signature Verification Security Tests', () => { it('should handle Buffer conversion errors gracefully', () => { // Mock Buffer.from to throw an error const originalBufferFrom = Buffer.from; - Buffer.from = jest.fn().mockImplementation((data) => { + Buffer.from = jest.fn().mockImplementation(data => { if (typeof data === 'string' && data.includes('signature')) { throw new Error('Buffer conversion failed'); } @@ -421,4 +428,4 @@ describe.skip('Signature Verification Security Tests', () => { expect(time2).toBeLessThan(100); }); }); -}); \ No newline at end of file +}); diff --git a/test/unit/services/claudeService.test.js b/test/unit/services/claudeService.test.js index fea8afc..4966615 100644 --- a/test/unit/services/claudeService.test.js +++ b/test/unit/services/claudeService.test.js @@ -75,7 +75,7 @@ describe('Claude Service', () => { }); // Verify test mode response - expect(result).toContain('Hello! I\'m Claude responding to your request.'); + expect(result).toContain("Hello! I'm Claude responding to your request."); expect(result).toContain('test/repo'); expect(sanitizeBotMentions).toHaveBeenCalled(); diff --git a/test/unit/services/githubService-simple.test.js b/test/unit/services/githubService-simple.test.js index edc49f8..fb1ac36 100644 --- a/test/unit/services/githubService-simple.test.js +++ b/test/unit/services/githubService-simple.test.js @@ -393,7 +393,7 @@ describe('githubService - Simple Coverage Tests', () => { it('should handle container keywords for docker', async () => { const labels = await githubService.getFallbackLabels( 'Container startup issue', - 'The container won\'t start properly' + "The container won't start properly" ); expect(labels).toContain('component:docker'); diff --git a/test/unit/utils/awsCredentialProvider.test.js b/test/unit/utils/awsCredentialProvider.test.js index 65430f8..0ff7804 100644 --- a/test/unit/utils/awsCredentialProvider.test.js +++ b/test/unit/utils/awsCredentialProvider.test.js @@ -154,7 +154,7 @@ region = us-west-2 process.env.AWS_PROFILE = 'non-existent-profile'; await expect(awsCredentialProvider.getCredentials()).rejects.toThrow( - 'Profile \'non-existent-profile\' not found' + "Profile 'non-existent-profile' not found" ); // Restore AWS_PROFILE @@ -172,7 +172,7 @@ aws_access_key_id = test-access-key fsPromises.readFile.mockImplementationOnce(() => Promise.resolve(mockConfigFile)); await expect(awsCredentialProvider.getCredentials()).rejects.toThrow( - 'Incomplete credentials for profile \'test-profile\'' + "Incomplete credentials for profile 'test-profile'" ); }); }); diff --git a/test/unit/utils/sanitize.test.ts b/test/unit/utils/sanitize.test.ts new file mode 100644 index 0000000..bbd3b56 --- /dev/null +++ b/test/unit/utils/sanitize.test.ts @@ -0,0 +1,182 @@ +import { + sanitizeBotMentions, + sanitizeLabels, + sanitizeCommandInput, + validateRepositoryName, + validateGitHubRef, + sanitizeEnvironmentValue +} from '../../../src/utils/sanitize'; + +describe('Sanitize Utils', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('sanitizeBotMentions', () => { + it('should remove bot mentions when BOT_USERNAME is set', () => { + process.env.BOT_USERNAME = '@TestBot'; + const text = 'Hello @TestBot, can you help me?'; + expect(sanitizeBotMentions(text)).toBe('Hello TestBot, can you help me?'); + }); + + it('should handle bot username without @ symbol', () => { + process.env.BOT_USERNAME = 'TestBot'; + const text = 'Hello TestBot, can you help me?'; + expect(sanitizeBotMentions(text)).toBe('Hello TestBot, can you help me?'); + }); + + it('should handle case insensitive mentions', () => { + process.env.BOT_USERNAME = '@TestBot'; + const text = 'Hello @testbot and @TESTBOT'; + expect(sanitizeBotMentions(text)).toBe('Hello TestBot and TestBot'); + }); + + it('should return original text when BOT_USERNAME is not set', () => { + delete process.env.BOT_USERNAME; + const text = 'Hello @TestBot'; + expect(sanitizeBotMentions(text)).toBe(text); + }); + + it('should handle empty or null text', () => { + process.env.BOT_USERNAME = '@TestBot'; + expect(sanitizeBotMentions('')).toBe(''); + expect(sanitizeBotMentions(null as any)).toBe(null); + expect(sanitizeBotMentions(undefined as any)).toBe(undefined); + }); + }); + + describe('sanitizeLabels', () => { + it('should remove invalid characters from labels', () => { + const labels = ['valid-label', 'invalid@label', 'another#invalid']; + const result = sanitizeLabels(labels); + expect(result).toEqual(['valid-label', 'invalidlabel', 'anotherinvalid']); + }); + + it('should allow valid label characters', () => { + const labels = ['bug', 'feature:request', 'priority_high', 'scope-backend']; + const result = sanitizeLabels(labels); + expect(result).toEqual(labels); + }); + + it('should handle empty labels array', () => { + expect(sanitizeLabels([])).toEqual([]); + }); + }); + + describe('sanitizeCommandInput', () => { + it('should remove dangerous shell characters', () => { + const input = 'echo `whoami` && rm -rf $HOME'; + const result = sanitizeCommandInput(input); + expect(result).not.toContain('`'); + expect(result).not.toContain('$'); + expect(result).not.toContain('&&'); + }); + + it('should remove command injection characters', () => { + const input = 'cat file.txt; ls -la | grep secret > output.txt'; + const result = sanitizeCommandInput(input); + expect(result).not.toContain(';'); + expect(result).not.toContain('|'); + expect(result).not.toContain('>'); + }); + + it('should preserve safe command text', () => { + const input = 'npm install express'; + expect(sanitizeCommandInput(input)).toBe('npm install express'); + }); + + it('should trim whitespace', () => { + const input = ' npm test '; + expect(sanitizeCommandInput(input)).toBe('npm test'); + }); + + it('should handle empty input', () => { + expect(sanitizeCommandInput('')).toBe(''); + expect(sanitizeCommandInput(null as any)).toBe(null); + }); + }); + + describe('validateRepositoryName', () => { + it('should accept valid repository names', () => { + const validNames = ['my-repo', 'my_repo', 'my.repo', 'MyRepo123', 'repo']; + + validNames.forEach(name => { + expect(validateRepositoryName(name)).toBe(true); + }); + }); + + it('should reject invalid repository names', () => { + const invalidNames = ['my repo', 'my@repo', 'my#repo', 'my/repo', 'my\\repo', '']; + + invalidNames.forEach(name => { + expect(validateRepositoryName(name)).toBe(false); + }); + }); + }); + + describe('validateGitHubRef', () => { + it('should accept valid GitHub refs', () => { + const validRefs = [ + 'main', + 'feature/new-feature', + 'release-1.0.0', + 'hotfix_123', + 'refs/heads/main', + 'v1.2.3' + ]; + + validRefs.forEach(ref => { + expect(validateGitHubRef(ref)).toBe(true); + }); + }); + + it('should reject invalid GitHub refs', () => { + const invalidRefs = ['feature..branch', 'branch with spaces', 'branch@123', 'branch#123', '']; + + invalidRefs.forEach(ref => { + expect(validateGitHubRef(ref)).toBe(false); + }); + }); + }); + + describe('sanitizeEnvironmentValue', () => { + it('should redact sensitive environment values', () => { + const sensitiveKeys = [ + 'GITHUB_TOKEN', + 'API_TOKEN', + 'SECRET_KEY', + 'PASSWORD', + 'AWS_ACCESS_KEY_ID', + 'ANTHROPIC_API_KEY' + ]; + + sensitiveKeys.forEach(key => { + expect(sanitizeEnvironmentValue(key, 'actual-value')).toBe('[REDACTED]'); + }); + }); + + it('should not redact non-sensitive values', () => { + const nonSensitiveKeys = ['NODE_ENV', 'PORT', 'APP_NAME', 'LOG_LEVEL']; + + nonSensitiveKeys.forEach(key => { + expect(sanitizeEnvironmentValue(key, 'value')).toBe('value'); + }); + }); + + it('should handle case insensitive key matching', () => { + expect(sanitizeEnvironmentValue('github_token', 'value')).toBe('[REDACTED]'); + expect(sanitizeEnvironmentValue('GITHUB_TOKEN', 'value')).toBe('[REDACTED]'); + }); + + it('should detect partial key matches', () => { + expect(sanitizeEnvironmentValue('MY_CUSTOM_TOKEN', 'value')).toBe('[REDACTED]'); + expect(sanitizeEnvironmentValue('DB_PASSWORD_HASH', 'value')).toBe('[REDACTED]'); + }); + }); +}); diff --git a/test/unit/utils/startup-metrics.test.ts b/test/unit/utils/startup-metrics.test.ts new file mode 100644 index 0000000..729e0a4 --- /dev/null +++ b/test/unit/utils/startup-metrics.test.ts @@ -0,0 +1,340 @@ +/* eslint-disable no-redeclare */ +import type { Request, Response, NextFunction } from 'express'; + +// Mock the logger +jest.mock('../../../src/utils/logger'); + +interface MockLogger { + info: jest.Mock; + error: jest.Mock; + warn: jest.Mock; + debug: jest.Mock; +} + +const mockLogger: MockLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn() +}; + +jest.mocked(require('../../../src/utils/logger')).createLogger = jest.fn(() => mockLogger); + +// Import after mocks are set up +import { StartupMetrics } from '../../../src/utils/startup-metrics'; + +describe('StartupMetrics', () => { + let metrics: StartupMetrics; + let mockDateNow: jest.SpiedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock Date.now for consistent timing + mockDateNow = jest.spyOn(Date, 'now'); + mockDateNow.mockReturnValue(1000); + + metrics = new StartupMetrics(); + + // Advance time for subsequent calls + let currentTime = 1000; + mockDateNow.mockImplementation(() => { + currentTime += 100; + return currentTime; + }); + }); + + afterEach(() => { + mockDateNow.mockRestore(); + }); + + describe('constructor', () => { + it('should initialize with current timestamp', () => { + mockDateNow.mockReturnValue(5000); + const newMetrics = new StartupMetrics(); + + expect(newMetrics.startTime).toBe(5000); + expect(newMetrics.milestones).toEqual([]); + expect(newMetrics.ready).toBe(false); + expect(newMetrics.totalStartupTime).toBeUndefined(); + }); + }); + + describe('recordMilestone', () => { + it('should record a milestone with description', () => { + metrics.recordMilestone('test_milestone', 'Test milestone description'); + + expect(metrics.milestones).toHaveLength(1); + expect(metrics.milestones[0]).toEqual({ + name: 'test_milestone', + timestamp: 1100, + description: 'Test milestone description' + }); + + expect(mockLogger.info).toHaveBeenCalledWith( + { + milestone: 'test_milestone', + elapsed: '100ms', + description: 'Test milestone description' + }, + 'Startup milestone: test_milestone' + ); + }); + + it('should record a milestone without description', () => { + metrics.recordMilestone('test_milestone'); + + expect(metrics.milestones[0]).toEqual({ + name: 'test_milestone', + timestamp: 1100, + description: '' + }); + }); + + it('should track multiple milestones', () => { + metrics.recordMilestone('first', 'First milestone'); + metrics.recordMilestone('second', 'Second milestone'); + metrics.recordMilestone('third', 'Third milestone'); + + expect(metrics.milestones).toHaveLength(3); + expect(metrics.getMilestoneNames()).toEqual(['first', 'second', 'third']); + }); + + it('should calculate elapsed time correctly', () => { + // Reset to have predictable times + mockDateNow.mockReturnValueOnce(2000); + const newMetrics = new StartupMetrics(); + + mockDateNow.mockReturnValueOnce(2500); + newMetrics.recordMilestone('milestone1'); + + mockDateNow.mockReturnValueOnce(3000); + newMetrics.recordMilestone('milestone2'); + + const milestone1 = newMetrics.getMilestone('milestone1'); + const milestone2 = newMetrics.getMilestone('milestone2'); + + expect(milestone1?.elapsed).toBe(500); + expect(milestone2?.elapsed).toBe(1000); + }); + }); + + describe('markReady', () => { + it('should mark service as ready and record total startup time', () => { + mockDateNow.mockReturnValueOnce(2000); + const totalTime = metrics.markReady(); + + expect(metrics.ready).toBe(true); + expect(metrics.totalStartupTime).toBe(1000); + expect(totalTime).toBe(1000); + + expect(mockLogger.info).toHaveBeenCalledWith( + { + totalStartupTime: '1000ms', + milestones: expect.any(Object) + }, + 'Service startup completed' + ); + + // Should have recorded service_ready milestone + const readyMilestone = metrics.getMilestone('service_ready'); + expect(readyMilestone).toBeDefined(); + expect(readyMilestone?.description).toBe('Service is ready to accept requests'); + }); + }); + + describe('getMetrics', () => { + it('should return current metrics state', () => { + metrics.recordMilestone('test1', 'Test 1'); + metrics.recordMilestone('test2', 'Test 2'); + + const metricsData = metrics.getMetrics(); + + expect(metricsData).toEqual({ + isReady: false, + totalElapsed: expect.any(Number), + milestones: { + test1: { + timestamp: expect.any(Number), + elapsed: expect.any(Number), + description: 'Test 1' + }, + test2: { + timestamp: expect.any(Number), + elapsed: expect.any(Number), + description: 'Test 2' + } + }, + startTime: 1000, + totalStartupTime: undefined + }); + }); + + it('should include totalStartupTime when ready', () => { + metrics.markReady(); + const metricsData = metrics.getMetrics(); + + expect(metricsData.isReady).toBe(true); + expect(metricsData.totalStartupTime).toBeDefined(); + }); + }); + + describe('metricsMiddleware', () => { + it('should attach metrics to request object', () => { + const middleware = metrics.metricsMiddleware(); + const req = {} as Request & { startupMetrics?: any }; + const res = {} as Response; + const next = jest.fn() as NextFunction; + + metrics.recordMilestone('before_middleware'); + + middleware(req, res, next); + + expect(req.startupMetrics).toBeDefined(); + expect(req.startupMetrics.milestones).toHaveProperty('before_middleware'); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should call next without error', () => { + const middleware = metrics.metricsMiddleware(); + const req = {} as Request; + const res = {} as Response; + const next = jest.fn() as NextFunction; + + middleware(req, res, next); + + expect(next).toHaveBeenCalledWith(); + }); + }); + + describe('getMilestone', () => { + it('should return milestone data if exists', () => { + metrics.recordMilestone('test_milestone', 'Test'); + + const milestone = metrics.getMilestone('test_milestone'); + + expect(milestone).toEqual({ + timestamp: expect.any(Number), + elapsed: expect.any(Number), + description: 'Test' + }); + }); + + it('should return undefined for non-existent milestone', () => { + const milestone = metrics.getMilestone('non_existent'); + + expect(milestone).toBeUndefined(); + }); + }); + + describe('getMilestoneNames', () => { + it('should return empty array when no milestones', () => { + expect(metrics.getMilestoneNames()).toEqual([]); + }); + + it('should return all milestone names', () => { + metrics.recordMilestone('first'); + metrics.recordMilestone('second'); + metrics.recordMilestone('third'); + + expect(metrics.getMilestoneNames()).toEqual(['first', 'second', 'third']); + }); + }); + + describe('getElapsedTime', () => { + it('should return elapsed time since start', () => { + mockDateNow.mockReturnValueOnce(5000); + + const elapsed = metrics.getElapsedTime(); + + expect(elapsed).toBe(4000); // 5000 - 1000 (start time) + }); + }); + + describe('isServiceReady', () => { + it('should return false initially', () => { + expect(metrics.isServiceReady()).toBe(false); + }); + + it('should return true after markReady', () => { + metrics.markReady(); + expect(metrics.isServiceReady()).toBe(true); + }); + }); + + describe('reset', () => { + it('should reset all metrics', () => { + metrics.recordMilestone('test1'); + metrics.recordMilestone('test2'); + metrics.markReady(); + + metrics.reset(); + + expect(metrics.milestones).toEqual([]); + expect(metrics.getMilestoneNames()).toEqual([]); + expect(metrics.ready).toBe(false); + expect(metrics.totalStartupTime).toBeUndefined(); + expect(mockLogger.info).toHaveBeenCalledWith('Startup metrics reset'); + }); + }); + + describe('integration scenarios', () => { + it('should handle typical startup sequence', () => { + // Simulate typical app startup + metrics.recordMilestone('env_loaded', 'Environment variables loaded'); + metrics.recordMilestone('express_initialized', 'Express app initialized'); + metrics.recordMilestone('middleware_configured', 'Middleware configured'); + metrics.recordMilestone('routes_configured', 'Routes configured'); + metrics.recordMilestone('server_listening', 'Server listening on port 3000'); + + const totalTime = metrics.markReady(); + + expect(metrics.getMilestoneNames()).toEqual([ + 'env_loaded', + 'express_initialized', + 'middleware_configured', + 'routes_configured', + 'server_listening', + 'service_ready' + ]); + + expect(totalTime).toBeGreaterThan(0); + expect(metrics.isServiceReady()).toBe(true); + }); + + it('should provide accurate metrics through middleware', () => { + const middleware = metrics.metricsMiddleware(); + + // Record some milestones + metrics.recordMilestone('startup', 'Application started'); + + // Simulate request + const req = {} as Request & { startupMetrics?: any }; + const res = {} as Response; + const next = jest.fn() as NextFunction; + + middleware(req, res, next); + + // Verify metrics are attached + expect(req.startupMetrics).toMatchObject({ + isReady: false, + totalElapsed: expect.any(Number), + milestones: { + startup: expect.objectContaining({ + description: 'Application started' + }) + } + }); + + // Mark ready + metrics.markReady(); + + // Another request should show ready state + const req2 = {} as Request & { startupMetrics?: any }; + middleware(req2, res, next); + + expect(req2.startupMetrics.isReady).toBe(true); + expect(req2.startupMetrics.totalStartupTime).toBeDefined(); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 711ae31..0042c95 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,14 +31,18 @@ "types": ["node", "jest"] }, "include": [ - "src/**/*", - "test/**/*" + "src/**/*" ], "exclude": [ "node_modules", "dist", "coverage", - "test-results" + "test-results", + "test/**/*", + "**/*.test.ts", + "**/*.test.js", + "**/*.spec.ts", + "**/*.spec.js" ], "ts-node": { "files": true,