Merge pull request 'Multiple communities' (#1) from multicom into main

Reviewed-on: #1
main
brobert 4 weeks ago
commit 99c68bf105

@ -48,3 +48,18 @@ Observabilidad y errores
- Métricas:
- sync_runs_total, contadores de alias resueltos, etc.
- Estados de gauges/counters expuestos en /metrics (Prom/JSON).
Control de acceso por grupos (allowed_groups)
- Esquema: tabla allowed_groups (group_id PK, label, status: pending|allowed|blocked, discovered_at/updated_at, discovered_by).
- Servicio: AllowedGroups con caché en memoria e interfaz isAllowed, setStatus, upsertPending, listByStatus, seedFromEnv.
- Flujo:
- Discover (GROUP_GATING_MODE='discover'): ante tráfico de un grupo desconocido, se registra como pending y se retorna temprano; opcionalmente se notifica a ADMIN_USERS (NOTIFY_ADMINS_ON_DISCOVERY='true').
- Enforce (GROUP_GATING_MODE='enforce'): se ignoran mensajes/comandos en grupos no allowed; /admin tiene bypass para permitir aprobación in situ.
- Superficies aplicadas:
- server.ts: retorno temprano en discover y enforce; excepción para /admin.
- services/command.ts: guard de enforce para evitar procesar comandos de grupos no allowed.
- services/group-sync.ts: sincronización de miembros salta grupos no allowed (incluye scheduler).
- tasks/service.ts: en enforce, crear tareas con group_id=null si el grupo no está allowed (compatibilidad).
- reminders.ts: omite tareas/grupos no allowed al generar recordatorios en enforce.
- Observabilidad:
- Gauges allowed_groups_total_* y counters unknown_groups_discovered_total, messages_blocked_group_total, commands_blocked_total, sync_skipped_group_total, admin_actions_total_{allow,block}.

@ -8,13 +8,17 @@ Variables de entorno (principales)
- WHATSAPP_COMMUNITY_ID: comunidad cuyos grupos se sincronizan.
- PORT: puerto HTTP (por defecto 3007).
- NODE_ENV: 'development' | 'test' | 'production'.
- METRICS_ENABLED: 'true'|'false'|'1'|'0' (por defecto habilitado salvo en test).
- METRICS_ENABLED: 'true'|'false'|'1'|'0' (por defecto habilitado salvo en test). Ej.: METRICS_ENABLED='true'
- RATE_LIMIT_PER_MIN: tokens por minuto por usuario (default 15).
- RATE_LIMIT_BURST: capacidad del bucket (default = RATE_LIMIT_PER_MIN).
- GROUP_SYNC_INTERVAL_MS: intervalo de sync de grupos (default 24h; min 10s en dev).
- GROUP_MEMBERS_SYNC_INTERVAL_MS: intervalo de sync de miembros (default 6h; min 10s en dev).
- GROUP_MEMBERS_INACTIVE_RETENTION_DAYS: días para borrar miembros inactivos (default 180).
- TZ: zona horaria para recordatorios (default Europe/Madrid).
- GROUP_GATING_MODE: 'off' | 'discover' | 'enforce' (control de acceso por grupos; por defecto 'off'). Ej.: GROUP_GATING_MODE='discover'
- ADMIN_USERS: lista separada por comas de IDs/JIDs autorizados para /admin (se normalizan a dígitos). Ej.: ADMIN_USERS='34600123456, 5554443333, +34 600 111 222'
- ALLOWED_GROUPS: lista separada por comas de group_id@g.us para sembrado inicial en arranque. Ej.: ALLOWED_GROUPS='12345-67890@g.us, 11111-22222@g.us'
- NOTIFY_ADMINS_ON_DISCOVERY: 'true'/'false' para avisar por DM a ADMIN_USERS al descubrir un grupo (modo 'discover'). Ej.: NOTIFY_ADMINS_ON_DISCOVERY='true'
Endpoints operativos
- GET /metrics
@ -42,6 +46,13 @@ Datos y backups
Métricas de referencia
- sync_runs_total, identity_alias_resolved_total, contadores/gauges específicos de colas y limpieza.
- Control de acceso por grupos (multicomunidades):
- allowed_groups_total_pending, allowed_groups_total_allowed, allowed_groups_total_blocked (gauges).
- unknown_groups_discovered_total (counter).
- messages_blocked_group_total (counter).
- commands_blocked_total (counter).
- sync_skipped_group_total (counter).
- admin_actions_total_allow, admin_actions_total_block (counters).
- Añadir nuevas métricas usando Metrics.inc/set y documentarlas aquí.
Buenas prácticas

@ -217,3 +217,163 @@ Notas de implementación
- Mantener compatibilidad hacia atrás: en creación de tareas, si el group_id no está allowed, persistir sin group_id (null).
- No bloquear DMs por diseño (gating aplica solo a grupos).
- Reutilizar ResponseQueue para todas las notificaciones (incluyendo admins).
Plan detallado etapa por etapa (con impacto en tests y archivos a tocar)
Convenciones transversales para no romper la suite:
- Nueva variable GROUP_GATING_MODE = 'off' | 'discover' | 'enforce'. Valor por defecto: 'off' (especialmente en tests). Solo se activará en tests específicos.
- Helper de tests tests/helpers/db.ts:
- makeMemDb(), injectAllServices(db), seedAllowed(db, groupIds[]), resetServices().
- Opcional tests/setup.ts (o equivalente): fija GROUP_GATING_MODE='off' por defecto para toda la suite.
- AllowedGroups con dbInstance inyectable, caché con clearCache/resetForTests y métodos idempotentes.
Etapa 0 — Preparación y criterios (tests-first)
Objetivo
- Asegurar un “harness” de tests estable y predecible para DB y servicios estáticos.
Cambios de código
- Ninguno funcional. Solo infraestructura de tests (helpers y, si procede, setup global).
Archivos a tocar (código)
- tests/helpers/db.ts (nuevo)
- tests/setup.ts (opcional, si existe configuración global de tests)
Tests nuevos a añadir
- tests/unit/db/migrations.smoke.test.ts: comprueba que initializeDatabase en :memory: no lanza y crea tablas base (sin allowed_groups aún).
Tests existentes a actualizar
- Ninguno obligatorio. Opcional: migrar suites que crean DB manualmente a usar tests/helpers/db.ts.
Etapa 1 — Esquema y servicio AllowedGroups (sin gating todavía)
Objetivo
- Crear tabla allowed_groups y servicio con caché. No bloquear nada aún.
Cambios de código
- src/db/migrations/index.ts: añadir migración v9_allowed_groups (CREATE TABLE IF NOT EXISTS ...).
- src/services/allowed-groups.ts (nuevo): métodos upsertPending, isAllowed, setStatus, listByStatus, seedFromEnv, clearCache/resetForTests.
- src/services/metrics.ts: gauges allowed_groups_total{status} (best-effort).
Tests nuevos a añadir
- tests/unit/services/allowed-groups.test.ts
- tests/unit/db/migrations.allowed-groups.test.ts
Tests existentes a actualizar
- Ninguno. La migración se ejecuta en initializeDatabase de suites que ya lo llaman.
Etapa 2 — Descubrimiento seguro de grupos (registrar, no operar)
Objetivo
- Registrar grupos desconocidos como pending en modo 'discover', sin cambiar comportamiento por defecto de la suite.
Cambios de código
- src/server.ts:
- handleMessageUpsert: si GROUP_GATING_MODE='discover' y es group_id desconocido → upsertPending y return temprano sin procesar comandos/sync.
- Métrica unknown_groups_discovered_total (src/services/metrics.ts).
- src/services/contacts.ts (opcional): obtener label/subject si viene en el webhook.
Tests nuevos a añadir
- tests/unit/server/unknown-group-discovery.test.ts
Tests existentes a actualizar
- tests/unit/server.test.ts: asegurar que:
- Setea GROUP_GATING_MODE='off' en beforeEach, o
- Si prueba mensajes de grupo, usar seedAllowed(db, ['<group_id>']).
- Resto de suites: sin cambios esperados.
Etapa 3 — Gating mínimo en superficies críticas
Objetivo
- Bloquear comandos y sync en grupos no allowed cuando GROUP_GATING_MODE='enforce'.
Cambios de código
- src/services/command.ts:
- Al inicio, si message viene de grupo y GROUP_GATING_MODE='enforce', llamar AllowedGroups.isAllowed(groupId). Si no allowed: política por defecto “silencio” o respuesta breve configurable.
- src/services/group-sync.ts:
- Filtrar operaciones a solo grupos AllowedGroups.isAllowed.
- src/server.ts:
- Antes de encolar/ejecutar comandos, si es grupo y 'enforce', exigir allowed.
- src/services/metrics.ts:
- commands_blocked_total
- sync_skipped_group_total (opcional)
Tests nuevos a añadir
- tests/unit/services/command.gating.test.ts
- tests/unit/services/group-sync.gating.test.ts
Tests existentes a actualizar
- tests/unit/services/command.claim-unassign.test.ts: sembrar grupo allowed en beforeAll/beforeEach o fijar GROUP_GATING_MODE='off'.
- tests/unit/services/command.reminders-config.test.ts: idem si el test ejecuta comandos en contexto de grupo.
- tests/unit/services/command.date-parsing.test.ts: normalmente DM; no tocar salvo que simule grupo.
- tests/unit/services/group-sync.members.test.ts: sembrar grupo allowed para '123@g.us'.
- tests/unit/services/group-sync.scheduler.test.ts: sembrar grupos allowed en el scheduler o setear mode='off'.
- tests/unit/tasks/claim-unassign.test.ts: si hay contexto de grupo, seedAllowed; si es DM, sin cambios.
- tests/unit/services/cleanup-inactive.test.ts, tests/unit/services/metrics-health.test.ts, tests/unit/services/response-queue*.test.ts, tests/unit/services/reminders.test.ts: no deberían requerir cambios; si alguno falla por simulación de grupo, aplicar seedAllowed o mode='off'.
Etapa 4 — Flujo de aprobación administrativa y notificaciones
Objetivo
- Permitir a ADMIN_USERS aprobar/bloquear grupos y consultar pendientes; notificar a admins al descubrir pending.
Cambios de código
- src/services/admin.ts (nuevo): /admin habilitar-aquí, deshabilitar-aquí, pendientes, allow-group <id>, block-group <id>.
- src/server.ts: router para /admin y validación de ADMIN_USERS; usar ResponseQueue para notificaciones.
- src/services/allowed-groups.ts: asegurar setStatus, listByStatus, get(groupId).
- src/services/response-queue.ts (o webhook-manager): enviar DM a ADMIN_USERS en descubrimiento pending (best-effort y detrás de flag para no romper tests existentes).
Tests nuevos a añadir
- tests/unit/services/admin.test.ts
- tests/unit/services/command.admin-approval.test.ts
Tests existentes a actualizar
- tests/unit/server.test.ts: si parsea /admin, agregar casos explícitos o mantener aislado con mode='off' para suites no relacionadas.
- No tocar otras suites.
Etapa 5 — Integración con recordatorios y tareas
Objetivo
- Recordatorios y operaciones por grupo respetan AllowedGroups cuando hay contexto de grupo. DMs no se ven afectados.
Cambios de código
- src/services/reminders.ts: filtrar por AllowedGroups.isAllowed(gid) cuando el recordatorio/consulta use contexto de grupo.
- src/tasks/service.ts: sin cambios funcionales (seguir permitiendo group_id=null).
- src/services/rate-limit.ts (opcional): clave compuesta por grupo.
Tests nuevos a añadir
- tests/unit/services/reminders.gating.test.ts
- tests/unit/tasks/service.gating.test.ts
Tests existentes a actualizar
- tests/unit/services/reminders.test.ts: si usa contexto de grupo, seedAllowed o mode='off' para mantener expectativas.
- Resto: sin cambios.
Etapa 6 — Observabilidad y operaciones
Objetivo
- Métricas agregadas y documentación.
Cambios de código
- src/services/metrics.ts: allowed_groups_total{status}, unknown_groups_discovered_total, commands_blocked_total, admin_actions_total{action}.
- docs/operations.md: documentar variables y comportamiento.
- docs/architecture.md: sección de control de acceso por grupos.
- Confirmar /metrics en src/server.ts.
Tests nuevos a añadir
- Opcional: tests/unit/services/metrics-health.test.ts puede ampliar cobertura para nuevos contadores (no estricta; tolerar ausencia si METRICS_ENABLED='false').
Tests existentes a actualizar
- tests/unit/services/metrics-health.test.ts: si asume conjunto exacto de métricas, flexibilizar aserciones o aislar nuevas métricas tras METRICS_ENABLED.
Etapa 7 — Hardening, regresión y rollout
Objetivo
- Validar que no hay rutas sin gating y que la activación progresiva no rompe nada.
Cambios de código
- Ninguno obligatorio más allá de ajustes menores.
Tests nuevos a añadir
- tests/integration/multi-groups.e2e.test.ts (si procede).
- Casos combinados con varios estados: allowed/pending/blocked + discover/enforce.
Tests existentes a actualizar
- Ninguno; mantener GROUP_GATING_MODE='off' por defecto y activar explícitamente en tests que cubren gating.
Checklist por etapa para mantener la suite en verde
- Antes de tocar código de gating: añadir tests nuevos primero (TDD light).
- Default en tests: GROUP_GATING_MODE='off'.
- Donde haya contexto de grupo en tests existentes: seedAllowed(memdb, ['<gid>']) en el setup del archivo.
- Limpiar cachés entre tests: AllowedGroups.resetForTests() en afterEach/afterAll cuando el servicio se use.
- initializeDatabase(memdb) siempre presente en suites con DB en memoria para aplicar migraciones (incluida v9).

@ -267,4 +267,26 @@ export const migrations: Migration[] = [
} catch {}
}
}
,
{
version: 9,
name: 'allowed-groups',
checksum: 'v9-allowed-groups-2025-09-29',
up: (db: Database) => {
db.exec(`
CREATE TABLE IF NOT EXISTS allowed_groups (
group_id TEXT PRIMARY KEY,
label TEXT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','allowed','blocked')),
discovered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
discovered_by TEXT NULL
);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_allowed_groups_status
ON allowed_groups (status);
`);
}
}
];

