import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll, mock } from 'bun:test'; import { Database } from 'bun:sqlite'; import { WebhookServer } from '../../src/server'; import { ResponseQueue } from '../../src/services/response-queue'; import { GroupSyncService } from '../../src/services/group-sync'; import { initializeDatabase } from '../../src/db'; // Mock the ResponseQueue let mockAdd: any; // Test database instance let testDb: Database; beforeAll(() => { // Create in-memory test database testDb = new Database(':memory:'); // Initialize schema initializeDatabase(testDb); }); afterAll(() => { // Close the test database testDb.close(); }); beforeEach(() => { mockAdd = mock(() => Promise.resolve()); ResponseQueue.add = mockAdd; // Inject testDb for WebhookServer to use WebhookServer.dbInstance = testDb; // Ensure database is initialized (recreates tables if dropped) initializeDatabase(testDb); // Reset database state between tests testDb.exec('DELETE FROM task_assignments'); testDb.exec('DELETE FROM tasks'); testDb.exec('DELETE FROM users'); testDb.exec('DELETE FROM groups'); // Mock GroupSyncService.isGroupActive to return true by default mock(GroupSyncService.isGroupActive).mockReturnValue(true); }); describe('WebhookServer', () => { const envBackup = process.env; beforeEach(() => { process.env = { ...envBackup, INSTANCE_NAME: 'test-instance', NODE_ENV: 'test' }; }); 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 invalidPayload of invalidPayloads) { const request = createTestRequest(invalidPayload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(400); } }); test('should verify instance name', async () => { process.env.TEST_VERIFY_INSTANCE = 'true'; const payload = { event: 'messages.upsert', instance: 'wrong-instance', data: {} }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(403); delete process.env.TEST_VERIFY_INSTANCE; }); 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); expect(mockAdd).toHaveBeenCalled(); }); test('should ignore empty message content', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: 'sender-id@s.whatsapp.net' }, message: { conversation: '' } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).not.toHaveBeenCalled(); }); test('should handle very long messages', async () => { const longMessage = '/tarea nueva ' + 'A'.repeat(5000); const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: 'sender-id@s.whatsapp.net' }, message: { conversation: longMessage } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).toHaveBeenCalled(); }); test('should handle messages with special characters and emojis', async () => { const specialMessage = '/tarea nueva Test 😊 你好 @#$%^&*()'; const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: 'sender-id@s.whatsapp.net' }, message: { conversation: specialMessage } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).toHaveBeenCalled(); }); test('should ignore non-/tarea commands', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: 'sender-id@s.whatsapp.net' }, message: { conversation: '/othercommand test' } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).not.toHaveBeenCalled(); }); test('should ignore message with mentions but no command', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: 'sender-id@s.whatsapp.net' }, message: { conversation: 'Hello everyone!', contextInfo: { mentionedJid: ['1234567890@s.whatsapp.net'] } } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).not.toHaveBeenCalled(); }); test('should ignore media attachment messages', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: 'sender-id@s.whatsapp.net' }, message: { imageMessage: { caption: 'This is an image' } } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).not.toHaveBeenCalled(); }); test('should handle requests on configured port', async () => { const originalPort = process.env.PORT; process.env.PORT = '3007'; try { const server = await WebhookServer.start(); const response = await fetch('http://localhost:3007/health'); expect(response.status).toBe(200); server.stop(); } finally { process.env.PORT = originalPort; } }); function getFutureDate(days: number): string { const date = new Date(); date.setDate(date.getDate() + days); return date.toISOString().split('T')[0]; } describe('/tarea command logging', () => { let consoleSpy: any; beforeEach(() => { consoleSpy = mock(() => {}); console.log = consoleSpy; }); afterEach(() => { consoleSpy.mockRestore(); }); test('should log basic /tarea command', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group123@g.us', participant: 'user123@s.whatsapp.net' }, message: { conversation: '/tarea test' } } }; await WebhookServer.handleRequest(createTestRequest(payload)); expect(consoleSpy).toHaveBeenCalledWith( '🔍 Detected /tarea command:', expect.objectContaining({ rawMessage: '/tarea test' }) ); expect(consoleSpy).toHaveBeenCalledWith( '✅ Successfully parsed command:', expect.objectContaining({ action: 'test', description: '', dueDate: 'none', mentionCount: 0 }) ); }); test('should log command with due date', async () => { const futureDate = getFutureDate(3); // Get date 3 days in future const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group123@g.us', participant: 'user123@s.whatsapp.net' }, message: { conversation: `/tarea nueva Finish project @user2 ${futureDate}`, contextInfo: { mentionedJid: ['user2@s.whatsapp.net'] } } } }; await WebhookServer.handleRequest(createTestRequest(payload)); // Verify the two command-related log calls expect(consoleSpy).toHaveBeenCalledTimes(2); // First call should be the command detection expect(consoleSpy.mock.calls[0][0]).toBe('🔍 Detected /tarea command:'); expect(consoleSpy.mock.calls[0][1]).toEqual({ timestamp: expect.any(String), from: 'user123@s.whatsapp.net', group: 'group123@g.us', rawMessage: `/tarea nueva Finish project @user2 ${futureDate}` }); // Second call should be the successful parsing expect(consoleSpy.mock.calls[1][0]).toBe('✅ Successfully parsed command:'); expect(consoleSpy.mock.calls[1][1]).toEqual({ action: 'nueva', description: 'Finish project @user2', dueDate: futureDate, mentionCount: 1 }); }); }); test('should handle XSS/SQL injection attempts', async () => { const maliciousMessage = `/tarea nueva '; DROP TABLE tasks; --`; const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: 'sender-id@s.whatsapp.net' }, message: { conversation: maliciousMessage } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).toHaveBeenCalled(); }); test('should handle multiple dates in command (use last one as due date)', async () => { const futureDate1 = getFutureDate(3); const futureDate2 = getFutureDate(5); 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 task ${futureDate1} some text ${futureDate2}` } } }; await WebhookServer.handleRequest(createTestRequest(payload)); expect(console.log).toHaveBeenCalledWith( '✅ Successfully parsed command:', expect.objectContaining({ description: `Test task ${futureDate1} some text`, dueDate: futureDate2 }) ); }); test('should ignore past dates as due dates', async () => { const pastDate = '2020-01-01'; const futureDate = getFutureDate(2); 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 task ${pastDate} more text ${futureDate}` } } }; await WebhookServer.handleRequest(createTestRequest(payload)); expect(console.log).toHaveBeenCalledWith( '✅ Successfully parsed command:', expect.objectContaining({ description: `Test task ${pastDate} more text`, dueDate: futureDate }) ); }); test('should handle multiple past dates correctly', async () => { const pastDate1 = '2020-01-01'; const pastDate2 = '2021-01-01'; 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 task ${pastDate1} and ${pastDate2}` } } }; await WebhookServer.handleRequest(createTestRequest(payload)); expect(console.log).toHaveBeenCalledWith( '✅ Successfully parsed command:', expect.objectContaining({ description: `Test task ${pastDate1} and ${pastDate2}`, dueDate: 'none' }) ); }); test('should handle mixed valid and invalid date formats', async () => { const futureDate = getFutureDate(2); 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 task 2023-13-01 (invalid) ${futureDate} 25/12/2023 (invalid)` } } }; await WebhookServer.handleRequest(createTestRequest(payload)); expect(console.log).toHaveBeenCalledWith( '✅ Successfully parsed command:', expect.objectContaining({ description: 'Test task 2023-13-01 (invalid) 25/12/2023 (invalid)', dueDate: futureDate }) ); }); test('should normalize sender ID before processing', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: '1234567890:12@s.whatsapp.net' // ID with participant }, message: { conversation: '/tarea nueva Test' } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).toHaveBeenCalled(); }); test('should ignore messages with invalid sender ID', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: 'invalid-id!' // Invalid ID }, message: { conversation: '/tarea nueva Test' } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).not.toHaveBeenCalled(); }); test('should ensure user exists and use normalized ID', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: '1234567890@s.whatsapp.net' }, message: { conversation: '/tarea nueva Test' } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).toHaveBeenCalled(); // Verify user was created in real database const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890'); expect(user).toBeDefined(); expect(user.id).toBe('1234567890'); }); test('should ignore messages if user creation fails', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: null // Invalid participant }, message: { conversation: '/tarea nueva Test' } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).not.toHaveBeenCalled(); // Verify no user was created const userCount = testDb.query("SELECT COUNT(*) as count FROM users").get(); expect(userCount.count).toBe(0); }); // Integration tests with real database describe('User validation in handleMessageUpsert', () => { test('should proceed with valid user', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: '1234567890@s.whatsapp.net' }, message: { conversation: '/tarea nueva Test' } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).toHaveBeenCalled(); // Verify user was created in real database const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890'); expect(user).toBeDefined(); expect(user.id).toBe('1234567890'); }); test('should ignore message if user validation fails', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: 'invalid!user@s.whatsapp.net' }, message: { conversation: '/tarea nueva Test' } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).not.toHaveBeenCalled(); // Verify no user was created const userCount = testDb.query("SELECT COUNT(*) as count FROM users").get(); expect(userCount.count).toBe(0); }); test('should handle database errors during user validation', async () => { // Force a database error by corrupting the database state testDb.exec('DROP TABLE users'); const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: '1234567890@s.whatsapp.net' }, message: { conversation: '/tarea nueva Test' } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).not.toHaveBeenCalled(); // Reinitialize database for subsequent tests initializeDatabase(testDb); }); test('should integrate user validation completely in handleMessageUpsert with valid user', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: '1234567890@s.whatsapp.net' }, message: { conversation: '/tarea nueva Test' } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).toHaveBeenCalled(); // Verify user was created/updated in database const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890'); expect(user).toBeDefined(); expect(user.id).toBe('1234567890'); expect(user.first_seen).toBeDefined(); expect(user.last_seen).toBeDefined(); }); test('should use normalized ID in command service', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: '1234567890:12@s.whatsapp.net' // Raw ID with participant }, message: { conversation: '/tarea nueva Test' } } }; // Mock CommandService.handle to capture the sender parameter const mockCommandHandle = mock(async () => []); const originalCommandService = await import('../../src/services/command'); originalCommandService.CommandService.handle = mockCommandHandle; const request = createTestRequest(payload); await WebhookServer.handleRequest(request); // Verify CommandService.handle was called with normalized sender ID expect(mockCommandHandle).toHaveBeenCalledWith({ sender: '1234567890', // Normalized ID groupId: 'group-id@g.us', message: '/tarea nueva Test', mentions: [] }); // Restore original originalCommandService.CommandService.handle = originalCommandService.CommandService.handle; }); test('should handle end-to-end flow with valid user and command processing', async () => { const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: '1234567890@s.whatsapp.net' }, message: { conversation: '/tarea nueva Test task' } } }; // Mock CommandService.handle to return a response const mockCommandHandle = mock(async () => [{ recipient: '1234567890', message: 'Task created successfully' }]); const originalCommandService = await import('../../src/services/command'); originalCommandService.CommandService.handle = mockCommandHandle; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); // Verify user was created/updated const user = testDb.query("SELECT * FROM users WHERE id = ?").get('1234567890'); expect(user).toBeDefined(); // Verify CommandService was called with normalized ID expect(mockCommandHandle).toHaveBeenCalledWith({ sender: '1234567890', groupId: 'group-id@g.us', message: '/tarea nueva Test task', mentions: [] }); // Verify ResponseQueue.add was called with the response expect(mockAdd).toHaveBeenCalledWith([{ recipient: '1234567890', message: 'Task created successfully' }]); // Restore original originalCommandService.CommandService.handle = originalCommandService.CommandService.handle; }); }); describe('Group validation in handleMessageUpsert', () => { test('should ignore messages from inactive groups', async () => { // Mock isGroupActive to return false for this test mock(GroupSyncService.isGroupActive).mockReturnValue(false); const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'inactive-group@g.us', participant: '1234567890@s.whatsapp.net' }, message: { conversation: '/tarea nueva Test' } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).not.toHaveBeenCalled(); }); test('should proceed with messages from active groups', async () => { // Ensure isGroupActive returns true (already mocked in beforeEach) const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'active-group@g.us', participant: '1234567890@s.whatsapp.net' }, message: { conversation: '/tarea nueva Test' } } }; const request = createTestRequest(payload); const response = await WebhookServer.handleRequest(request); expect(response.status).toBe(200); expect(mockAdd).toHaveBeenCalled(); }); }); });