@ -0,0 +1,149 @@
|
|||||||
|
import type { Database } from 'bun:sqlite';
|
||||||
|
import { db } from '../db';
|
||||||
|
|
||||||
|
type GroupStatus = 'pending' | 'allowed' | 'blocked';
|
||||||
|
|
||||||
|
type CacheEntry = {
|
||||||
|
status: GroupStatus;
|
||||||
|
label: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AllowedGroups {
|
||||||
|
static dbInstance: Database = db;
|
||||||
|
|
||||||
|
// Caché en memoria: group_id (JID completo) -> { status, label }
|
||||||
|
private static cache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
private static nowExpr = "strftime('%Y-%m-%d %H:%M:%f','now')";
|
||||||
|
|
||||||
|
static clearCache() {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
static resetForTests() {
|
||||||
|
this.clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getRow(groupId: string): { group_id: string; label: string | null; status: GroupStatus } | null {
|
||||||
|
try {
|
||||||
|
const row = this.dbInstance
|
||||||
|
.prepare(`SELECT group_id, label, status FROM allowed_groups WHERE group_id = ?`)
|
||||||
|
.get(groupId) as any;
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
group_id: String(row.group_id),
|
||||||
|
label: row.label != null ? String(row.label) : null,
|
||||||
|
status: String(row.status) as GroupStatus,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Tabla podría no existir en contextos muy tempranos: degradar a null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static isAllowed(groupId: string | null | undefined): boolean {
|
||||||
|
const gid = String(groupId || '').trim();
|
||||||
|
if (!gid) return false;
|
||||||
|
|
||||||
|
const cached = this.cache.get(gid);
|
||||||
|
if (cached) return cached.status === 'allowed';
|
||||||
|
|
||||||
|
const row = this.getRow(gid);
|
||||||
|
const status = row?.status || 'pending';
|
||||||
|
const label = row?.label ?? null;
|
||||||
|
this.cache.set(gid, { status, label });
|
||||||
|
return status === 'allowed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserta un grupo como pending si no existe. No degrada estados existentes (allowed/blocked).
|
||||||
|
* Actualiza label si se proporciona y cambió.
|
||||||
|
*/
|
||||||
|
static upsertPending(groupId: string, label?: string | null, discoveredBy?: string | null): void {
|
||||||
|
const gid = String(groupId || '').trim();
|
||||||
|
if (!gid) return;
|
||||||
|
|
||||||
|
const row = this.getRow(gid);
|
||||||
|
if (!row) {
|
||||||
|
// Insertar como pending
|
||||||
|
this.dbInstance
|
||||||
|
.prepare(`
|
||||||
|
INSERT INTO allowed_groups (group_id, label, status, discovered_at, updated_at, discovered_by)
|
||||||
|
VALUES (?, ?, 'pending', ${this.nowExpr}, ${this.nowExpr}, ?)
|
||||||
|
`)
|
||||||
|
.run(gid, label ?? null, discoveredBy ?? null);
|
||||||
|
this.cache.set(gid, { status: 'pending', label: label ?? null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cambiar status existente. Solo actualizar label si se aporta y cambió.
|
||||||
|
const newLabel = label ?? row.label;
|
||||||
|
if (label != null && String(row.label ?? '') !== String(label)) {
|
||||||
|
this.dbInstance
|
||||||
|
.prepare(`
|
||||||
|
UPDATE allowed_groups
|
||||||
|
SET label = ?, updated_at = ${this.nowExpr}
|
||||||
|
WHERE group_id = ?
|
||||||
|
`)
|
||||||
|
.run(newLabel, gid);
|
||||||
|
}
|
||||||
|
this.cache.set(gid, { status: row.status, label: newLabel ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establece el estado de un grupo (upsert). Devuelve true si cambió algo (estado o label).
|
||||||
|
*/
|
||||||
|
static setStatus(groupId: string, status: GroupStatus, label?: string | null): boolean {
|
||||||
|
const gid = String(groupId || '').trim();
|
||||||
|
if (!gid) return false;
|
||||||
|
|
||||||
|
const before = this.getRow(gid);
|
||||||
|
this.dbInstance
|
||||||
|
.prepare(`
|
||||||
|
INSERT INTO allowed_groups (group_id, label, status, discovered_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ${this.nowExpr}, ${this.nowExpr})
|
||||||
|
ON CONFLICT(group_id) DO UPDATE SET
|
||||||
|
status = excluded.status,
|
||||||
|
label = COALESCE(excluded.label, allowed_groups.label),
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`)
|
||||||
|
.run(gid, label ?? null, status);
|
||||||
|
|
||||||
|
const after = this.getRow(gid) || { group_id: gid, label: label ?? null, status };
|
||||||
|
this.cache.set(gid, { status: after.status, label: after.label });
|
||||||
|
|
||||||
|
return (
|
||||||
|
!before ||
|
||||||
|
before.status !== after.status ||
|
||||||
|
(label != null && String(before.label ?? '') !== String(label))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static listByStatus(status: GroupStatus): Array<{ group_id: string; label: string | null }> {
|
||||||
|
const rows = this.dbInstance
|
||||||
|
.prepare(
|
||||||
|
`SELECT group_id, label FROM allowed_groups WHERE status = ? ORDER BY group_id`
|
||||||
|
)
|
||||||
|
.all(status) as Array<{ group_id: string; label: string | null }>;
|
||||||
|
return rows.map(r => ({
|
||||||
|
group_id: String((r as any).group_id),
|
||||||
|
label: (r as any).label != null ? String((r as any).label) : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marca como allowed todos los group_ids provistos en una cadena separada por comas.
|
||||||
|
* Si env no se pasa, usa process.env.ALLOWED_GROUPS.
|
||||||
|
*/
|
||||||
|
static seedFromEnv(env?: string | null): void {
|
||||||
|
const val = (env ?? process?.env?.ALLOWED_GROUPS ?? '').trim();
|
||||||
|
if (!val) return;
|
||||||
|
const ids = val
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const gid of ids) {
|
||||||
|
this.setStatus(gid, 'allowed', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
import Database, { type Database as SqliteDatabase } from 'bun:sqlite';
|
||||||
|
import { initializeDatabase } from '../../src/db';
|
||||||
|
|
||||||
|
// Servicios opcionales para inyección de DB en tests.
|
||||||
|
// Importamos con nombres existentes en la base de código para respetar convenciones.
|
||||||
|
import { TaskService } from '../../src/tasks/service';
|
||||||
|
import { CommandService } from '../../src/services/command';
|
||||||
|
import { ResponseQueue } from '../../src/services/response-queue';
|
||||||
|
import { IdentityService } from '../../src/services/identity';
|
||||||
|
import { GroupSyncService } from '../../src/services/group-sync';
|
||||||
|
import { RemindersService } from '../../src/services/reminders';
|
||||||
|
import { AllowedGroups } from '../../src/services/allowed-groups';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea una DB en memoria y aplica initializeDatabase() con todas las migraciones.
|
||||||
|
*/
|
||||||
|
export function makeMemDb(): SqliteDatabase {
|
||||||
|
const memdb = new Database(':memory:');
|
||||||
|
initializeDatabase(memdb);
|
||||||
|
return memdb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inyecta la instancia de DB en los servicios que la exponen como propiedad estática.
|
||||||
|
* Pensado para usarse en beforeAll/beforeEach de tests que usan estos servicios.
|
||||||
|
*/
|
||||||
|
export function injectAllServices(db: SqliteDatabase): void {
|
||||||
|
try { (TaskService as any).dbInstance = db; } catch {}
|
||||||
|
try { (CommandService as any).dbInstance = db; } catch {}
|
||||||
|
try { (ResponseQueue as any).dbInstance = db; } catch {}
|
||||||
|
try { (IdentityService as any).dbInstance = db; } catch {}
|
||||||
|
try { (GroupSyncService as any).dbInstance = db; } catch {}
|
||||||
|
try { (RemindersService as any).dbInstance = db; } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restablece estado global/cachés en servicios entre tests.
|
||||||
|
* Best-effort: solo llama si existen los métodos.
|
||||||
|
*/
|
||||||
|
export function resetServices(): void {
|
||||||
|
try { (IdentityService as any).clearCache?.(); } catch {}
|
||||||
|
try { (GroupSyncService as any).clearCaches?.(); } catch {}
|
||||||
|
try { (CommandService as any).resetForTests?.(); } catch {}
|
||||||
|
try { (ResponseQueue as any).resetForTests?.(); } catch {}
|
||||||
|
try { (RemindersService as any).resetForTests?.(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marca como 'allowed' los groupIds indicados en la DB provista.
|
||||||
|
*/
|
||||||
|
export function seedAllowed(db: SqliteDatabase, groupIds: string[]): void {
|
||||||
|
(AllowedGroups as any).dbInstance = db;
|
||||||
|
for (const gid of groupIds) {
|
||||||
|
const g = String(gid || '').trim();
|
||||||
|
if (!g) continue;
|
||||||
|
try { AllowedGroups.setStatus(g, 'allowed'); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Setup opcional para tests. Si el runner lo soporta (p. ej., bun test con --preload),
|
||||||
|
* fija el modo de gating por defecto a 'off' para no afectar suites existentes.
|
||||||
|
*/
|
||||||
|
if (!process.env.GROUP_GATING_MODE) {
|
||||||
|
process.env.GROUP_GATING_MODE = 'off';
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import Database from 'bun:sqlite';
|
||||||
|
import { initializeDatabase } from '../../../src/db';
|
||||||
|
|
||||||
|
describe('Migración v9 - allowed_groups', () => {
|
||||||
|
it('crea la tabla allowed_groups', () => {
|
||||||
|
const memdb = new Database(':memory:');
|
||||||
|
expect(() => initializeDatabase(memdb)).not.toThrow();
|
||||||
|
|
||||||
|
const row = memdb
|
||||||
|
.query(`SELECT name FROM sqlite_master WHERE type='table' AND name='allowed_groups'`)
|
||||||
|
.get() as any;
|
||||||
|
|
||||||
|
expect(row?.name).toBe('allowed_groups');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforce CHECK de status', () => {
|
||||||
|
const memdb = new Database(':memory:');
|
||||||
|
initializeDatabase(memdb);
|
||||||
|
|
||||||
|
// En bun:sqlite, exec() puede no lanzar en constraint violation. Validamos no persistencia.
|
||||||
|
memdb.exec(`
|
||||||
|
INSERT INTO allowed_groups (group_id, status, discovered_at, updated_at)
|
||||||
|
VALUES ('123@g.us', 'invalid-status', strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'));
|
||||||
|
`);
|
||||||
|
|
||||||
|
const invalidCount = memdb
|
||||||
|
.query(`SELECT COUNT(*) AS c FROM allowed_groups WHERE group_id = '123@g.us'`)
|
||||||
|
.get() as any;
|
||||||
|
expect(Number(invalidCount?.c || 0)).toBe(0);
|
||||||
|
|
||||||
|
// Inserción válida debe persistir
|
||||||
|
memdb.exec(`
|
||||||
|
INSERT INTO allowed_groups (group_id, status, discovered_at, updated_at)
|
||||||
|
VALUES ('ok@g.us', 'allowed', strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'));
|
||||||
|
`);
|
||||||
|
const validCount = memdb
|
||||||
|
.query(`SELECT COUNT(*) AS c FROM allowed_groups WHERE group_id = 'ok@g.us'`)
|
||||||
|
.get() as any;
|
||||||
|
expect(Number(validCount?.c || 0)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import Database from 'bun:sqlite';
|
||||||
|
import { initializeDatabase } from '../../../src/db';
|
||||||
|
|
||||||
|
describe('DB migrations smoke', () => {
|
||||||
|
it('initializeDatabase en :memory: no lanza y crea tablas base', () => {
|
||||||
|
const memdb = new Database(':memory:');
|
||||||
|
expect(() => initializeDatabase(memdb)).not.toThrow();
|
||||||
|
|
||||||
|
// Comprobar que al menos una tabla base existe (users, tasks o response_queue).
|
||||||
|
const rows = memdb
|
||||||
|
.query(`SELECT name FROM sqlite_master WHERE type='table' AND name IN ('users','tasks','response_queue')`)
|
||||||
|
.all() as Array<{ name: string }>;
|
||||||
|
|
||||||
|
expect(Array.isArray(rows)).toBe(true);
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import { WebhookServer } from '../../../src/server';
|
||||||
|
import { initializeDatabase } from '../../../src/db';
|
||||||
|
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||||
|
|
||||||
|
let testDb: Database;
|
||||||
|
let originalAdd: any;
|
||||||
|
|
||||||
|
let simulatedQueue: any[] = [];
|
||||||
|
const SimulatedResponseQueue = {
|
||||||
|
async add(responses: any[]) {
|
||||||
|
simulatedQueue.push(...responses);
|
||||||
|
},
|
||||||
|
clear() { simulatedQueue = []; },
|
||||||
|
get() { return simulatedQueue; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestRequest = (payload: any) =>
|
||||||
|
new Request('http://localhost:3007', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WebhookServer - /admin aprobación en modo enforce', () => {
|
||||||
|
const envBackup = process.env;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
testDb = new Database(':memory:');
|
||||||
|
initializeDatabase(testDb);
|
||||||
|
originalAdd = (ResponseQueue as any).add;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
(ResponseQueue as any).add = originalAdd;
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = {
|
||||||
|
...envBackup,
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
GROUP_GATING_MODE: 'enforce',
|
||||||
|
ADMIN_USERS: '1234567890'
|
||||||
|
};
|
||||||
|
SimulatedResponseQueue.clear();
|
||||||
|
(ResponseQueue as any).add = SimulatedResponseQueue.add;
|
||||||
|
WebhookServer.dbInstance = testDb;
|
||||||
|
|
||||||
|
testDb.exec('DELETE FROM response_queue');
|
||||||
|
testDb.exec('DELETE FROM allowed_groups');
|
||||||
|
testDb.exec('DELETE FROM users');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = envBackup;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin puede habilitar el grupo actual incluso si no está allowed', async () => {
|
||||||
|
const payload = {
|
||||||
|
event: 'messages.upsert',
|
||||||
|
instance: 'test-instance',
|
||||||
|
data: {
|
||||||
|
key: {
|
||||||
|
remoteJid: 'new-group@g.us',
|
||||||
|
participant: '1234567890@s.whatsapp.net'
|
||||||
|
},
|
||||||
|
message: { conversation: '/admin habilitar-aquí' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await WebhookServer.handleRequest(createTestRequest(payload));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// Debe haber respuesta de confirmación encolada
|
||||||
|
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// El grupo debe figurar como allowed
|
||||||
|
const row = testDb.query(`SELECT status FROM allowed_groups WHERE group_id = 'new-group@g.us'`).get() as any;
|
||||||
|
expect(row && String(row.status)).toBe('allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no admin no puede usar /admin', async () => {
|
||||||
|
process.env.ADMIN_USERS = '5555555555';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
event: 'messages.upsert',
|
||||||
|
instance: 'test-instance',
|
||||||
|
data: {
|
||||||
|
key: {
|
||||||
|
remoteJid: 'another-group@g.us',
|
||||||
|
participant: '1234567890@s.whatsapp.net'
|
||||||
|
},
|
||||||
|
message: { conversation: '/admin habilitar-aquí' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await WebhookServer.handleRequest(createTestRequest(payload));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// Debe haber una respuesta indicando no autorizado
|
||||||
|
const out = SimulatedResponseQueue.get();
|
||||||
|
expect(out.length).toBe(1);
|
||||||
|
expect(String(out[0].message).toLowerCase()).toContain('no estás autorizado');
|
||||||
|
|
||||||
|
// Y el grupo no debe estar allowed
|
||||||
|
const row = testDb.query(`SELECT status FROM allowed_groups WHERE group_id = 'another-group@g.us'`).get() as any;
|
||||||
|
expect(row == null).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import { WebhookServer } from '../../../src/server';
|
||||||
|
import { initializeDatabase } from '../../../src/db';
|
||||||
|
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||||
|
import { GroupSyncService } from '../../../src/services/group-sync';
|
||||||
|
|
||||||
|
let testDb: Database;
|
||||||
|
let originalAdd: any;
|
||||||
|
|
||||||
|
let simulatedQueue: any[] = [];
|
||||||
|
const SimulatedResponseQueue = {
|
||||||
|
async add(responses: any[]) {
|
||||||
|
simulatedQueue.push(...responses);
|
||||||
|
},
|
||||||
|
clear() { simulatedQueue = []; },
|
||||||
|
get() { return simulatedQueue; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestRequest = (payload: any) =>
|
||||||
|
new Request('http://localhost:3007', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WebhookServer - discovery guarda label del grupo si está en caché', () => {
|
||||||
|
const envBackup = process.env;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
testDb = new Database(':memory:');
|
||||||
|
initializeDatabase(testDb);
|
||||||
|
originalAdd = (ResponseQueue as any).add;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
(ResponseQueue as any).add = originalAdd;
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = {
|
||||||
|
...envBackup,
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
GROUP_GATING_MODE: 'discover'
|
||||||
|
};
|
||||||
|
SimulatedResponseQueue.clear();
|
||||||
|
(ResponseQueue as any).add = SimulatedResponseQueue.add;
|
||||||
|
WebhookServer.dbInstance = testDb;
|
||||||
|
|
||||||
|
// Limpiar tablas relevantes
|
||||||
|
testDb.exec('DELETE FROM response_queue');
|
||||||
|
testDb.exec('DELETE FROM allowed_groups');
|
||||||
|
testDb.exec('DELETE FROM users');
|
||||||
|
|
||||||
|
// Poblar caché con el nombre del grupo
|
||||||
|
GroupSyncService.activeGroupsCache.clear();
|
||||||
|
GroupSyncService.activeGroupsCache.set('label-group@g.us', 'Proyecto Foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = envBackup;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registra pending con label del grupo desde la caché', async () => {
|
||||||
|
const payload = {
|
||||||
|
event: 'messages.upsert',
|
||||||
|
instance: 'test-instance',
|
||||||
|
data: {
|
||||||
|
key: {
|
||||||
|
remoteJid: 'label-group@g.us',
|
||||||
|
participant: '9999999999@s.whatsapp.net'
|
||||||
|
},
|
||||||
|
message: { conversation: '/t n hola' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await WebhookServer.handleRequest(createTestRequest(payload));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// No debe haber respuestas encoladas (retorno temprano)
|
||||||
|
expect(SimulatedResponseQueue.get().length).toBe(0);
|
||||||
|
|
||||||
|
// Debe existir registro pending con label en allowed_groups
|
||||||
|
const row = testDb
|
||||||
|
.query(`SELECT status, label FROM allowed_groups WHERE group_id = 'label-group@g.us'`)
|
||||||
|
.get() as any;
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(String(row.status)).toBe('pending');
|
||||||
|
expect(String(row.label)).toBe('Proyecto Foo');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import { WebhookServer } from '../../../src/server';
|
||||||
|
import { initializeDatabase } from '../../../src/db';
|
||||||
|
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||||
|
|
||||||
|
let testDb: Database;
|
||||||
|
let originalAdd: any;
|
||||||
|
|
||||||
|
let simulatedQueue: any[] = [];
|
||||||
|
const SimulatedResponseQueue = {
|
||||||
|
async add(responses: any[]) {
|
||||||
|
simulatedQueue.push(...responses);
|
||||||
|
},
|
||||||
|
clear() { simulatedQueue = []; },
|
||||||
|
get() { return simulatedQueue; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestRequest = (payload: any) =>
|
||||||
|
new Request('http://localhost:3007', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WebhookServer - notifica a ADMIN_USERS en descubrimiento (modo discover)', () => {
|
||||||
|
const envBackup = process.env;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
testDb = new Database(':memory:');
|
||||||
|
initializeDatabase(testDb);
|
||||||
|
originalAdd = (ResponseQueue as any).add;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
(ResponseQueue as any).add = originalAdd;
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = {
|
||||||
|
...envBackup,
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
GROUP_GATING_MODE: 'discover',
|
||||||
|
NOTIFY_ADMINS_ON_DISCOVERY: 'true',
|
||||||
|
ADMIN_USERS: '1234567890, 5555555555'
|
||||||
|
};
|
||||||
|
SimulatedResponseQueue.clear();
|
||||||
|
(ResponseQueue as any).add = SimulatedResponseQueue.add;
|
||||||
|
WebhookServer.dbInstance = testDb;
|
||||||
|
|
||||||
|
// Limpiar tablas relevantes
|
||||||
|
testDb.exec('DELETE FROM response_queue');
|
||||||
|
testDb.exec('DELETE FROM allowed_groups');
|
||||||
|
testDb.exec('DELETE FROM users');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = envBackup;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registra pending y envía DMs a todos los admins', async () => {
|
||||||
|
const payload = {
|
||||||
|
event: 'messages.upsert',
|
||||||
|
instance: 'test-instance',
|
||||||
|
data: {
|
||||||
|
key: {
|
||||||
|
remoteJid: 'notify-group@g.us',
|
||||||
|
participant: '9999999999@s.whatsapp.net'
|
||||||
|
},
|
||||||
|
message: { conversation: '/t n hola' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await WebhookServer.handleRequest(createTestRequest(payload));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// allowed_groups debe tener el grupo en pending
|
||||||
|
const row = testDb
|
||||||
|
.query(`SELECT status FROM allowed_groups WHERE group_id = 'notify-group@g.us'`)
|
||||||
|
.get() as any;
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(String(row.status)).toBe('pending');
|
||||||
|
|
||||||
|
// Debe haberse encolado una notificación por cada admin
|
||||||
|
const out = SimulatedResponseQueue.get();
|
||||||
|
const recipients = out.map((r: any) => r.recipient).sort();
|
||||||
|
expect(out.length).toBe(2);
|
||||||
|
expect(recipients).toEqual(['1234567890', '5555555555']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import { WebhookServer } from '../../../src/server';
|
||||||
|
import { initializeDatabase } from '../../../src/db';
|
||||||
|
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||||
|
import { AllowedGroups } from '../../../src/services/allowed-groups';
|
||||||
|
import { GroupSyncService } from '../../../src/services/group-sync';
|
||||||
|
|
||||||
|
let testDb: Database;
|
||||||
|
let originalAdd: any;
|
||||||
|
|
||||||
|
let simulatedQueue: any[] = [];
|
||||||
|
const SimulatedResponseQueue = {
|
||||||
|
async add(responses: any[]) {
|
||||||
|
simulatedQueue.push(...responses);
|
||||||
|
},
|
||||||
|
clear() { simulatedQueue = []; },
|
||||||
|
get() { return simulatedQueue; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestRequest = (payload: any) =>
|
||||||
|
new Request('http://localhost:3007', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WebhookServer - enforce gating (modo=enforce)', () => {
|
||||||
|
const envBackup = process.env;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
testDb = new Database(':memory:');
|
||||||
|
initializeDatabase(testDb);
|
||||||
|
originalAdd = (ResponseQueue as any).add;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
(ResponseQueue as any).add = originalAdd;
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = {
|
||||||
|
...envBackup,
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
GROUP_GATING_MODE: 'enforce'
|
||||||
|
};
|
||||||
|
SimulatedResponseQueue.clear();
|
||||||
|
(ResponseQueue as any).add = SimulatedResponseQueue.add;
|
||||||
|
WebhookServer.dbInstance = testDb;
|
||||||
|
(AllowedGroups as any).dbInstance = testDb;
|
||||||
|
|
||||||
|
// Limpiar tablas relevantes
|
||||||
|
testDb.exec('DELETE FROM response_queue');
|
||||||
|
testDb.exec('DELETE FROM allowed_groups');
|
||||||
|
testDb.exec('DELETE FROM users');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = envBackup;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bloquea mensaje de grupo no permitido (no se encolan respuestas)', async () => {
|
||||||
|
const payload = {
|
||||||
|
event: 'messages.upsert',
|
||||||
|
instance: 'test-instance',
|
||||||
|
data: {
|
||||||
|
key: {
|
||||||
|
remoteJid: 'blocked-group@g.us',
|
||||||
|
participant: '1234567890@s.whatsapp.net'
|
||||||
|
},
|
||||||
|
message: { conversation: '/t ayuda' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await WebhookServer.handleRequest(createTestRequest(payload));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// No debe haber respuestas encoladas (retorno temprano)
|
||||||
|
expect(SimulatedResponseQueue.get().length).toBe(0);
|
||||||
|
|
||||||
|
// allowed_groups no contiene allowed para ese grupo (get() devuelve null cuando no hay filas)
|
||||||
|
const row = testDb.query(`SELECT status FROM allowed_groups WHERE group_id = 'blocked-group@g.us'`).get() as any;
|
||||||
|
expect(row == null).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('permite mensaje en grupo allowed y procesa comando', async () => {
|
||||||
|
// Sembrar grupo como allowed
|
||||||
|
testDb.exec(`
|
||||||
|
INSERT INTO allowed_groups (group_id, status, discovered_at, updated_at)
|
||||||
|
VALUES ('allowed-group@g.us', 'allowed', strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Marcar el grupo como activo en la caché para evitar retorno temprano por "grupo inactivo" en tests
|
||||||
|
GroupSyncService.activeGroupsCache.set('allowed-group@g.us', 'Allowed Group');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
event: 'messages.upsert',
|
||||||
|
instance: 'test-instance',
|
||||||
|
data: {
|
||||||
|
key: {
|
||||||
|
remoteJid: 'allowed-group@g.us',
|
||||||
|
participant: '1234567890@s.whatsapp.net'
|
||||||
|
},
|
||||||
|
message: { conversation: '/t ayuda' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await WebhookServer.handleRequest(createTestRequest(payload));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// Debe haberse encolado al menos una respuesta (ayuda)
|
||||||
|
expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import { WebhookServer } from '../../../src/server';
|
||||||
|
import { initializeDatabase } from '../../../src/db';
|
||||||
|
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||||
|
|
||||||
|
let testDb: Database;
|
||||||
|
let originalAdd: any;
|
||||||
|
|
||||||
|
let simulatedQueue: any[] = [];
|
||||||
|
const SimulatedResponseQueue = {
|
||||||
|
async add(responses: any[]) {
|
||||||
|
simulatedQueue.push(...responses);
|
||||||
|
},
|
||||||
|
clear() { simulatedQueue = []; },
|
||||||
|
get() { return simulatedQueue; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestRequest = (payload: any) =>
|
||||||
|
new Request('http://localhost:3007', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WebhookServer - unknown group discovery (mode=discover)', () => {
|
||||||
|
const envBackup = process.env;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
testDb = new Database(':memory:');
|
||||||
|
initializeDatabase(testDb);
|
||||||
|
originalAdd = (ResponseQueue as any).add;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
(ResponseQueue as any).add = originalAdd;
|
||||||
|
testDb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = {
|
||||||
|
...envBackup,
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
GROUP_GATING_MODE: 'discover'
|
||||||
|
};
|
||||||
|
SimulatedResponseQueue.clear();
|
||||||
|
(ResponseQueue as any).add = SimulatedResponseQueue.add;
|
||||||
|
WebhookServer.dbInstance = testDb;
|
||||||
|
|
||||||
|
// Limpiar tablas relevantes
|
||||||
|
testDb.exec('DELETE FROM response_queue');
|
||||||
|
testDb.exec('DELETE FROM allowed_groups');
|
||||||
|
testDb.exec('DELETE FROM users');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = envBackup;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registra grupo desconocido como pending y no procesa comandos', async () => {
|
||||||
|
const payload = {
|
||||||
|
event: 'messages.upsert',
|
||||||
|
instance: 'test-instance',
|
||||||
|
data: {
|
||||||
|
key: {
|
||||||
|
remoteJid: 'new-group@g.us',
|
||||||
|
participant: '1234567890@s.whatsapp.net'
|
||||||
|
},
|
||||||
|
message: { conversation: '/t n hola' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await WebhookServer.handleRequest(createTestRequest(payload));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// No debe haber respuestas encoladas (retorno temprano)
|
||||||
|
expect(SimulatedResponseQueue.get().length).toBe(0);
|
||||||
|
|
||||||
|
// Debe existir registro pending en allowed_groups
|
||||||
|
const row = testDb
|
||||||
|
.query(`SELECT status FROM allowed_groups WHERE group_id = 'new-group@g.us'`)
|
||||||
|
.get() as any;
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(String(row.status)).toBe('pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, beforeEach, expect } from 'bun:test';
|
||||||
|
import { makeMemDb } from '../../helpers/db';
|
||||||
|
import { AdminService } from '../../../src/services/admin';
|
||||||
|
import { AllowedGroups } from '../../../src/services/allowed-groups';
|
||||||
|
|
||||||
|
describe('AdminService - comandos básicos', () => {
|
||||||
|
const envBackup = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...envBackup, NODE_ENV: 'test', ADMIN_USERS: '34600123456' };
|
||||||
|
const memdb = makeMemDb();
|
||||||
|
(AdminService as any).dbInstance = memdb;
|
||||||
|
(AllowedGroups as any).dbInstance = memdb;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rechaza a usuarios no admin', async () => {
|
||||||
|
const out = await AdminService.handle({
|
||||||
|
sender: '34999888777',
|
||||||
|
groupId: 'g1@g.us',
|
||||||
|
message: '/admin pendientes'
|
||||||
|
});
|
||||||
|
expect(out.length).toBe(1);
|
||||||
|
expect(out[0].message.toLowerCase()).toContain('no estás autorizado');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lista pendientes', async () => {
|
||||||
|
AllowedGroups.upsertPending('a@g.us', 'A', 'tester');
|
||||||
|
AllowedGroups.upsertPending('b@g.us', 'B', 'tester');
|
||||||
|
|
||||||
|
const out = await AdminService.handle({
|
||||||
|
sender: '34600123456',
|
||||||
|
groupId: 'g1@g.us',
|
||||||
|
message: '/admin pendientes'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out.length).toBe(1);
|
||||||
|
expect(out[0].message).toContain('Grupos pendientes');
|
||||||
|
expect(out[0].message).toContain('a@g.us');
|
||||||
|
expect(out[0].message).toContain('b@g.us');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('habilitar-aquí en grupo', async () => {
|
||||||
|
const out = await AdminService.handle({
|
||||||
|
sender: '34600123456',
|
||||||
|
groupId: 'g1@g.us',
|
||||||
|
message: '/admin habilitar-aquí'
|
||||||
|
});
|
||||||
|
expect(out.length).toBe(1);
|
||||||
|
expect(AllowedGroups.isAllowed('g1@g.us')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allow-group <jid> habilita explícitamente', async () => {
|
||||||
|
const out = await AdminService.handle({
|
||||||
|
sender: '34600123456',
|
||||||
|
groupId: '1234567890@s.whatsapp.net',
|
||||||
|
message: '/admin allow-group g2@g.us'
|
||||||
|
});
|
||||||
|
expect(out.length).toBe(1);
|
||||||
|
expect(AllowedGroups.isAllowed('g2@g.us')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, beforeEach, expect } from 'bun:test';
|
||||||
|
import { makeMemDb } from '../../helpers/db';
|
||||||
|
import { AllowedGroups } from '../../../src/services/allowed-groups';
|
||||||
|
|
||||||
|
describe('AllowedGroups service', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const memdb = makeMemDb();
|
||||||
|
(AllowedGroups as any).dbInstance = memdb;
|
||||||
|
AllowedGroups.resetForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upsertPending inserta pending y es idempotente', () => {
|
||||||
|
const gid = '123@g.us';
|
||||||
|
|
||||||
|
AllowedGroups.upsertPending(gid, 'Grupo 123', 'tester');
|
||||||
|
AllowedGroups.upsertPending(gid, 'Grupo 123', 'tester');
|
||||||
|
|
||||||
|
// No se expone la DB aquí; validamos por comportamiento
|
||||||
|
expect(AllowedGroups.isAllowed(gid)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setStatus cambia a allowed y isAllowed refleja el estado', () => {
|
||||||
|
const gid = '456@g.us';
|
||||||
|
|
||||||
|
expect(AllowedGroups.isAllowed(gid)).toBe(false);
|
||||||
|
const changed = AllowedGroups.setStatus(gid, 'allowed', 'Grupo 456');
|
||||||
|
expect(changed).toBe(true);
|
||||||
|
expect(AllowedGroups.isAllowed(gid)).toBe(true);
|
||||||
|
|
||||||
|
// Repetir con el mismo estado no debe cambiar
|
||||||
|
const changedAgain = AllowedGroups.setStatus(gid, 'allowed', 'Grupo 456');
|
||||||
|
expect(changedAgain).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listByStatus devuelve grupos por estado', () => {
|
||||||
|
AllowedGroups.setStatus('a@g.us', 'allowed', 'A');
|
||||||
|
AllowedGroups.setStatus('b@g.us', 'pending', 'B');
|
||||||
|
AllowedGroups.setStatus('c@g.us', 'blocked', 'C');
|
||||||
|
|
||||||
|
const allowed = AllowedGroups.listByStatus('allowed').map(r => r.group_id);
|
||||||
|
const pending = AllowedGroups.listByStatus('pending').map(r => r.group_id);
|
||||||
|
const blocked = AllowedGroups.listByStatus('blocked').map(r => r.group_id);
|
||||||
|
|
||||||
|
expect(allowed).toContain('a@g.us');
|
||||||
|
expect(pending).toContain('b@g.us');
|
||||||
|
expect(blocked).toContain('c@g.us');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seedFromEnv marca como allowed los ids provistos', () => {
|
||||||
|
const prev = process.env.ALLOWED_GROUPS;
|
||||||
|
process.env.ALLOWED_GROUPS = 'x@g.us, y@g.us , , z@g.us';
|
||||||
|
|
||||||
|
AllowedGroups.seedFromEnv();
|
||||||
|
|
||||||
|
expect(AllowedGroups.isAllowed('x@g.us')).toBe(true);
|
||||||
|
expect(AllowedGroups.isAllowed('y@g.us')).toBe(true);
|
||||||
|
expect(AllowedGroups.isAllowed('z@g.us')).toBe(true);
|
||||||
|
|
||||||
|
process.env.ALLOWED_GROUPS = prev;
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { makeMemDb } from '../../helpers/db';
|
||||||
|
import { CommandService } from '../../../src/services/command';
|
||||||
|
import { AllowedGroups } from '../../../src/services/allowed-groups';
|
||||||
|
|
||||||
|
describe('CommandService - gating en modo enforce', () => {
|
||||||
|
const envBackup = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce' };
|
||||||
|
const memdb = makeMemDb();
|
||||||
|
(CommandService as any).dbInstance = memdb;
|
||||||
|
(AllowedGroups as any).dbInstance = memdb;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = envBackup;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bloquea comandos en grupo no permitido (desconocido)', async () => {
|
||||||
|
const res = await CommandService.handle({
|
||||||
|
sender: '34600123456',
|
||||||
|
groupId: 'g1@g.us',
|
||||||
|
message: '/t ayuda',
|
||||||
|
mentions: []
|
||||||
|
});
|
||||||
|
expect(Array.isArray(res)).toBe(true);
|
||||||
|
expect(res.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('permite comandos en grupo permitido', async () => {
|
||||||
|
AllowedGroups.setStatus('g2@g.us', 'allowed', 'G2');
|
||||||
|
|
||||||
|
const res = await CommandService.handle({
|
||||||
|
sender: '34600123456',
|
||||||
|
groupId: 'g2@g.us',
|
||||||
|
message: '/t ayuda',
|
||||||
|
mentions: []
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.length).toBeGreaterThan(0);
|
||||||
|
expect(res[0].recipient).toBe('34600123456');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { makeMemDb } from '../../helpers/db';
|
||||||
|
import { GroupSyncService } from '../../../src/services/group-sync';
|
||||||
|
import { AllowedGroups } from '../../../src/services/allowed-groups';
|
||||||
|
|
||||||
|
describe('GroupSyncService - gating en syncMembersForGroup (enforce)', () => {
|
||||||
|
const envBackup = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce' };
|
||||||
|
const memdb = makeMemDb();
|
||||||
|
(GroupSyncService as any).dbInstance = memdb;
|
||||||
|
(AllowedGroups as any).dbInstance = memdb;
|
||||||
|
|
||||||
|
// Stub fetchGroupMembersFromAPI para no hacer red
|
||||||
|
(GroupSyncService as any).fetchGroupMembersFromAPI = async (_groupId: string) => {
|
||||||
|
return [{ userId: '34600111111', isAdmin: false }];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = envBackup;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no sincroniza miembros para grupo no allowed', async () => {
|
||||||
|
const res = await GroupSyncService.syncMembersForGroup('na@g.us');
|
||||||
|
expect(res).toEqual({ added: 0, updated: 0, deactivated: 0 });
|
||||||
|
|
||||||
|
const db = (GroupSyncService as any).dbInstance;
|
||||||
|
const row = db.query(`SELECT COUNT(*) AS c FROM group_members WHERE group_id = 'na@g.us'`).get() as any;
|
||||||
|
expect(Number(row?.c || 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sincroniza miembros para grupo allowed', async () => {
|
||||||
|
AllowedGroups.setStatus('ok@g.us', 'allowed', 'OK');
|
||||||
|
|
||||||
|
const res = await GroupSyncService.syncMembersForGroup('ok@g.us');
|
||||||
|
expect(res.added + res.updated + res.deactivated).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const db = (GroupSyncService as any).dbInstance;
|
||||||
|
const row = db.query(`SELECT COUNT(*) AS c FROM group_members WHERE group_id = 'ok@g.us'`).get() as any;
|
||||||
|
expect(Number(row?.c || 0)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import Database from 'bun:sqlite';
|
||||||
|
import { initializeDatabase } from '../../../src/db';
|
||||||
|
import { GroupSyncService } from '../../../src/services/group-sync';
|
||||||
|
|
||||||
|
describe('GroupSyncService - upsertGroups actualiza label en allowed_groups', () => {
|
||||||
|
const envBackup = process.env;
|
||||||
|
let memdb: Database;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...envBackup, NODE_ENV: 'test', WHATSAPP_COMMUNITY_ID: 'comm-1' };
|
||||||
|
memdb = new Database(':memory:');
|
||||||
|
initializeDatabase(memdb);
|
||||||
|
(GroupSyncService as any).dbInstance = memdb;
|
||||||
|
|
||||||
|
// Limpiar tablas
|
||||||
|
memdb.exec('DELETE FROM allowed_groups');
|
||||||
|
memdb.exec('DELETE FROM groups');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
memdb.close();
|
||||||
|
process.env = envBackup;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propaga subject como label aunque no exista fila previa', async () => {
|
||||||
|
await (GroupSyncService as any).upsertGroups([{ id: 'gg@g.us', subject: 'Grupo GG', linkedParent: 'comm-1' }]);
|
||||||
|
|
||||||
|
const row = memdb.query(`SELECT label, status FROM allowed_groups WHERE group_id = 'gg@g.us'`).get() as any;
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(String(row.label)).toBe('Grupo GG');
|
||||||
|
expect(String(row.status)).toBe('pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import { makeMemDb } from '../../helpers/db';
|
||||||
|
import { GroupSyncService } from '../../../src/services/group-sync';
|
||||||
|
import { AllowedGroups } from '../../../src/services/allowed-groups';
|
||||||
|
|
||||||
|
describe('GroupSyncService - gating en scheduler de miembros (enforce)', () => {
|
||||||
|
const envBackup = process.env;
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...envBackup, NODE_ENV: 'ci', GROUP_GATING_MODE: 'enforce' };
|
||||||
|
|
||||||
|
const memdb = makeMemDb();
|
||||||
|
(GroupSyncService as any).dbInstance = memdb;
|
||||||
|
(AllowedGroups as any).dbInstance = memdb;
|
||||||
|
|
||||||
|
// Preparar caché de grupos activos (2 grupos: uno allowed y otro no)
|
||||||
|
GroupSyncService.activeGroupsCache.clear();
|
||||||
|
GroupSyncService.activeGroupsCache.set('ok@g.us', 'OK Group');
|
||||||
|
GroupSyncService.activeGroupsCache.set('na@g.us', 'NA Group');
|
||||||
|
|
||||||
|
// Sembrar allowed solo para 'ok@g.us'
|
||||||
|
AllowedGroups.setStatus('ok@g.us', 'allowed', 'OK Group');
|
||||||
|
|
||||||
|
// Stub de fetch para no hacer red y registrar llamadas
|
||||||
|
(GroupSyncService as any).fetchGroupMembersFromAPI = async (gid: string) => {
|
||||||
|
calls.push(gid);
|
||||||
|
// Snapshot vacía para no escribir en DB
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = envBackup;
|
||||||
|
calls.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('salta grupos no allowed y solo procesa los allowed', async () => {
|
||||||
|
const summary = await GroupSyncService.syncMembersForActiveGroups();
|
||||||
|
|
||||||
|
// Debe haber procesado solo 1 grupo (el allowed)
|
||||||
|
expect(summary.groups).toBe(1);
|
||||||
|
expect(calls).toEqual(['ok@g.us']);
|
||||||
|
expect(calls).not.toContain('na@g.us');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect } from 'bun:test';
|
||||||
|
import Database from 'bun:sqlite';
|
||||||
|
import { initializeDatabase } from '../../../src/db';
|
||||||
|
import { TaskService } from '../../../src/tasks/service';
|
||||||
|
import { RemindersService } from '../../../src/services/reminders';
|
||||||
|
import { AllowedGroups } from '../../../src/services/allowed-groups';
|
||||||
|
import { ResponseQueue } from '../../../src/services/response-queue';
|
||||||
|
|
||||||
|
function seedGroup(db: Database, groupId: string) {
|
||||||
|
const cols = db.query(`PRAGMA table_info(groups)`).all() as any[];
|
||||||
|
const values: Record<string, any> = {};
|
||||||
|
const nowIso = new Date().toISOString().replace('T', ' ').replace('Z', '');
|
||||||
|
|
||||||
|
for (const c of cols) {
|
||||||
|
const name = String(c.name);
|
||||||
|
const type = String(c.type || '').toUpperCase();
|
||||||
|
const notnull = Number(c.notnull || 0) === 1;
|
||||||
|
const hasDefault = c.dflt_value != null;
|
||||||
|
|
||||||
|
if (name === 'id') { values[name] = groupId; continue; }
|
||||||
|
if (name === 'name' || name === 'title' || name === 'subject') { values[name] = 'Test Group'; continue; }
|
||||||
|
if (name === 'created_by') { values[name] = 'tester'; continue; }
|
||||||
|
if (name.endsWith('_at')) { values[name] = nowIso; continue; }
|
||||||
|
if (name === 'is_active' || name === 'active') { values[name] = 1; continue; }
|
||||||
|
|
||||||
|
if (notnull && !hasDefault) {
|
||||||
|
if (type.includes('INT')) values[name] = 1;
|
||||||
|
else if (type.includes('REAL')) values[name] = 0;
|
||||||
|
else values[name] = 'N/A';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('id' in values)) values['id'] = groupId;
|
||||||
|
|
||||||
|
const colsList = Object.keys(values);
|
||||||
|
const placeholders = colsList.map(() => '?').join(', ');
|
||||||
|
const sql = `INSERT OR REPLACE INTO groups (${colsList.join(', ')}) VALUES (${placeholders})`;
|
||||||
|
db.prepare(sql).run(...colsList.map(k => values[k]));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RemindersService - gating por grupos en modo enforce', () => {
|
||||||
|
const envBackup = process.env;
|
||||||
|
let memdb: Database;
|
||||||
|
let originalAdd: any;
|
||||||
|
let sent: any[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce', TZ: 'Europe/Madrid' };
|
||||||
|
memdb = new Database(':memory:');
|
||||||
|
initializeDatabase(memdb);
|
||||||
|
(TaskService as any).dbInstance = memdb;
|
||||||
|
(RemindersService as any).dbInstance = memdb;
|
||||||
|
(AllowedGroups as any).dbInstance = memdb;
|
||||||
|
|
||||||
|
// Stub de ResponseQueue
|
||||||
|
originalAdd = (ResponseQueue as any).add;
|
||||||
|
(ResponseQueue as any).add = async (msgs: any[]) => { sent.push(...msgs); };
|
||||||
|
sent = [];
|
||||||
|
|
||||||
|
// Asegurar usuario receptor para satisfacer la FK de user_preferences
|
||||||
|
const iso = new Date().toISOString().replace('T', ' ').replace('Z', '');
|
||||||
|
memdb.exec(`
|
||||||
|
INSERT INTO users (id, first_seen, last_seen)
|
||||||
|
VALUES ('34600123456', '${iso}', '${iso}')
|
||||||
|
ON CONFLICT(id) DO NOTHING
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Preferencias del usuario receptor
|
||||||
|
memdb.exec(`
|
||||||
|
INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
|
||||||
|
VALUES ('34600123456', 'daily', '00:00', NULL, strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
reminder_freq = excluded.reminder_freq,
|
||||||
|
reminder_time = excluded.reminder_time,
|
||||||
|
last_reminded_on = NULL,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Sembrar grupos y estados
|
||||||
|
seedGroup(memdb, 'ok@g.us');
|
||||||
|
seedGroup(memdb, 'na@g.us');
|
||||||
|
AllowedGroups.setStatus('ok@g.us', 'allowed', 'OK');
|
||||||
|
AllowedGroups.setStatus('na@g.us', 'allowed', 'NA'); // inicialmente allowed para que las tareas se creen con group_id
|
||||||
|
|
||||||
|
// Crear dos tareas, una en cada grupo, asignadas al usuario
|
||||||
|
TaskService.createTask(
|
||||||
|
{ description: 'Tarea OK', created_by: '34600123456', group_id: 'ok@g.us', due_date: null },
|
||||||
|
[{ user_id: '34600123456', assigned_by: '34600123456' }]
|
||||||
|
);
|
||||||
|
TaskService.createTask(
|
||||||
|
{ description: 'Tarea NA', created_by: '34600123456', group_id: 'na@g.us', due_date: null },
|
||||||
|
[{ user_id: '34600123456', assigned_by: '34600123456' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cambiar a bloqueado uno de los grupos antes de correr los recordatorios
|
||||||
|
AllowedGroups.setStatus('na@g.us', 'blocked', 'NA');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(ResponseQueue as any).add = originalAdd;
|
||||||
|
memdb.close();
|
||||||
|
process.env = envBackup;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omite tareas de grupos no allowed en los recordatorios', async () => {
|
||||||
|
const now = new Date('2025-09-08T07:40:00.000Z'); // ≥ 08:30 Europe/Madrid en un lunes
|
||||||
|
await RemindersService.runOnce(now);
|
||||||
|
|
||||||
|
expect(sent.length).toBe(1);
|
||||||
|
const msg = String(sent[0].message);
|
||||||
|
|
||||||
|
// Debe incluir solo la tarea del grupo allowed y omitir la del bloqueado
|
||||||
|
expect(msg).toContain('Tarea OK');
|
||||||
|
expect(msg).not.toContain('Tarea NA');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect } from 'bun:test';
|
||||||
|
import Database from 'bun:sqlite';
|
||||||
|
import { initializeDatabase } from '../../../src/db';
|
||||||
|
import { TaskService } from '../../../src/tasks/service';
|
||||||
|
import { AllowedGroups } from '../../../src/services/allowed-groups';
|
||||||
|
|
||||||
|
function seedGroup(db: Database, groupId: string) {
|
||||||
|
// Sembrado robusto: cubrir columnas NOT NULL sin valor por defecto
|
||||||
|
const cols = db.query(`PRAGMA table_info(groups)`).all() as any[];
|
||||||
|
const values: Record<string, any> = {};
|
||||||
|
const nowIso = new Date().toISOString().replace('T', ' ').replace('Z', '');
|
||||||
|
|
||||||
|
for (const c of cols) {
|
||||||
|
const name = String(c.name);
|
||||||
|
const type = String(c.type || '').toUpperCase();
|
||||||
|
const notnull = Number(c.notnull || 0) === 1;
|
||||||
|
const hasDefault = c.dflt_value != null;
|
||||||
|
|
||||||
|
if (name === 'id') {
|
||||||
|
values[name] = groupId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preconfigurar algunos alias comunes
|
||||||
|
if (name === 'name' || name === 'title' || name === 'subject') {
|
||||||
|
values[name] = 'Test Group';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name === 'created_by') {
|
||||||
|
values[name] = 'tester';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name.endsWith('_at')) {
|
||||||
|
values[name] = nowIso;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name === 'is_active' || name === 'active') {
|
||||||
|
values[name] = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Para columnas NOT NULL sin valor por defecto, asignar valores genéricos
|
||||||
|
if (notnull && !hasDefault) {
|
||||||
|
if (type.includes('INT')) values[name] = 1;
|
||||||
|
else if (type.includes('REAL')) values[name] = 0;
|
||||||
|
else values[name] = 'N/A';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asegurar que id esté siempre
|
||||||
|
if (!('id' in values)) values['id'] = groupId;
|
||||||
|
|
||||||
|
const colsList = Object.keys(values);
|
||||||
|
const placeholders = colsList.map(() => '?').join(', ');
|
||||||
|
const sql = `INSERT OR REPLACE INTO groups (${colsList.join(', ')}) VALUES (${placeholders})`;
|
||||||
|
db.prepare(sql).run(...colsList.map(k => values[k]));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TaskService - gating en creación con group_id (enforce)', () => {
|
||||||
|
const envBackup = process.env;
|
||||||
|
let memdb: Database;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce' };
|
||||||
|
memdb = new Database(':memory:');
|
||||||
|
initializeDatabase(memdb);
|
||||||
|
(TaskService as any).dbInstance = memdb;
|
||||||
|
(AllowedGroups as any).dbInstance = memdb;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = envBackup;
|
||||||
|
memdb.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fuerza group_id=null cuando el grupo no está allowed', () => {
|
||||||
|
const gid = 'na@g.us';
|
||||||
|
seedGroup(memdb, gid);
|
||||||
|
AllowedGroups.setStatus(gid, 'blocked');
|
||||||
|
|
||||||
|
const taskId = TaskService.createTask(
|
||||||
|
{
|
||||||
|
description: 'Probar gating',
|
||||||
|
due_date: null,
|
||||||
|
group_id: gid,
|
||||||
|
created_by: '34600123456',
|
||||||
|
},
|
||||||
|
[{ user_id: '34600123456', assigned_by: '34600123456' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = memdb
|
||||||
|
.query(`SELECT group_id FROM tasks WHERE id = ?`)
|
||||||
|
.get(taskId) as any;
|
||||||
|
expect(row?.group_id).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('conserva group_id cuando el grupo está allowed', () => {
|
||||||
|
const gid = 'ok@g.us';
|
||||||
|
seedGroup(memdb, gid);
|
||||||
|
AllowedGroups.setStatus(gid, 'allowed');
|
||||||
|
|
||||||
|
const taskId = TaskService.createTask(
|
||||||
|
{
|
||||||
|
description: 'Tarea en grupo allowed',
|
||||||
|
due_date: null,
|
||||||
|
group_id: gid,
|
||||||
|
created_by: '34600123456',
|
||||||
|
},
|
||||||
|
[{ user_id: '34600123456', assigned_by: '34600123456' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = memdb
|
||||||
|
.query(`SELECT group_id FROM tasks WHERE id = ?`)
|
||||||
|
.get(taskId) as any;
|
||||||
|
expect(String(row?.group_id)).toBe(gid);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue