diff --git a/tests/unit/services/response-queue.reaction.test.ts b/tests/unit/services/response-queue.reaction.test.ts new file mode 100644 index 0000000..d93dfa4 --- /dev/null +++ b/tests/unit/services/response-queue.reaction.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { ResponseQueue } from '../../../src/services/response-queue'; + +describe('ResponseQueue - payload de reacción', () => { + const OLD_FETCH = globalThis.fetch; + + beforeEach(() => { + process.env.EVOLUTION_API_URL = 'http://evolution.local'; + process.env.EVOLUTION_API_INSTANCE = 'instance-1'; + }); + + afterEach(() => { + globalThis.fetch = OLD_FETCH; + delete process.env.EVOLUTION_API_URL; + delete process.env.EVOLUTION_API_INSTANCE; + }); + + it('incluye participant y fromMe cuando están presentes', async () => { + const calls: any[] = []; + globalThis.fetch = (async (url: any, init?: any) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + text: async () => '' + } as any; + }) as any; + + const item = { + id: 1, + recipient: '12345-67890@g.us', + message: '', + metadata: JSON.stringify({ + kind: 'reaction', + emoji: '✅', + chatId: '12345-67890@g.us', + messageId: 'MSG-123', + fromMe: true, + participant: '34600123456@s.whatsapp.net' + }), + attempts: 0 + }; + + const res = await ResponseQueue.sendOne(item as any); + expect(res.ok).toBe(true); + + expect(calls.length).toBe(1); + const { url, init } = calls[0]; + expect(String(url)).toBe('http://evolution.local/message/sendReaction/instance-1'); + const payload = JSON.parse(String(init.body || '{}')); + expect(payload).toBeTruthy(); + expect(payload.reaction).toBe('✅'); + expect(payload.key).toEqual({ + remoteJid: '12345-67890@g.us', + fromMe: true, + id: 'MSG-123', + participant: '34600123456@s.whatsapp.net' + }); + }); + + it('omite participant y usa fromMe=false por defecto cuando no se proveen', async () => { + const calls: any[] = []; + globalThis.fetch = (async (url: any, init?: any) => { + calls.push({ url, init }); + return { + ok: true, + status: 200, + text: async () => '' + } as any; + }) as any; + + const item = { + id: 2, + recipient: '12345-67890@g.us', + message: '', + metadata: JSON.stringify({ + kind: 'reaction', + emoji: '✅', + chatId: '12345-67890@g.us', + messageId: 'MSG-456' + }), + attempts: 0 + }; + + const res = await ResponseQueue.sendOne(item as any); + expect(res.ok).toBe(true); + + const { url, init } = calls[0]; + expect(String(url)).toBe('http://evolution.local/message/sendReaction/instance-1'); + const payload = JSON.parse(String(init.body || '{}')); + expect(payload.reaction).toBe('✅'); + expect(payload.key.remoteJid).toBe('12345-67890@g.us'); + expect(payload.key.id).toBe('MSG-456'); + expect(payload.key.fromMe).toBe(false); + expect('participant' in payload.key).toBe(false); + }); +}); diff --git a/tests/web/api.tasks.complete.reaction.test.ts b/tests/web/api.tasks.complete.reaction.test.ts new file mode 100644 index 0000000..1601b6c --- /dev/null +++ b/tests/web/api.tasks.complete.reaction.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, afterEach, describe, expect, it } from 'bun:test'; +import { createTempDb } from './helpers/db'; +import { POST as completeHandler } from '../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts'; + +function toIsoSql(d: Date): string { + return d.toISOString().replace('T', ' ').replace('Z', ''); +} + +describe('Web API - completar tarea encola reacción ✅', () => { + let cleanup: () => void; + let db: any; + let path: string; + + const USER = '34600123456'; + const GROUP_ID = '12345-67890@g.us'; + + beforeEach(() => { + const tmp = createTempDb(); + cleanup = tmp.cleanup; + db = tmp.db; + path = tmp.path; + + process.env.NODE_ENV = 'test'; + process.env.DB_PATH = path; + process.env.REACTIONS_ENABLED = 'true'; + process.env.REACTIONS_SCOPE = 'groups'; + process.env.REACTIONS_TTL_DAYS = '14'; + process.env.GROUP_GATING_MODE = 'enforce'; + + // Sembrar usuario y grupo permitido + membresía activa + db.prepare(`INSERT OR IGNORE INTO users (id) VALUES (?)`).run(USER); + db.prepare(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES (?, 'comm-1', 'Group', 1)`).run(GROUP_ID); + db.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, label, status) VALUES (?, 'Test', 'allowed')`).run(GROUP_ID); + db.prepare(`INSERT OR REPLACE INTO group_members (group_id, user_id, is_admin, is_active) VALUES (?, ?, 0, 1)`).run(GROUP_ID, USER); + }); + + afterEach(() => { + if (cleanup) cleanup(); + // Limpiar env relevantes + delete process.env.DB_PATH; + delete process.env.REACTIONS_ENABLED; + delete process.env.REACTIONS_SCOPE; + delete process.env.REACTIONS_TTL_DAYS; + delete process.env.GROUP_GATING_MODE; + }); + + it('caso feliz: encola 1 reacción ✅ con metadata canónica', async () => { + // Crear tarea en grupo (no completada) + const ins = db.prepare(` + INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at) + VALUES ('Probar reacción', NULL, ?, ?, 0, NULL) + `).run(GROUP_ID, USER) as any; + const taskId = Number(ins.lastInsertRowid); + + // Origen reciente con participant y from_me=1 + const messageId = 'MSG-abc-123'; + db.prepare(` + INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at, participant, from_me) + VALUES (?, ?, ?, ?, ?, ?) + `).run(taskId, GROUP_ID, messageId, toIsoSql(new Date()), `${USER}@s.whatsapp.net`, 1); + + // Ejecutar endpoint + const event: any = { + locals: { userId: USER }, + params: { id: String(taskId) }, + request: new Request('http://localhost', { method: 'POST' }) + }; + const res = await completeHandler(event); + expect(res.status).toBe(200); + const payload = await res.json(); + expect(payload.status).toBe('updated'); + + // Verificar encolado + const row = db.prepare(`SELECT recipient, message, metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any; + expect(row).toBeTruthy(); + expect(String(row.recipient)).toBe(GROUP_ID); + expect(String(row.message)).toBe(''); + + const meta = JSON.parse(String(row.metadata || '{}')); + expect(meta).toEqual({ + kind: 'reaction', + emoji: '✅', + chatId: GROUP_ID, + messageId, + fromMe: true, + participant: `${USER}@s.whatsapp.net` + }); + + // Idempotencia del endpoint: segunda llamada no crea nuevo job + const res2 = await completeHandler(event); + expect(res2.status).toBe(200); + const body2 = await res2.json(); + expect(body2.status).toBe('already'); + + const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue WHERE metadata = ?`).get(JSON.stringify(meta)) as any; + expect(Number(cnt.c || 0)).toBe(1); + }); + + it('TTL vencido: no encola reacción', async () => { + const ins = db.prepare(` + INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at) + VALUES ('Vieja', NULL, ?, ?, 0, NULL) + `).run(GROUP_ID, USER) as any; + const taskId = Number(ins.lastInsertRowid); + + const messageId = 'MSG-old-001'; + const old = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000); // 20 días + db.prepare(` + INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at) + VALUES (?, ?, ?, ?) + `).run(taskId, GROUP_ID, messageId, toIsoSql(old)); + + const event: any = { + locals: { userId: USER }, + params: { id: String(taskId) }, + request: new Request('http://localhost', { method: 'POST' }) + }; + const res = await completeHandler(event); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe('updated'); + + const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any; + expect(Number(cnt.c || 0)).toBe(0); + }); + + it('scope=groups: origen DM no encola', async () => { + const ins = db.prepare(` + INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at) + VALUES ('DM scope', NULL, ?, ?, 0, NULL) + `).run(GROUP_ID, USER) as any; + const taskId = Number(ins.lastInsertRowid); + + const messageId = 'MSG-dm-001'; + const dmChat = `${USER}@s.whatsapp.net`; // no @g.us + db.prepare(` + INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at) + VALUES (?, ?, ?, ?) + `).run(taskId, dmChat, messageId, toIsoSql(new Date())); + + const event: any = { + locals: { userId: USER }, + params: { id: String(taskId) }, + request: new Request('http://localhost', { method: 'POST' }) + }; + const res = await completeHandler(event); + expect(res.status).toBe(200); + + const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any; + expect(Number(cnt.c || 0)).toBe(0); + }); + + it('sin participant/from_me: metadata no incluye claves opcionales', async () => { + const ins = db.prepare(` + INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at) + VALUES ('Sin opcionales', NULL, ?, ?, 0, NULL) + `).run(GROUP_ID, USER) as any; + const taskId = Number(ins.lastInsertRowid); + + const messageId = 'MSG-nopts-001'; + db.prepare(` + INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at) + VALUES (?, ?, ?, ?) + `).run(taskId, GROUP_ID, messageId, toIsoSql(new Date())); + + const event: any = { + locals: { userId: USER }, + params: { id: String(taskId) }, + request: new Request('http://localhost', { method: 'POST' }) + }; + const res = await completeHandler(event); + expect(res.status).toBe(200); + + const row = db.prepare(`SELECT metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any; + const meta = JSON.parse(String(row.metadata || '{}')); + expect(meta).toEqual({ + kind: 'reaction', + emoji: '✅', + chatId: GROUP_ID, + messageId + }); + expect('fromMe' in meta).toBe(false); + expect('participant' in meta).toBe(false); + }); +});