import { beforeEach, describe, expect, it, afterEach } from 'bun:test'; import Database from 'bun:sqlite'; import { initializeDatabase } from '../../../src/db'; import { MaintenanceService } from '../../../src/services/maintenance'; import { toIsoSqlUTC } from '../../../src/utils/datetime'; import { Metrics } from '../../../src/services/metrics'; function makeMem(): any { const db = new Database(':memory:'); initializeDatabase(db); return db; } describe('MaintenanceService', () => { let memdb: any; beforeEach(() => { memdb = makeMem(); }); it('cleanupInactiveMembersOnce elimina miembros inactivos más antiguos que el umbral', async () => { // Grupo y usuarios memdb.exec(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES ('g1@g.us','comm','G',1);`); memdb.exec(`INSERT OR IGNORE INTO users (id) VALUES ('111');`); memdb.exec(`INSERT OR IGNORE INTO users (id) VALUES ('222');`); // last_seen_at muy antiguo → debe borrarse memdb.exec(` INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) VALUES ('g1@g.us','111',0,0,'2020-01-01 00:00:00','2020-01-01 00:00:00'); `); // last_seen_at reciente → no debe borrarse const recent = toIsoSqlUTC(new Date(Date.now() - 12 * 60 * 60 * 1000)); memdb.exec(` INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) VALUES ('g1@g.us','222',0,0,'${recent}','${recent}'); `); const deleted = await MaintenanceService.cleanupInactiveMembersOnce(memdb, 1); expect(deleted).toBe(1); const rows = memdb.prepare(`SELECT user_id FROM group_members ORDER BY user_id`).all() as any[]; const remaining = rows.map(r => r.user_id); expect(remaining).toEqual(['222']); }); it('cleanupInactiveMembersOnce no hace nada si retención <= 0', async () => { const deleted = await MaintenanceService.cleanupInactiveMembersOnce(memdb, 0); expect(deleted).toBe(0); }); it('reconcileAliasUsersOnce fusiona alias a usuario real en tablas principales', async () => { // Sembrar usuarios memdb.exec(`INSERT OR IGNORE INTO users (id) VALUES ('alias-1');`); memdb.exec(`INSERT OR IGNORE INTO users (id) VALUES ('real-1');`); // Tarea creada por alias y asignaciones usando alias const res = memdb.prepare(` INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at) VALUES ('T1', NULL, NULL, 'alias-1', 0, NULL) `).run() as any; const taskId = Number(res.lastInsertRowid); memdb.exec(` INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by) VALUES (${taskId}, 'alias-1', 'alias-1'); `); // Tabla de alias memdb.exec(` INSERT OR IGNORE INTO user_aliases (alias, user_id, source) VALUES ('alias-1', 'real-1', 'test'); `); const merged = await MaintenanceService.reconcileAliasUsersOnce(memdb); expect(merged).toBeGreaterThanOrEqual(1); const tRow = memdb.prepare(`SELECT created_by FROM tasks WHERE id = ?`).get(taskId) as any; expect(String(tRow.created_by)).toBe('real-1'); const aRows = memdb.prepare(`SELECT user_id, assigned_by FROM task_assignments WHERE task_id = ?`).all(taskId) as any[]; expect(aRows.length).toBe(1); expect(String(aRows[0].user_id)).toBe('real-1'); expect(String(aRows[0].assigned_by)).toBe('real-1'); }); it('reconcileAliasUsersOnce retorna 0 si no existe la tabla user_aliases', async () => { try { memdb.exec(`DROP TABLE IF EXISTS user_aliases;`); } catch {} const merged = await MaintenanceService.reconcileAliasUsersOnce(memdb); expect(merged).toBe(0); }); }); describe('MaintenanceService - Evolution health check', () => { let originalFetch: any; const ENV_KEYS = ['METRICS_ENABLED', 'EVOLUTION_API_URL', 'EVOLUTION_API_INSTANCE', 'EVOLUTION_API_KEY', 'HEALTH_CHECK_RESTART_COOLDOWN_MS', 'HEALTH_CHECK_INTERVAL_MS']; const savedEnv: Record = {}; beforeEach(() => { // Guardar y configurar entorno mínimo para habilitar métricas y health-check for (const k of ENV_KEYS) savedEnv[k] = process.env[k]; process.env.METRICS_ENABLED = 'true'; process.env.EVOLUTION_API_URL = 'http://evo'; process.env.EVOLUTION_API_INSTANCE = 'inst'; process.env.EVOLUTION_API_KEY = 'key'; process.env.HEALTH_CHECK_RESTART_COOLDOWN_MS = '0'; // facilitar intentos de reinicio en tests // Resetear métricas y estado interno Metrics.reset(); (MaintenanceService as any)._lastEvolutionState = null; (MaintenanceService as any)._lastStateChangeTs = 0; (MaintenanceService as any)._lastRestartAttempt = 0; // Guardar fetch original originalFetch = globalThis.fetch; }); afterEach(() => { // Restaurar entorno for (const k of ENV_KEYS) { if (savedEnv[k] == null) delete (process.env as any)[k]; else process.env[k] = savedEnv[k]; } // Restaurar fetch globalThis.fetch = originalFetch; // Reset de métricas tras cada caso Metrics.reset(); }); it('registra y actualiza métricas en transición de estado (open → closed)', async () => { let currentState = 'open'; let restartStatus = 200; globalThis.fetch = async (url: any, init?: any) => { const u = String(url); if (u.includes('/instance/connectionState/')) { return new Response(JSON.stringify({ instance: { state: currentState } }), { status: 200 }); } if (u.includes('/instance/restart/')) { return new Response('', { status: restartStatus }); } return new Response('', { status: 404 }); }; // Primer muestreo: open await (MaintenanceService as any).performEvolutionHealthCheck(); // Segundo muestreo: closed (debe disparar transición) currentState = 'closed'; await (MaintenanceService as any).performEvolutionHealthCheck(); const stats = JSON.parse(Metrics.render('json')); const lg = stats.labeledGauges || {}; const lc = stats.labeledCounters || {}; expect(lg.evolution_instance_state['instance="inst",state="closed"']).toBe(1); expect(lg.evolution_instance_state['instance="inst",state="open"']).toBe(0); expect(typeof lg.evolution_instance_last_state_change_ts['instance="inst"']).toBe('number'); expect(lc.evolution_instance_state_changes_total['instance="inst"']).toBe(1); }); it('no incrementa cambios si el estado se repite (open → open)', async () => { let currentState = 'open'; globalThis.fetch = async (url: any, init?: any) => { const u = String(url); if (u.includes('/instance/connectionState/')) { return new Response(JSON.stringify({ instance: { state: currentState } }), { status: 200 }); } if (u.includes('/instance/restart/')) { return new Response('', { status: 200 }); } return new Response('', { status: 404 }); }; await (MaintenanceService as any).performEvolutionHealthCheck(); await (MaintenanceService as any).performEvolutionHealthCheck(); const stats = JSON.parse(Metrics.render('json')); const lg = stats.labeledGauges || {}; const lc = stats.labeledCounters || {}; expect(lg.evolution_instance_state['instance="inst",state="open"']).toBe(1); const changes = lc.evolution_instance_state_changes_total; expect(!changes || Object.values(changes).reduce((a: number, b: any) => a + Number(b || 0), 0) === 0).toBe(true); }); it('registra error y marca estado "unreachable" ante HTTP no OK', async () => { globalThis.fetch = async (url: any, init?: any) => { const u = String(url); if (u.includes('/instance/connectionState/')) { return new Response('err', { status: 500 }); } return new Response('', { status: 404 }); }; await (MaintenanceService as any).performEvolutionHealthCheck(); const stats = JSON.parse(Metrics.render('json')); const lg = stats.labeledGauges || {}; const lc = stats.labeledCounters || {}; expect(lg.evolution_instance_state['instance="inst",state="unreachable"']).toBe(1); expect(lc.evolution_health_check_errors_total['instance="inst"']).toBe(1); }); it('incrementa attempts y success al reiniciar cuando el estado no es open', async () => { let currentState = 'closed'; let restartStatus = 200; globalThis.fetch = async (url: any, init?: any) => { const u = String(url); if (u.includes('/instance/connectionState/')) { return new Response(JSON.stringify({ instance: { state: currentState } }), { status: 200 }); } if (u.includes('/instance/restart/')) { return new Response('', { status: restartStatus }); } return new Response('', { status: 404 }); }; await (MaintenanceService as any).performEvolutionHealthCheck(); const stats = JSON.parse(Metrics.render('json')); const lc = stats.labeledCounters || {}; expect(lc.evolution_instance_restart_attempts_total['instance="inst"']).toBe(1); expect(lc.evolution_instance_restart_success_total['instance="inst"']).toBe(1); }); it('no incrementa success si el reinicio falla', async () => { let currentState = 'closed'; let restartStatus = 500; globalThis.fetch = async (url: any, init?: any) => { const u = String(url); if (u.includes('/instance/connectionState/')) { return new Response(JSON.stringify({ instance: { state: currentState } }), { status: 200 }); } if (u.includes('/instance/restart/')) { return new Response('', { status: restartStatus }); } return new Response('', { status: 404 }); }; await (MaintenanceService as any).performEvolutionHealthCheck(); const stats = JSON.parse(Metrics.render('json')); const lc = stats.labeledCounters || {}; expect(lc.evolution_instance_restart_attempts_total['instance="inst"']).toBe(1); expect(!lc.evolution_instance_restart_success_total || lc.evolution_instance_restart_success_total['instance="inst"'] == null).toBe(true); }); });