@ -14,6 +14,8 @@ import { RemindersService } from './services/reminders';
import { Metrics } from './services/metrics';
import { MaintenanceService } from './services/maintenance';
import { IdentityService } from './services/identity';
import { AllowedGroups } from './services/allowed-groups';
import { AdminService } from './services/admin';
// Bun is available globally when running under Bun runtime
declare global {
@ -65,6 +67,23 @@ export class WebhookServer {
if (!Metrics.enabled()) {
return new Response('Metrics disabled', { status: 404 });
}
// Gauges de allowed_groups por estado (best-effort)
try {
const rows = WebhookServer.dbInstance
.prepare(`SELECT status, COUNT(*) AS c FROM allowed_groups GROUP BY status`)
.all() as any[];
let pending = 0, allowed = 0, blocked = 0;
for (const r of rows) {
const s = String(r?.status || '');
const c = Number(r?.c || 0);
if (s === 'pending') pending = c;
else if (s === 'allowed') allowed = c;
else if (s === 'blocked') blocked = c;
}
Metrics.set('allowed_groups_total_pending', pending);
Metrics.set('allowed_groups_total_allowed', allowed);
Metrics.set('allowed_groups_total_blocked', blocked);
} catch {}
const format = (process.env.METRICS_FORMAT || 'prom').toLowerCase() === 'json' ? 'json' : 'prom';
const body = Metrics.render(format as any);
return new Response(body, {
@ -298,6 +317,86 @@ export class WebhookServer {
return;
}
const messageTextTrimmed = messageText.trim();
const isAdminCmd = messageTextTrimmed.startsWith('/admin');
// Etapa 2: Descubrimiento seguro de grupos (modo 'discover')
if (isGroupId(remoteJid)) {
try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {}
const gatingMode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (gatingMode === 'discover') {
try {
const exists = WebhookServer.dbInstance
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? LIMIT 1`)
.get(remoteJid) as any;
if (!exists) {
try { AllowedGroups.upsertPending(remoteJid, (GroupSyncService.activeGroupsCache.get(remoteJid) || null), normalizedSenderId); } catch {}
try { Metrics.inc('unknown_groups_discovered_total'); } catch {}
try {
const notify = String(process.env.NOTIFY_ADMINS_ON_DISCOVERY || 'false').toLowerCase() === 'true';
if (notify && !isAdminCmd) {
const admins = AdminService.getAdmins();
if (admins.length > 0) {
const info = remoteJid;
const msg = `🔎 Nuevo grupo detectado: ${info}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${info}.`;
await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg })));
}
}
} catch {}
if (!isAdminCmd) return;
}
} catch {
// Si la tabla no existe por alguna razón, intentar upsert y retornar igualmente
try { AllowedGroups.upsertPending(remoteJid, (GroupSyncService.activeGroupsCache.get(remoteJid) || null), normalizedSenderId); } catch {}
try { Metrics.inc('unknown_groups_discovered_total'); } catch {}
try {
const notify = String(process.env.NOTIFY_ADMINS_ON_DISCOVERY || 'false').toLowerCase() === 'true';
if (notify && !isAdminCmd) {
const admins = AdminService.getAdmins();
if (admins.length > 0) {
const info = remoteJid;
const msg = `🔎 Nuevo grupo detectado: ${info}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${info}.`;
await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg })));
}
}
} catch {}
if (!isAdminCmd) return;
}
}
}
// Etapa 3: Gating en modo 'enforce' — ignorar mensajes de grupos no permitidos
if (isGroupId(remoteJid)) {
try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {}
const gatingMode2 = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (gatingMode2 === 'enforce') {
try {
const allowed = AllowedGroups.isAllowed(remoteJid);
if (!allowed && !isAdminCmd) {
try { Metrics.inc('messages_blocked_group_total'); } catch {}
return;
}
} catch {
// Si falla el check por cualquier motivo, ser conservadores y permitir
}
}
}
// Manejo de comandos de administración (/admin) antes de cualquier otra lógica de grupo
if (messageTextTrimmed.startsWith('/admin')) {
try { (AdminService as any).dbInstance = WebhookServer.dbInstance; } catch {}
try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {}
const adminResponses = await AdminService.handle({
sender: normalizedSenderId,
groupId: remoteJid,
message: messageText
});
if (adminResponses.length > 0) {
await ResponseQueue.add(adminResponses);
}
return;
}
// Check/ensure group exists (allow DMs always)
if (isGroupId(data.key.remoteJid) && !GroupSyncService.isGroupActive(data.key.remoteJid)) {
// En tests, mantener comportamiento anterior: ignorar mensajes de grupos inactivos
@ -319,7 +418,7 @@ export class WebhookServer {
}
// Forward to command service only if it's a text-ish message and starts with /t or /tarea
const messageTextTrimmed = messageText.trim();
// messageTextTrimmed computed earlier
if (messageTextTrimmed.startsWith('/tarea') || messageTextTrimmed.startsWith('/t')) {
// Rate limiting básico por usuario (desactivado en tests)
if (process.env.NODE_ENV !== 'test') {
@ -389,6 +488,10 @@ export class WebhookServer {
// Run database migrations (up-only) before starting services
await Migrator.migrateToLatest(this.dbInstance);
// Etapa 7: seed inicial de grupos permitidos desde ALLOWED_GROUPS (best-effort)
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
try { AllowedGroups.seedFromEnv(); } catch {}
const PORT = process.env.PORT || '3007';
console.log('✅ Environment variables validated');

@ -0,0 +1,126 @@
import type { Database } from 'bun:sqlite';
import { db } from '../db';
import { AllowedGroups } from './allowed-groups';
import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp';
import { Metrics } from './metrics';
type AdminContext = {
sender: string; // normalized user id (digits only)
groupId: string; // raw JID (group or DM)
message: string; // raw message text
};
type AdminResponse = { recipient: string; message: string };
export class AdminService {
static dbInstance: Database = db;
private static admins(): Set<string> {
const raw = String(process.env.ADMIN_USERS || '');
const set = new Set<string>();
for (const token of raw.split(',').map(s => s.trim()).filter(Boolean)) {
const n = normalizeWhatsAppId(token);
if (n) set.add(n);
}
return set;
}
static getAdmins(): string[] {
return Array.from(this.admins());
}
private static isAdmin(userId: string | null | undefined): boolean {
const n = normalizeWhatsAppId(userId || '');
if (!n) return false;
return this.admins().has(n);
}
private static help(): string {
return [
'Comandos de administración:',
'- /admin pendientes',
'- /admin habilitar-aquí',
'- /admin deshabilitar-aquí',
'- /admin allow-group <group_id@g.us>',
'- /admin block-group <group_id@g.us>',
].join('\n');
}
static async handle(ctx: AdminContext): Promise<AdminResponse[]> {
const sender = normalizeWhatsAppId(ctx.sender);
if (!sender) return [];
if (!this.isAdmin(sender)) {
return [{ recipient: sender, message: '🚫 No estás autorizado para usar /admin.' }];
}
// Asegurar acceso a la misma DB para AllowedGroups
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
const raw = String(ctx.message || '').trim();
const lower = raw.toLowerCase();
if (!lower.startsWith('/admin')) {
return [];
}
const rest = lower.slice('/admin'.length).trim();
// /admin pendientes
if (rest === 'pendientes') {
const rows = AllowedGroups.listByStatus('pending');
if (!rows || rows.length === 0) {
return [{ recipient: sender, message: '✅ No hay grupos pendientes.' }];
}
const list = rows.map(r => `- ${r.group_id}${r.label ? ` (${r.label})` : ''}`).join('\n');
return [{
recipient: sender,
message: `Grupos pendientes (${rows.length}):\n${list}`
}];
}
// /admin habilitar-aquí
if (rest === 'habilitar-aquí' || rest === 'habilitar-aqui') {
if (!isGroupId(ctx.groupId)) {
return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }];
}
const changed = AllowedGroups.setStatus(ctx.groupId, 'allowed');
try { if (changed) Metrics.inc('admin_actions_total_allow'); } catch {}
return [{ recipient: sender, message: `✅ Grupo habilitado: ${ctx.groupId}` }];
}
// /admin deshabilitar-aquí
if (rest === 'deshabilitar-aquí' || rest === 'deshabilitar-aqui') {
if (!isGroupId(ctx.groupId)) {
return [{ recipient: sender, message: ' Este comando se debe usar dentro de un grupo.' }];
}
const changed = AllowedGroups.setStatus(ctx.groupId, 'blocked');
try { if (changed) Metrics.inc('admin_actions_total_block'); } catch {}
return [{ recipient: sender, message: `✅ Grupo deshabilitado: ${ctx.groupId}` }];
}
// /admin allow-group <jid>
if (rest.startsWith('allow-group ')) {
const arg = rest.slice('allow-group '.length).trim();
if (!isGroupId(arg)) {
return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
}
const changed = AllowedGroups.setStatus(arg, 'allowed');
try { if (changed) Metrics.inc('admin_actions_total_allow'); } catch {}
return [{ recipient: sender, message: `✅ Grupo habilitado: ${arg}` }];
}
// /admin block-group <jid>
if (rest.startsWith('block-group ')) {
const arg = rest.slice('block-group '.length).trim();
if (!isGroupId(arg)) {
return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }];
}
const changed = AllowedGroups.setStatus(arg, 'blocked');
try { if (changed) Metrics.inc('admin_actions_total_block'); } catch {}
return [{ recipient: sender, message: `✅ Grupo bloqueado: ${arg}` }];
}
// Ayuda por defecto
return [{ recipient: sender, message: this.help() }];
}
}

@ -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);
}
}
}

@ -7,6 +7,8 @@ import { ContactsService } from './contacts';
import { ICONS } from '../utils/icons';
import { padTaskId, codeId, formatDDMM, bold, italic } from '../utils/formatting';
import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups';
import { Metrics } from './metrics';
type CommandContext = {
sender: string; // normalized user id (digits only), but accept raw too
@ -1060,6 +1062,22 @@ export class CommandService {
return [];
}
// Gating de grupos en modo 'enforce' (Etapa 3) cuando CommandService se invoca directamente
if (isGroupId(context.groupId)) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') {
try {
if (!AllowedGroups.isAllowed(context.groupId)) {
try { Metrics.inc('commands_blocked_total'); } catch {}
return [];
}
} catch {
// Si falla el check, ser permisivos
}
}
}
try {
return await this.processTareaCommand(context);
} catch (error) {

@ -3,6 +3,7 @@ import { db, ensureUserExists } from '../db';
import { normalizeWhatsAppId } from '../utils/whatsapp';
import { Metrics } from './metrics';
import { IdentityService } from './identity';
import { AllowedGroups } from './allowed-groups';
// In-memory cache for active groups
// const activeGroupsCache = new Map<string, string>(); // groupId -> groupName
@ -331,6 +332,8 @@ export class GroupSyncService {
console.log('Added group:', group.id, 'result:', insertResult);
added++;
}
// Propagar subject como label a allowed_groups (no degrada estado; actualiza label si cambia)
try { (AllowedGroups as any).dbInstance = this.dbInstance; AllowedGroups.upsertPending(group.id, group.subject, null); } catch {}
}
return { added, updated };
@ -604,9 +607,29 @@ export class GroupSyncService {
if (this.activeGroupsCache.size === 0) {
this.cacheActiveGroups();
}
// Etapa 3: gating también en el scheduler masivo
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
const enforce = mode === 'enforce';
if (enforce) {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
}
let groups = 0, added = 0, updated = 0, deactivated = 0;
for (const [groupId] of this.activeGroupsCache.entries()) {
try {
if (enforce) {
try {
if (!AllowedGroups.isAllowed(groupId)) {
// Saltar grupos no permitidos en modo enforce
try { Metrics.inc('sync_skipped_group_total'); } catch {}
continue;
}
} catch {
// Si falla el check, no bloquear el grupo
}
}
const snapshot = await this.fetchGroupMembersFromAPI(groupId);
const res = this.reconcileGroupMembers(groupId, snapshot);
groups++;
@ -794,7 +817,25 @@ export class GroupSyncService {
* Sincroniza miembros para un grupo concreto (útil tras detectar un grupo nuevo).
*/
public static async syncMembersForGroup(groupId: string): Promise<{ added: number; updated: number; deactivated: number }> {
// Gating en modo 'enforce': solo sincronizar miembros para grupos permitidos
try {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (mode === 'enforce') {
try {
(AllowedGroups as any).dbInstance = this.dbInstance;
if (!AllowedGroups.isAllowed(groupId)) {
try { Metrics.inc('sync_skipped_group_total'); } catch {}
return { added: 0, updated: 0, deactivated: 0 };
}
} catch {
// Si el check falla, seguimos sin bloquear
}
}
} catch {}
try {
// Asegurar existencia del grupo en DB (FKs) antes de reconciliar
this.ensureGroupExists(groupId);
const snapshot = await (this as any).fetchGroupMembersFromAPI(groupId);
return this.reconcileGroupMembers(groupId, snapshot);
} catch (e) {

@ -6,6 +6,7 @@ import { ContactsService } from './contacts';
import { GroupSyncService } from './group-sync';
import { ICONS } from '../utils/icons';
import { codeId, formatDDMM, bold, italic } from '../utils/formatting';
import { AllowedGroups } from './allowed-groups';
type UserPreference = {
user_id: string;
@ -88,6 +89,16 @@ export class RemindersService {
WHERE reminder_freq IN ('daily', 'weekly', 'weekdays')
`).all() as UserPreference[];
// Determinar si aplicar gating por grupos
const enforce = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() === 'enforce';
if (enforce) {
try {
(AllowedGroups as any).dbInstance = this.dbInstance;
// Evitar falsos positivos por caché obsoleta entre operaciones previas del test
AllowedGroups.clearCache?.();
} catch {}
}
for (const pref of rows) {
// Evitar duplicado el mismo día
if (pref.last_reminded_on === todayYMD) continue;
@ -102,9 +113,10 @@ export class RemindersService {
if (pref.reminder_freq === 'weekly' && weekday !== 'Mon') continue;
try {
const items = TaskService.listUserPending(pref.user_id, 10);
const allItems = TaskService.listUserPending(pref.user_id, 10);
const items = enforce ? allItems.filter(t => !t.group_id || AllowedGroups.isAllowed(t.group_id)) : allItems;
const total = TaskService.countUserPending(pref.user_id);
if (!items || items.length === 0 || total === 0) {
if (!items || items.length === 0) {
// No enviar si no hay tareas; no marcamos last_reminded_on para permitir enviar si aparecen más tarde hoy
continue;
}
@ -152,6 +164,7 @@ export class RemindersService {
sections.push(...rendered);
}
// Si hay más tareas de las listadas (tope), añadir resumen
if (total > items.length) {
sections.push(italic(`… y ${total - items.length} más`));
}
@ -159,7 +172,10 @@ export class RemindersService {
// (Etapa 3) Sección opcional de "sin responsable" filtrada por membresía activa + snapshot fresca.
const includeUnassigned = String(process.env.REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP || '').toLowerCase() === 'true';
if (includeUnassigned) {
const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(pref.user_id);
let memberGroups = GroupSyncService.getFreshMemberGroupsForUser(pref.user_id);
if (enforce) {
memberGroups = memberGroups.filter(gid => AllowedGroups.isAllowed(gid));
}
for (const gid of memberGroups) {
const unassigned = TaskService.listGroupUnassigned(gid, 10);
if (unassigned.length > 0) {

@ -1,5 +1,7 @@
import type { Database } from 'bun:sqlite';
import { db, ensureUserExists } from '../db';
import { AllowedGroups } from '../services/allowed-groups';
import { isGroupId } from '../utils/whatsapp';
type CreateTaskInput = {
description: string;
@ -59,6 +61,18 @@ export class TaskService {
// Si el group_id no existe en la tabla groups, usar NULL para no violar la FK
let groupIdToInsert = task.group_id ?? null;
// Etapa 5: en modo 'enforce', si es un grupo no permitido, forzar a NULL (compatibilidad)
try {
const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase();
if (groupIdToInsert && isGroupId(groupIdToInsert) && mode === 'enforce') {
try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {}
if (!AllowedGroups.isAllowed(groupIdToInsert)) {
groupIdToInsert = null;
}
}
} catch {}
if (groupIdToInsert) {
const exists = this.dbInstance.prepare(`SELECT 1 FROM groups WHERE id = ?`).get(groupIdToInsert);
if (!exists) {

@ -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…
Cancel
Save