From b7ed60a471cac0f06f000f51b50aaf4e588b947a Mon Sep 17 00:00:00 2001 From: "borja (aider)" Date: Wed, 26 Mar 2025 23:41:17 +0100 Subject: [PATCH] feat: implement base webhook server with validation and tests --- src/server.ts | 73 ++++++++++++++++++++++++++++++++++++ tests/unit/server.test.ts | 78 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/server.ts create mode 100644 tests/unit/server.test.ts diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..b1c2f5e --- /dev/null +++ b/src/server.ts @@ -0,0 +1,73 @@ +import { Bun } from 'bun'; +import { CommandService } from './services/command'; + +const PORT = 3007; + +type WebhookPayload = { + event: string; + instance: string; + data: any; + // Other fields from Evolution API +}; + +export class WebhookServer { + static async handleRequest(request: Request): Promise { + // 1. Method validation + if (request.method !== 'POST') { + return new Response('Method not allowed', { status: 405 }); + } + + // 2. Content-Type validation + const contentType = request.headers.get('content-type'); + if (!contentType?.includes('application/json')) { + return new Response('Invalid content type', { status: 400 }); + } + + try { + // 3. Parse and validate payload + const payload = await request.json() as WebhookPayload; + + if (!payload.event || !payload.instance) { + return new Response('Invalid payload', { status: 400 }); + } + + // 4. Verify instance matches + if (payload.instance !== process.env.INSTANCE_NAME) { + return new Response('Invalid instance', { status: 403 }); + } + + // 5. Route events + switch (payload.event) { + case 'messages.upsert': + await this.handleMessageUpsert(payload.data); + break; + // Other events will be added later + } + + return new Response('OK', { status: 200 }); + } catch (error) { + return new Response('Invalid request', { status: 400 }); + } + } + + private static async handleMessageUpsert(data: any) { + // Basic message validation + if (!data?.key?.remoteJid || !data.message) return; + + // Forward to command service if applicable + const messageText = data.message.conversation; + if (messageText?.startsWith('/tarea')) { + await CommandService.handle({ + sender: data.key.participant, + groupId: data.key.remoteJid, + message: messageText, + mentions: data.contextInfo?.mentionedJid || [] + }); + } + } +} + +Bun.serve({ + port: PORT, + fetch: WebhookServer.handleRequest, +}); diff --git a/tests/unit/server.test.ts b/tests/unit/server.test.ts new file mode 100644 index 0000000..d9ad308 --- /dev/null +++ b/tests/unit/server.test.ts @@ -0,0 +1,78 @@ +import { describe, test, expect, mock } from 'bun:test'; +import { WebhookServer } from '../../src/server'; + +describe('WebhookServer', () => { + const envBackup = process.env; + + beforeEach(() => { + process.env = { ...envBackup, INSTANCE_NAME: 'test-instance' }; + }); + + afterEach(() => { + process.env = envBackup; + }); + + const createTestRequest = (payload: any) => + new Request('http://localhost:3007', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + test('should reject non-POST requests', async () => { + const request = new Request('http://localhost:3007', { method: 'GET' }); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(405); + }); + + test('should require JSON content type', async () => { + const request = new Request('http://localhost:3007', { + method: 'POST', + headers: { 'Content-Type': 'text/plain' } + }); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(400); + }); + + test('should validate payload structure', async () => { + const invalidPayloads = [ + {}, + { event: null }, + { event: 'messages.upsert', instance: null } + ]; + + for (const payload of invalidPayloads) { + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(400); + } + }); + + test('should verify instance name', async () => { + const payload = { + event: 'messages.upsert', + instance: 'wrong-instance', + data: {} + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(403); + }); + + test('should handle valid messages.upsert', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'group-id@g.us', + participant: 'sender-id@s.whatsapp.net' + }, + message: { conversation: '/tarea nueva Test' } + } + }; + const request = createTestRequest(payload); + const response = await WebhookServer.handleRequest(request); + expect(response.status).toBe(200); + }); +});