Compare commits
No commits in common. '536de6b4f86842ad2308d63a2b291882c1cbf848' and '2450c8806abf8f21cc0f9d51bba7f3f4fd08f5df' have entirely different histories.
536de6b4f8
...
2450c8806a
@ -1,131 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll, beforeEach } from 'bun:test';
|
|
||||||
import { Database } from 'bun:sqlite';
|
|
||||||
import { initializeDatabase } from '../../../src/db';
|
|
||||||
import { TaskService } from '../../../src/tasks/service';
|
|
||||||
import { CommandService } from '../../../src/services/command';
|
|
||||||
import { GroupSyncService } from '../../../src/services/group-sync';
|
|
||||||
|
|
||||||
describe('CommandService - inserta task_origins al crear en grupo con messageId', () => {
|
|
||||||
let memdb: Database;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
memdb = new Database(':memory:');
|
|
||||||
initializeDatabase(memdb);
|
|
||||||
(TaskService as any).dbInstance = memdb;
|
|
||||||
(CommandService as any).dbInstance = memdb;
|
|
||||||
|
|
||||||
// Sembrar grupo activo y cache
|
|
||||||
memdb.exec(`
|
|
||||||
INSERT OR IGNORE INTO groups (id, community_id, name, active, archived, is_community, last_verified)
|
|
||||||
VALUES ('g1@g.us', 'comm-1', 'G1', 1, 0, 0, strftime('%Y-%m-%d %H:%M:%f','now'))
|
|
||||||
`);
|
|
||||||
try { (GroupSyncService as any).dbInstance = memdb; } catch {}
|
|
||||||
GroupSyncService.activeGroupsCache?.clear?.();
|
|
||||||
GroupSyncService.activeGroupsCache?.set?.('g1@g.us', 'G1');
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.NODE_ENV = 'test';
|
|
||||||
memdb.exec('DELETE FROM task_assignments; DELETE FROM tasks; DELETE FROM task_origins;');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('crea tarea en grupo y registra (task_id, chat_id, message_id)', async () => {
|
|
||||||
const sender = '600111222';
|
|
||||||
const res = await CommandService.handle({
|
|
||||||
sender,
|
|
||||||
groupId: 'g1@g.us',
|
|
||||||
message: '/t n pruebas origen 2099-01-05',
|
|
||||||
mentions: [],
|
|
||||||
messageId: 'MSG-ORIG-1'
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const t = memdb.prepare(`SELECT id FROM tasks ORDER BY id DESC LIMIT 1`).get() as any;
|
|
||||||
expect(t).toBeTruthy();
|
|
||||||
const row = memdb.prepare(`
|
|
||||||
SELECT task_id, chat_id, message_id FROM task_origins WHERE task_id = ?
|
|
||||||
`).get(Number(t.id)) as any;
|
|
||||||
|
|
||||||
expect(row).toBeTruthy();
|
|
||||||
expect(Number(row.task_id)).toBe(Number(t.id));
|
|
||||||
expect(String(row.chat_id)).toBe('g1@g.us');
|
|
||||||
expect(String(row.message_id)).toBe('MSG-ORIG-1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
||||||
import { Database } from 'bun:sqlite';
|
|
||||||
import { initializeDatabase } from '../../../src/db';
|
|
||||||
import { ResponseQueue } from '../../../src/services/response-queue';
|
|
||||||
|
|
||||||
const ORIGINAL_FETCH = globalThis.fetch;
|
|
||||||
const envBackup = { ...process.env };
|
|
||||||
|
|
||||||
describe('ResponseQueue - jobs de reacción (enqueue + sendOne)', () => {
|
|
||||||
let memdb: Database;
|
|
||||||
let captured: { url?: string; payload?: any } = {};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env = {
|
|
||||||
...envBackup,
|
|
||||||
NODE_ENV: 'test',
|
|
||||||
EVOLUTION_API_URL: 'http://evolution.test',
|
|
||||||
EVOLUTION_API_INSTANCE: 'instance-1',
|
|
||||||
EVOLUTION_API_KEY: 'apikey',
|
|
||||||
RQ_REACTIONS_MAX_ATTEMPTS: '3',
|
|
||||||
};
|
|
||||||
|
|
||||||
memdb = new Database(':memory:');
|
|
||||||
memdb.exec('PRAGMA foreign_keys = ON;');
|
|
||||||
initializeDatabase(memdb);
|
|
||||||
|
|
||||||
(ResponseQueue as any).dbInstance = memdb;
|
|
||||||
|
|
||||||
globalThis.fetch = async (url: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
captured.url = String(url);
|
|
||||||
try {
|
|
||||||
captured.payload = init?.body ? JSON.parse(String(init.body)) : null;
|
|
||||||
} catch {
|
|
||||||
captured.payload = null;
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({ ok: true }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
memdb.exec('DELETE FROM response_queue');
|
|
||||||
captured = {};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
globalThis.fetch = ORIGINAL_FETCH;
|
|
||||||
process.env = envBackup;
|
|
||||||
try { memdb.close(); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enqueueReaction aplica idempotencia por (chatId, messageId, emoji) en ventana 24h', async () => {
|
|
||||||
await ResponseQueue.enqueueReaction('123@g.us', 'MSG-1', '🤖');
|
|
||||||
await ResponseQueue.enqueueReaction('123@g.us', 'MSG-1', '🤖'); // duplicado → ignorar
|
|
||||||
|
|
||||||
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
|
||||||
expect(Number(cnt.c)).toBe(1);
|
|
||||||
|
|
||||||
// Mismo chat y mensaje, emoji distinto → debe insertar
|
|
||||||
await ResponseQueue.enqueueReaction('123@g.us', 'MSG-1', '⚠️');
|
|
||||||
const cnt2 = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
|
||||||
expect(Number(cnt2.c)).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sendOne con metadata.kind === "reaction" usa /message/sendReaction y payload esperado', async () => {
|
|
||||||
const item = {
|
|
||||||
id: 42,
|
|
||||||
recipient: '123@g.us',
|
|
||||||
message: '', // no se usa para reaction
|
|
||||||
attempts: 0,
|
|
||||||
metadata: JSON.stringify({ kind: 'reaction', emoji: '🤖', chatId: '123@g.us', messageId: 'MSG-99' }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await ResponseQueue.sendOne(item as any);
|
|
||||||
expect(res.ok).toBe(true);
|
|
||||||
|
|
||||||
expect(captured.url?.includes('/message/sendReaction/instance-1')).toBe(true);
|
|
||||||
expect(captured.payload).toBeDefined();
|
|
||||||
expect(captured.payload.reaction).toBe('🤖');
|
|
||||||
expect(captured.payload.key).toEqual({ remoteJid: '123@g.us', fromMe: false, id: 'MSG-99' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
||||||
import { Database } from 'bun:sqlite';
|
|
||||||
import { initializeDatabase } from '../../../src/db';
|
|
||||||
import { TaskService } from '../../../src/tasks/service';
|
|
||||||
import { ResponseQueue } from '../../../src/services/response-queue';
|
|
||||||
import { AllowedGroups } from '../../../src/services/allowed-groups';
|
|
||||||
|
|
||||||
function toIsoSql(d: Date): string {
|
|
||||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('TaskService - reacción ✅ al completar (Fase 2)', () => {
|
|
||||||
let memdb: Database;
|
|
||||||
let envBackup: Record<string, string | undefined>;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
envBackup = { ...process.env };
|
|
||||||
memdb = new Database(':memory:');
|
|
||||||
initializeDatabase(memdb);
|
|
||||||
(TaskService as any).dbInstance = memdb;
|
|
||||||
(ResponseQueue as any).dbInstance = memdb;
|
|
||||||
(AllowedGroups as any).dbInstance = memdb;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
process.env = envBackup;
|
|
||||||
try { memdb.close(); } catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
process.env.NODE_ENV = 'test';
|
|
||||||
process.env.REACTIONS_ENABLED = 'true';
|
|
||||||
process.env.REACTIONS_SCOPE = 'groups';
|
|
||||||
process.env.REACTIONS_TTL_DAYS = '14';
|
|
||||||
process.env.GROUP_GATING_MODE = 'enforce';
|
|
||||||
|
|
||||||
memdb.exec(`
|
|
||||||
DELETE FROM response_queue;
|
|
||||||
DELETE FROM task_assignments;
|
|
||||||
DELETE FROM tasks;
|
|
||||||
DELETE FROM users;
|
|
||||||
DELETE FROM task_origins;
|
|
||||||
DELETE FROM allowed_groups;
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enqueuea ✅ al completar una tarea con task_origins dentro de TTL y grupo allowed', async () => {
|
|
||||||
const groupId = 'grp-1@g.us';
|
|
||||||
AllowedGroups.setStatus(groupId, 'allowed');
|
|
||||||
|
|
||||||
const taskId = TaskService.createTask({
|
|
||||||
description: 'Prueba ✅',
|
|
||||||
due_date: null,
|
|
||||||
group_id: groupId,
|
|
||||||
created_by: '600111222'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Origen reciente (dentro de TTL)
|
|
||||||
const msgId = 'MSG-OK-1';
|
|
||||||
memdb.prepare(`
|
|
||||||
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`).run(taskId, groupId, msgId, toIsoSql(new Date()));
|
|
||||||
|
|
||||||
const res = TaskService.completeTask(taskId, '600111222');
|
|
||||||
expect(res.status).toBe('updated');
|
|
||||||
|
|
||||||
const row = memdb.prepare(`SELECT id, recipient, metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
|
|
||||||
expect(row).toBeTruthy();
|
|
||||||
expect(String(row.recipient)).toBe(groupId);
|
|
||||||
|
|
||||||
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(msgId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no encola ✅ si el origen está fuera de TTL', async () => {
|
|
||||||
const groupId = 'grp-2@g.us';
|
|
||||||
AllowedGroups.setStatus(groupId, 'allowed');
|
|
||||||
|
|
||||||
// TTL 7 días para forzar expiración
|
|
||||||
process.env.REACTIONS_TTL_DAYS = '7';
|
|
||||||
|
|
||||||
const taskId = TaskService.createTask({
|
|
||||||
description: 'Fuera TTL',
|
|
||||||
due_date: null,
|
|
||||||
group_id: groupId,
|
|
||||||
created_by: '600111222'
|
|
||||||
});
|
|
||||||
|
|
||||||
const msgId = 'MSG-OLD-1';
|
|
||||||
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); // 8 días atrás
|
|
||||||
memdb.prepare(`
|
|
||||||
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`).run(taskId, groupId, msgId, toIsoSql(old));
|
|
||||||
|
|
||||||
const res = TaskService.completeTask(taskId, '600111222');
|
|
||||||
expect(res.status).toBe('updated');
|
|
||||||
|
|
||||||
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
|
||||||
expect(Number(cnt.c)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('idempotencia: completar dos veces encola solo un ✅', async () => {
|
|
||||||
const groupId = 'grp-3@g.us';
|
|
||||||
AllowedGroups.setStatus(groupId, 'allowed');
|
|
||||||
|
|
||||||
const taskId = TaskService.createTask({
|
|
||||||
description: 'Idempotencia ✅',
|
|
||||||
due_date: null,
|
|
||||||
group_id: groupId,
|
|
||||||
created_by: '600111222'
|
|
||||||
});
|
|
||||||
|
|
||||||
const msgId = 'MSG-IDEMP-1';
|
|
||||||
memdb.prepare(`
|
|
||||||
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`).run(taskId, groupId, msgId, toIsoSql(new Date()));
|
|
||||||
|
|
||||||
const r1 = TaskService.completeTask(taskId, '600111222');
|
|
||||||
const r2 = TaskService.completeTask(taskId, '600111222');
|
|
||||||
expect(r1.status === 'updated' || r1.status === 'already').toBe(true);
|
|
||||||
expect(r2.status === 'updated' || r2.status === 'already').toBe(true);
|
|
||||||
|
|
||||||
const rows = memdb.query(`SELECT metadata FROM response_queue`).all() as any[];
|
|
||||||
expect(rows.length).toBe(1);
|
|
||||||
const meta = JSON.parse(String(rows[0].metadata || '{}'));
|
|
||||||
expect(meta.emoji).toBe('✅');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enforce: grupo no allowed → no encola ✅', async () => {
|
|
||||||
const groupId = 'grp-4@g.us';
|
|
||||||
// Estado por defecto 'pending' (no allowed)
|
|
||||||
|
|
||||||
const taskId = TaskService.createTask({
|
|
||||||
description: 'No allowed',
|
|
||||||
due_date: null,
|
|
||||||
group_id: groupId,
|
|
||||||
created_by: '600111222'
|
|
||||||
});
|
|
||||||
|
|
||||||
const msgId = 'MSG-NO-ALLOW-1';
|
|
||||||
memdb.prepare(`
|
|
||||||
INSERT INTO task_origins (task_id, chat_id, message_id, created_at)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`).run(taskId, groupId, msgId, toIsoSql(new Date()));
|
|
||||||
|
|
||||||
const res = TaskService.completeTask(taskId, '600111222');
|
|
||||||
expect(res.status === 'updated' || res.status === 'already').toBe(true);
|
|
||||||
|
|
||||||
const cnt = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
|
|
||||||
expect(Number(cnt.c)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue