diff --git a/src/server.ts b/src/server.ts index bbf8bdc..f7cd34b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -565,6 +565,13 @@ export class WebhookServer { Metrics.inc('onboarding_prompts_sent_total', 0); Metrics.inc('onboarding_prompts_skipped_total', 0); Metrics.inc('onboarding_assign_failures_total', 0); + + // Precalentar métricas de reacciones por emoji + for (const emoji of ['robot', 'warn', 'check', 'other']) { + Metrics.inc('reactions_enqueued_total', 0, { emoji }); + Metrics.inc('reactions_sent_total', 0, { emoji }); + Metrics.inc('reactions_failed_total', 0, { emoji }); + } } catch {} if (process.env.NODE_ENV !== 'test') { diff --git a/tests/unit/server/webhook.reactions.e2e.test.ts b/tests/unit/server/webhook.reactions.e2e.test.ts new file mode 100644 index 0000000..554de6e --- /dev/null +++ b/tests/unit/server/webhook.reactions.e2e.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { WebhookServer } from '../../../src/server'; +import { ResponseQueue } from '../../../src/services/response-queue'; +import { AllowedGroups } from '../../../src/services/allowed-groups'; +import { GroupSyncService } from '../../../src/services/group-sync'; + +function makePayload(event: string, data: any) { + return { + event, + instance: 'test-instance', + data + }; +} + +async function postWebhook(payload: any) { + const req = new Request('http://localhost/webhook', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + return await WebhookServer.handleRequest(req); +} + +describe('WebhookServer E2E - reacciones por comando', () => { + let memdb: Database; + const envBackup = { ...process.env }; + + beforeAll(() => { + memdb = new Database(':memory:'); + initializeDatabase(memdb); + (WebhookServer as any).dbInstance = memdb; + (ResponseQueue as any).dbInstance = memdb; + (AllowedGroups as any).dbInstance = memdb; + (GroupSyncService as any).dbInstance = memdb; + }); + + afterAll(() => { + process.env = envBackup; + try { memdb.close(); } catch {} + }); + + beforeEach(() => { + process.env = { + ...envBackup, + NODE_ENV: 'test', + REACTIONS_ENABLED: 'true', + REACTIONS_SCOPE: 'groups', + GROUP_GATING_MODE: 'enforce', + CHATBOT_PHONE_NUMBER: '999' + }; + memdb.exec(` + DELETE FROM response_queue; + DELETE FROM task_assignments; + DELETE FROM tasks; + DELETE FROM users; + DELETE FROM groups; + DELETE FROM allowed_groups; + `); + GroupSyncService.activeGroupsCache?.clear?.(); + }); + + it('encola 🤖 en grupo allowed y activo tras /t n', async () => { + const groupId = 'g1@g.us'; + // Sembrar grupo activo y allowed + memdb.exec(` + INSERT OR IGNORE INTO groups (id, community_id, name, active, archived, is_community, last_verified) + VALUES ('${groupId}', 'comm-1', 'G1', 1, 0, 0, strftime('%Y-%m-%d %H:%M:%f','now')) + `); + GroupSyncService.activeGroupsCache.set(groupId, 'G1'); + AllowedGroups.setStatus(groupId, 'allowed'); + + const payload = makePayload('MESSAGES_UPSERT', { + key: { remoteJid: groupId, id: 'MSG-OK-1', fromMe: false, participant: '600111222@s.whatsapp.net' }, + message: { conversation: '/t n prueba e2e' } + }); + + const res = await postWebhook(payload); + expect(res.status).toBe(200); + + const row = memdb.prepare(`SELECT metadata FROM response_queue WHERE metadata LIKE '%"kind":"reaction"%' ORDER BY id DESC LIMIT 1`).get() as any; + expect(row).toBeTruthy(); + const meta = JSON.parse(String(row.metadata)); + expect(meta.kind).toBe('reaction'); + expect(meta.emoji).toBe('🤖'); + expect(meta.chatId).toBe(groupId); + expect(meta.messageId).toBe('MSG-OK-1'); + }); + + it('no encola reacción en DM cuando REACTIONS_SCOPE=groups', async () => { + const dmJid = '600111222@s.whatsapp.net'; + + const payload = makePayload('MESSAGES_UPSERT', { + key: { remoteJid: dmJid, id: 'MSG-DM-1', fromMe: false }, + message: { conversation: '/t n en DM no reacciona' } + }); + + const res = await postWebhook(payload); + expect(res.status).toBe(200); + + const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue WHERE metadata LIKE '%"kind":"reaction"%'`).get() as any; + expect(Number(cnt.c)).toBe(0); + }); + + it('encola ⚠️ en grupo allowed y activo para comando inválido (/t x sin IDs)', async () => { + const groupId = 'g2@g.us'; + memdb.exec(` + INSERT OR IGNORE INTO groups (id, community_id, name, active, archived, is_community, last_verified) + VALUES ('${groupId}', 'comm-1', 'G2', 1, 0, 0, strftime('%Y-%m-%d %H:%M:%f','now')) + `); + GroupSyncService.activeGroupsCache.set(groupId, 'G2'); + AllowedGroups.setStatus(groupId, 'allowed'); + + const payload = makePayload('MESSAGES_UPSERT', { + key: { remoteJid: groupId, id: 'MSG-ERR-1', fromMe: false, participant: '600111222@s.whatsapp.net' }, + message: { conversation: '/t x' } + }); + + const res = await postWebhook(payload); + expect(res.status).toBe(200); + + const row = memdb.prepare(`SELECT metadata FROM response_queue WHERE metadata LIKE '%"kind":"reaction"%' ORDER BY id DESC LIMIT 1`).get() as any; + expect(row).toBeTruthy(); + const meta = JSON.parse(String(row.metadata)); + expect(meta.kind).toBe('reaction'); + expect(meta.emoji).toBe('⚠️'); + expect(meta.chatId).toBe(groupId); + expect(meta.messageId).toBe('MSG-ERR-1'); + }); +});