diff --git a/tests/unit/server.groups-upsert.test.ts b/tests/unit/server.groups-upsert.test.ts new file mode 100644 index 0000000..6f07262 --- /dev/null +++ b/tests/unit/server.groups-upsert.test.ts @@ -0,0 +1,55 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { WebhookServer } from '../../src/server'; +import { GroupSyncService } from '../../src/services/group-sync'; + +const envBackup = { ...process.env }; + +describe('WebhookServer - evento GROUPS_UPSERT', () => { + let originalSyncGroups: any; + let originalRefresh: any; + let originalSyncMembers: any; + + beforeEach(() => { + process.env = { ...envBackup, NODE_ENV: 'test' }; // omite verificación de instancia + originalSyncGroups = GroupSyncService.syncGroups; + originalRefresh = (GroupSyncService as any).refreshActiveGroupsCache; + originalSyncMembers = GroupSyncService.syncMembersForActiveGroups; + }); + + afterEach(() => { + GroupSyncService.syncGroups = originalSyncGroups; + (GroupSyncService as any).refreshActiveGroupsCache = originalRefresh; + GroupSyncService.syncMembersForActiveGroups = originalSyncMembers; + process.env = envBackup; + }); + + test('dispara syncGroups -> refresh -> syncMembers', async () => { + const calls: string[] = []; + GroupSyncService.syncGroups = async () => { + calls.push('syncGroups'); + return { added: 0, updated: 0 }; + }; + (GroupSyncService as any).refreshActiveGroupsCache = () => { + calls.push('refresh'); + }; + GroupSyncService.syncMembersForActiveGroups = async () => { + calls.push('syncMembers'); + return { groups: 0, added: 0, updated: 0, deactivated: 0 }; + }; + + const payload = { + event: 'GROUPS_UPSERT', + instance: 'any-instance', + data: {} + }; + const req = new Request('http://localhost:3007', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const res = await WebhookServer.handleRequest(req); + expect(res.status).toBe(200); + expect(calls).toEqual(['syncGroups', 'refresh', 'syncMembers']); + }); +}); diff --git a/tests/unit/server.test.ts b/tests/unit/server.test.ts index e437e20..1a47480 100644 --- a/tests/unit/server.test.ts +++ b/tests/unit/server.test.ts @@ -320,6 +320,19 @@ describe('WebhookServer', () => { test('should handle requests on configured port', async () => { const originalPort = process.env.PORT; process.env.PORT = '3007'; + // Satisfacer validación de entorno en start() + const prevEnv = { + EVOLUTION_API_URL: process.env.EVOLUTION_API_URL, + EVOLUTION_API_KEY: process.env.EVOLUTION_API_KEY, + EVOLUTION_API_INSTANCE: process.env.EVOLUTION_API_INSTANCE, + CHATBOT_PHONE_NUMBER: process.env.CHATBOT_PHONE_NUMBER, + WEBHOOK_URL: process.env.WEBHOOK_URL + }; + process.env.EVOLUTION_API_URL = 'http://localhost:3000'; + process.env.EVOLUTION_API_KEY = 'test-key'; + process.env.EVOLUTION_API_INSTANCE = 'test-instance'; + process.env.CHATBOT_PHONE_NUMBER = '9999999999'; + process.env.WEBHOOK_URL = 'http://localhost:3007'; try { const server = await WebhookServer.start(); @@ -328,6 +341,11 @@ describe('WebhookServer', () => { server.stop(); } finally { process.env.PORT = originalPort; + process.env.EVOLUTION_API_URL = prevEnv.EVOLUTION_API_URL; + process.env.EVOLUTION_API_KEY = prevEnv.EVOLUTION_API_KEY; + process.env.EVOLUTION_API_INSTANCE = prevEnv.EVOLUTION_API_INSTANCE; + process.env.CHATBOT_PHONE_NUMBER = prevEnv.CHATBOT_PHONE_NUMBER; + process.env.WEBHOOK_URL = prevEnv.WEBHOOK_URL; } }); diff --git a/tests/unit/services/group-sync.fetch-members.test.ts b/tests/unit/services/group-sync.fetch-members.test.ts new file mode 100644 index 0000000..5533397 --- /dev/null +++ b/tests/unit/services/group-sync.fetch-members.test.ts @@ -0,0 +1,98 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { GroupSyncService } from '../../../src/services/group-sync'; + +const ORIGINAL_FETCH = globalThis.fetch; + +describe('GroupSyncService - fetchGroupMembersFromAPI (parsing y fallbacks)', () => { + const envBackup = { ...process.env }; + + beforeEach(() => { + process.env = { + ...envBackup, + NODE_ENV: 'development', // evitar early-return de test + EVOLUTION_API_URL: 'http://evolution.test', + EVOLUTION_API_INSTANCE: 'instance-1', + EVOLUTION_API_KEY: 'apikey' + }; + }); + + afterEach(() => { + process.env = envBackup; + globalThis.fetch = ORIGINAL_FETCH; + }); + + test('parsea /group/participants con múltiples formas de payload y roles', async () => { + globalThis.fetch = async (url: RequestInfo | URL) => { + if (String(url).includes('/group/participants/')) { + const body = { + participants: [ + { id: '553198296801@s.whatsapp.net', admin: 'superadmin' }, + { id: '1234567890@s.whatsapp.net', admin: 'admin' }, + { id: '1111111111@s.whatsapp.net', admin: null }, + '2222222222@s.whatsapp.net', + { jid: '3333333333@s.whatsapp.net', role: 'member' }, + { user: { id: '4444444444@s.whatsapp.net' }, isAdmin: true } + ] + }; + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return new Response('not found', { status: 404 }); + }; + + const out = await (GroupSyncService as any).fetchGroupMembersFromAPI('123@g.us'); + // Debe mapear a IDs normalizados (solo dígitos) y detectar admin correctamente + const ids = out.map((x: any) => x.userId).sort(); + expect(ids).toEqual(['1111111111','1234567890','2222222222','3333333333','4444444444','553198296801'].sort()); + + const adminMap = new Map(out.map((x: any) => [x.userId, x.isAdmin])); + expect(adminMap.get('553198296801')).toBe(true); + expect(adminMap.get('1234567890')).toBe(true); + expect(adminMap.get('1111111111')).toBe(false); + expect(adminMap.get('2222222222')).toBe(false); + expect(adminMap.get('3333333333')).toBe(false); + expect(adminMap.get('4444444444')).toBe(true); + }); + + test('fallback a fetchAllGroups cuando /group/participants no trae participants', async () => { + globalThis.fetch = async (url: RequestInfo | URL) => { + const u = String(url); + if (u.includes('/group/participants/')) { + // OK pero sin campo participants + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (u.includes('/group/fetchAllGroups/')) { + const wrapped = { + response: [ + { id: '123@g.us', participants: [{ id: '9999999999@s.whatsapp.net', admin: 'admin' }] } + ] + }; + return new Response(JSON.stringify(wrapped), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return new Response('not found', { status: 404 }); + }; + + const out = await (GroupSyncService as any).fetchGroupMembersFromAPI('123@g.us'); + expect(out).toEqual([{ userId: '9999999999', isAdmin: true }]); + }); + + test('fallback devuelve snapshot vacío si el grupo no está presente', async () => { + globalThis.fetch = async (url: RequestInfo | URL) => { + const u = String(url); + if (u.includes('/group/participants/')) { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (u.includes('/group/fetchAllGroups/')) { + const wrapped = { + response: [ + { id: 'other@g.us', participants: [{ id: '9999999999@s.whatsapp.net', admin: 'admin' }] } + ] + }; + return new Response(JSON.stringify(wrapped), { status: 200 }); + } + return new Response('not found', { status: 404 }); + }; + + const out = await (GroupSyncService as any).fetchGroupMembersFromAPI('123@g.us'); + expect(out).toEqual([]); + }); +}); diff --git a/tests/unit/services/group-sync.scheduler.test.ts b/tests/unit/services/group-sync.scheduler.test.ts new file mode 100644 index 0000000..0f4b100 --- /dev/null +++ b/tests/unit/services/group-sync.scheduler.test.ts @@ -0,0 +1,48 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { GroupSyncService } from '../../../src/services/group-sync'; + +const envBackup = { ...process.env }; +let originalSyncMembers: any; + +function sleep(ms: number) { + return new Promise(res => setTimeout(res, ms)); +} + +describe('GroupSyncService - scheduler de miembros', () => { + beforeEach(() => { + originalSyncMembers = GroupSyncService.syncMembersForActiveGroups; + }); + + afterEach(() => { + GroupSyncService.stopMembersScheduler(); + GroupSyncService.syncMembersForActiveGroups = originalSyncMembers; + process.env = envBackup; + }); + + test('no arranca en entorno de test', async () => { + process.env = { ...envBackup, NODE_ENV: 'test' }; + let called = 0; + GroupSyncService.syncMembersForActiveGroups = async () => { + called++; + return { groups: 0, added: 0, updated: 0, deactivated: 0 }; + }; + + GroupSyncService.startMembersScheduler(); + await sleep(100); + expect(called).toBe(0); + }); + + test('arranca en producción y ejecuta según intervalo', async () => { + process.env = { ...envBackup, NODE_ENV: 'production', GROUP_MEMBERS_SYNC_INTERVAL_MS: '30' }; + let called = 0; + GroupSyncService.syncMembersForActiveGroups = async () => { + called++; + return { groups: 0, added: 0, updated: 0, deactivated: 0 }; + }; + + GroupSyncService.startMembersScheduler(); + await sleep(120); + GroupSyncService.stopMembersScheduler(); + expect(called).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/tests/unit/services/group-sync.sync-members.test.ts b/tests/unit/services/group-sync.sync-members.test.ts new file mode 100644 index 0000000..d2b6797 --- /dev/null +++ b/tests/unit/services/group-sync.sync-members.test.ts @@ -0,0 +1,83 @@ +import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { GroupSyncService } from '../../../src/services/group-sync'; + +let memdb: Database; +const envBackup = { ...process.env }; +let originalFetchMembers: any; + +describe('GroupSyncService - syncMembersForActiveGroups (agregado por grupos)', () => { + beforeAll(() => { + memdb = new Database(':memory:'); + memdb.exec('PRAGMA foreign_keys = ON;'); + initializeDatabase(memdb); + GroupSyncService.dbInstance = memdb as any; + }); + + afterAll(() => { + memdb.close(); + }); + + beforeEach(() => { + process.env = { ...envBackup, NODE_ENV: 'development' }; // evitar early return + // Reset tablas + memdb.exec('DELETE FROM group_members'); + memdb.exec('DELETE FROM users'); + memdb.exec('DELETE FROM groups'); + + // Grupo activo + memdb.prepare(`INSERT INTO groups (id, community_id, name, active) VALUES (?, ?, ?, 1)`) + .run('g1@g.us', 'community-1', 'Grupo 1'); + + // Refrescar caché de grupos activos + GroupSyncService.refreshActiveGroupsCache(); + + // Stub método de red + originalFetchMembers = (GroupSyncService as any).fetchGroupMembersFromAPI; + }); + + afterEach(() => { + (GroupSyncService as any).fetchGroupMembersFromAPI = originalFetchMembers; + process.env = envBackup; + }); + + test('primera pasada añade miembros y marca activos', async () => { + (GroupSyncService as any).fetchGroupMembersFromAPI = async (_gid: string) => ([ + { userId: '111', isAdmin: true }, + { userId: '222', isAdmin: false }, + ]); + + const res = await GroupSyncService.syncMembersForActiveGroups(); + expect(res).toEqual({ groups: 1, added: 2, updated: 0, deactivated: 0 }); + + const rows = memdb.prepare(`SELECT user_id, is_admin, is_active FROM group_members ORDER BY user_id`).all() as any[]; + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual({ user_id: '111', is_admin: 1, is_active: 1 }); + expect(rows[1]).toEqual({ user_id: '222', is_admin: 0, is_active: 1 }); + }); + + test('pasadas posteriores actualizan roles y desactivan ausentes', async () => { + // Semilla inicial + (GroupSyncService as any).fetchGroupMembersFromAPI = async (_gid: string) => ([ + { userId: '111', isAdmin: true }, + { userId: '222', isAdmin: false }, + ]); + await GroupSyncService.syncMembersForActiveGroups(); + + // Cambio: 111 pierde admin, 222 desaparece + (GroupSyncService as any).fetchGroupMembersFromAPI = async (_gid: string) => ([ + { userId: '111', isAdmin: false }, + ]); + + const res2 = await GroupSyncService.syncMembersForActiveGroups(); + expect(res2).toEqual({ groups: 1, added: 0, updated: 1, deactivated: 1 }); + + const rows = memdb.prepare(`SELECT user_id, is_admin, is_active FROM group_members ORDER BY user_id`).all() as any[]; + const m111 = rows.find(r => r.user_id === '111'); + const m222 = rows.find(r => r.user_id === '222'); + expect(m111.is_admin).toBe(0); + expect(m111.is_active).toBe(1); + expect(m222.is_active).toBe(0); + }); +});