Compare commits

..

8 Commits

Author SHA1 Message Date
brobert 773a0e8912 añade el svelte-kit correcto a gitignore 1 week ago
brobert fceb6baa04 añade build.lock a gitignore 1 week ago
brobert 9c4498c5cb test: eliminar imports duplicados y cargar handler dinámicamente
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert d7bf328db5 test: usar imports dinámicos y afterEach asíncrono en pruebas
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert 01c274a8ca fix: usar bun:sqlite en tests y exponer closeDb para reiniciar BD
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert 39c3f97e4c fix: soportar entorno dinámico con fallback a process.env en env.ts
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert 60cb5877d8 test: agregar tests para encolar reacciones y payload de respuesta
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago
brobert 7a5f933b8c feat: activar encolado de reacciones en web al completar tareas
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
1 week ago

3
.gitignore vendored

@ -45,4 +45,5 @@ docs/evolution-api.envs
tmp/
apps/web/tmp/
apps/web/.sveltekit
apps/web/.build.lock
apps/web/.svelte-kit

@ -28,13 +28,21 @@ function applyDefaultPragmas(instance: any): void {
* - En Node (Vite dev SSR): better-sqlite3
*/
async function importSqliteDatabase(): Promise<any> {
// En tests, forzar bun:sqlite para evitar mezclar engines con la conexión de tests
const nodeEnv = String(process.env.NODE_ENV || '').toLowerCase();
if (nodeEnv === 'test') {
const mod: any = await import('bun:sqlite');
return (mod as any).Database || (mod as any).default || mod;
}
// En desarrollo (Vite SSR), cargar better-sqlite3 vía require de Node para mantener el contexto CJS
if (import.meta.env.DEV) {
if (typeof import.meta !== 'undefined' && (import.meta as any).env?.DEV) {
const modModule: any = await import('node:module');
const require = modModule.createRequire(import.meta.url);
const mod = require('better-sqlite3');
return (mod as any).default || (mod as any).Database || mod;
}
// En producción (Bun en runtime), usar bun:sqlite nativo
const mod: any = await import('bun:sqlite');
return (mod as any).Database || (mod as any).default || mod;
@ -163,3 +171,16 @@ export async function getDb(filename: string = 'tasks.db'): Promise<any> {
_db = await openDb(filename);
return _db;
}
/**
* Cierra y resetea la instancia compartida (útil en tests para evitar manejar
* un descriptor abierto al borrar el archivo de la BD en disco).
*/
export function closeDb(): void {
try {
if (_db && typeof _db.close === 'function') {
_db.close();
}
} catch {}
_db = null;
}

@ -1,5 +1,14 @@
import { join, resolve } from 'path';
import { env } from '$env/dynamic/private';
// Carga compatible del entorno: en SvelteKit usa $env/dynamic/private;
// en tests/ejecución fuera de SvelteKit cae a process.env.
let env: any;
try {
const mod = await import('$env/dynamic/private');
env = (mod as any).env;
} catch {
env = process.env as any;
}
/**
* Resuelve la ruta absoluta al archivo de la base de datos SQLite compartida.
@ -44,3 +53,10 @@ export const icsRateLimitPerMin = Math.max(0, Math.floor(ICS_RATE_LIMIT_PER_MIN)
const UNCOMPLETE_WINDOW_MIN_RAW = Number(env.UNCOMPLETE_WINDOW_MIN || 1440);
export const UNCOMPLETE_WINDOW_MIN = Math.max(1, Math.floor(UNCOMPLETE_WINDOW_MIN_RAW));
export const uncompleteWindowMs = UNCOMPLETE_WINDOW_MIN * 60 * 1000;
// Reacciones (flags de característica para la web)
const REACTIONS_TTL_DAYS_RAW = Number(env.REACTIONS_TTL_DAYS || 14);
export const REACTIONS_TTL_DAYS = Math.max(1, Math.floor(REACTIONS_TTL_DAYS_RAW));
export const REACTIONS_ENABLED = toBool(env.REACTIONS_ENABLED || '');
export const REACTIONS_SCOPE = ((env.REACTIONS_SCOPE || 'groups').trim().toLowerCase() === 'all' ? 'all' : 'groups');
export const GROUP_GATING_MODE = (env.GROUP_GATING_MODE || 'off').trim().toLowerCase();

@ -1,5 +1,6 @@
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { REACTIONS_ENABLED, REACTIONS_TTL_DAYS, REACTIONS_SCOPE, GROUP_GATING_MODE } from '$lib/server/env';
export const POST: RequestHandler = async (event) => {
const userId = event.locals.userId ?? null;
@ -101,6 +102,75 @@ export const POST: RequestHandler = async (event) => {
const statusStr = Number(updated.completed || 0) === 1 ? 'updated' : 'already';
// Encolar reacción ✅ desde la web si procede (idéntico formato al bot)
try {
if (statusStr === 'updated' && REACTIONS_ENABLED) {
// Buscar origen con columnas opcionales (participant/from_me) si existen
let origin: any = null;
try {
origin = db.prepare(`
SELECT chat_id, message_id, created_at, participant, from_me
FROM task_origins
WHERE task_id = ?
`).get(taskId) as any;
} catch {
origin = db.prepare(`
SELECT chat_id, message_id, created_at
FROM task_origins
WHERE task_id = ?
`).get(taskId) as any;
}
if (origin && origin.chat_id && origin.message_id) {
const chatId = String(origin.chat_id);
// Scope: por defecto solo reaccionar en grupos
if (REACTIONS_SCOPE === 'all' || chatId.endsWith('@g.us')) {
// TTL (por defecto 14 días)
const ttlMs = REACTIONS_TTL_DAYS * 24 * 60 * 60 * 1000;
const createdRaw = String(origin.created_at || '');
const createdIso = createdRaw.includes('T') ? createdRaw : (createdRaw.replace(' ', 'T') + 'Z');
const createdMs = Date.parse(createdIso);
const withinTtl = Number.isFinite(createdMs) ? (Date.now() - createdMs <= ttlMs) : false;
// Gating 'enforce' (solo aplica a grupos)
let allowed = true;
if (GROUP_GATING_MODE === 'enforce' && chatId.endsWith('@g.us')) {
const row = db.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`).get(chatId) as any;
allowed = !!row;
}
if (withinTtl && allowed) {
// Idempotencia 24h por metadata canónica exacta
const nowIso = new Date().toISOString().replace('T', ' ').replace('Z', '');
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().replace('T', ' ').replace('Z', '');
const meta: any = { kind: 'reaction', emoji: '✅', chatId, messageId: String(origin.message_id) };
if (origin && (origin.from_me === 1 || origin.from_me === true)) meta.fromMe = true;
if (origin && origin.participant) meta.participant = String(origin.participant);
const metadata = JSON.stringify(meta);
const exists = db.prepare(`
SELECT 1
FROM response_queue
WHERE metadata = ?
AND status IN ('queued','processing','sent')
AND (updated_at > ? OR created_at > ?)
LIMIT 1
`).get(metadata, cutoff, cutoff) as any;
if (!exists) {
db.prepare(`
INSERT INTO response_queue (recipient, message, metadata, next_attempt_at)
VALUES (?, ?, ?, ?)
`).run(chatId, '', metadata, nowIso);
}
}
}
}
}
} catch {}
const body = {
status: statusStr,
task: {

@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { ResponseQueue } from '../../../src/services/response-queue';
describe('ResponseQueue - payload de reacción', () => {
const OLD_FETCH = globalThis.fetch;
beforeEach(() => {
process.env.EVOLUTION_API_URL = 'http://evolution.local';
process.env.EVOLUTION_API_INSTANCE = 'instance-1';
});
afterEach(() => {
globalThis.fetch = OLD_FETCH;
delete process.env.EVOLUTION_API_URL;
delete process.env.EVOLUTION_API_INSTANCE;
});
it('incluye participant y fromMe cuando están presentes', async () => {
const calls: any[] = [];
globalThis.fetch = (async (url: any, init?: any) => {
calls.push({ url, init });
return {
ok: true,
status: 200,
text: async () => ''
} as any;
}) as any;
const item = {
id: 1,
recipient: '12345-67890@g.us',
message: '',
metadata: JSON.stringify({
kind: 'reaction',
emoji: '✅',
chatId: '12345-67890@g.us',
messageId: 'MSG-123',
fromMe: true,
participant: '34600123456@s.whatsapp.net'
}),
attempts: 0
};
const res = await ResponseQueue.sendOne(item as any);
expect(res.ok).toBe(true);
expect(calls.length).toBe(1);
const { url, init } = calls[0];
expect(String(url)).toBe('http://evolution.local/message/sendReaction/instance-1');
const payload = JSON.parse(String(init.body || '{}'));
expect(payload).toBeTruthy();
expect(payload.reaction).toBe('✅');
expect(payload.key).toEqual({
remoteJid: '12345-67890@g.us',
fromMe: true,
id: 'MSG-123',
participant: '34600123456@s.whatsapp.net'
});
});
it('omite participant y usa fromMe=false por defecto cuando no se proveen', async () => {
const calls: any[] = [];
globalThis.fetch = (async (url: any, init?: any) => {
calls.push({ url, init });
return {
ok: true,
status: 200,
text: async () => ''
} as any;
}) as any;
const item = {
id: 2,
recipient: '12345-67890@g.us',
message: '',
metadata: JSON.stringify({
kind: 'reaction',
emoji: '✅',
chatId: '12345-67890@g.us',
messageId: 'MSG-456'
}),
attempts: 0
};
const res = await ResponseQueue.sendOne(item as any);
expect(res.ok).toBe(true);
const { url, init } = calls[0];
expect(String(url)).toBe('http://evolution.local/message/sendReaction/instance-1');
const payload = JSON.parse(String(init.body || '{}'));
expect(payload.reaction).toBe('✅');
expect(payload.key.remoteJid).toBe('12345-67890@g.us');
expect(payload.key.id).toBe('MSG-456');
expect(payload.key.fromMe).toBe(false);
expect('participant' in payload.key).toBe(false);
});
});

@ -0,0 +1,194 @@
import { beforeEach, afterEach, describe, expect, it } from 'bun:test';
import { createTempDb } from './helpers/db';
// Los imports del handler y closeDb se hacen dinámicos dentro de cada test/teardown
function toIsoSql(d: Date): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
describe('Web API - completar tarea encola reacción ✅', () => {
let cleanup: () => void;
let db: any;
let path: string;
const USER = '34600123456';
const GROUP_ID = '12345-67890@g.us';
beforeEach(() => {
const tmp = createTempDb();
cleanup = tmp.cleanup;
db = tmp.db;
path = tmp.path;
process.env.NODE_ENV = 'test';
process.env.DB_PATH = path;
process.env.REACTIONS_ENABLED = 'true';
process.env.REACTIONS_SCOPE = 'groups';
process.env.REACTIONS_TTL_DAYS = '14';
process.env.GROUP_GATING_MODE = 'enforce';
// Sembrar usuario y grupo permitido + membresía activa
db.prepare(`INSERT OR IGNORE INTO users (id) VALUES (?)`).run(USER);
db.prepare(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES (?, 'comm-1', 'Group', 1)`).run(GROUP_ID);
db.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, label, status) VALUES (?, 'Test', 'allowed')`).run(GROUP_ID);
db.prepare(`INSERT OR REPLACE INTO group_members (group_id, user_id, is_admin, is_active) VALUES (?, ?, 0, 1)`).run(GROUP_ID, USER);
});
afterEach(async () => {
// Cerrar la conexión singleton de la web antes de borrar el archivo
try {
const { closeDb } = await import('../../apps/web/src/lib/server/db.ts');
closeDb();
} catch {}
if (cleanup) cleanup();
// Limpiar env relevantes
delete process.env.DB_PATH;
delete process.env.REACTIONS_ENABLED;
delete process.env.REACTIONS_SCOPE;
delete process.env.REACTIONS_TTL_DAYS;
delete process.env.GROUP_GATING_MODE;
});
it('caso feliz: encola 1 reacción ✅ con metadata canónica', async () => {
// Crear tarea en grupo (no completada)
const ins = db.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
VALUES ('Probar reacción', NULL, ?, ?, 0, NULL)
`).run(GROUP_ID, USER) as any;
const taskId = Number(ins.lastInsertRowid);
// Origen reciente con participant y from_me=1
const messageId = 'MSG-abc-123';
db.prepare(`
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at, participant, from_me)
VALUES (?, ?, ?, ?, ?, ?)
`).run(taskId, GROUP_ID, messageId, toIsoSql(new Date()), `${USER}@s.whatsapp.net`, 1);
// Ejecutar endpoint
const event: any = {
locals: { userId: USER },
params: { id: String(taskId) },
request: new Request('http://localhost', { method: 'POST' })
};
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
const res = await completeHandler(event);
expect(res.status).toBe(200);
const payload = await res.json();
expect(payload.status).toBe('updated');
// Verificar encolado
const row = db.prepare(`SELECT recipient, message, metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
expect(row).toBeTruthy();
expect(String(row.recipient)).toBe(GROUP_ID);
expect(String(row.message)).toBe('');
const meta = JSON.parse(String(row.metadata || '{}'));
expect(meta).toEqual({
kind: 'reaction',
emoji: '✅',
chatId: GROUP_ID,
messageId,
fromMe: true,
participant: `${USER}@s.whatsapp.net`
});
// Idempotencia del endpoint: segunda llamada no crea nuevo job
const res2 = await completeHandler(event);
expect(res2.status).toBe(200);
const body2 = await res2.json();
expect(body2.status).toBe('already');
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue WHERE metadata = ?`).get(JSON.stringify(meta)) as any;
expect(Number(cnt.c || 0)).toBe(1);
});
it('TTL vencido: no encola reacción', async () => {
const ins = db.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
VALUES ('Vieja', NULL, ?, ?, 0, NULL)
`).run(GROUP_ID, USER) as any;
const taskId = Number(ins.lastInsertRowid);
const messageId = 'MSG-old-001';
const old = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000); // 20 días
db.prepare(`
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at)
VALUES (?, ?, ?, ?)
`).run(taskId, GROUP_ID, messageId, toIsoSql(old));
const event: any = {
locals: { userId: USER },
params: { id: String(taskId) },
request: new Request('http://localhost', { method: 'POST' })
};
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
const res = await completeHandler(event);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe('updated');
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
expect(Number(cnt.c || 0)).toBe(0);
});
it('scope=groups: origen DM no encola', async () => {
const ins = db.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
VALUES ('DM scope', NULL, ?, ?, 0, NULL)
`).run(GROUP_ID, USER) as any;
const taskId = Number(ins.lastInsertRowid);
const messageId = 'MSG-dm-001';
const dmChat = `${USER}@s.whatsapp.net`; // no @g.us
db.prepare(`
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at)
VALUES (?, ?, ?, ?)
`).run(taskId, dmChat, messageId, toIsoSql(new Date()));
const event: any = {
locals: { userId: USER },
params: { id: String(taskId) },
request: new Request('http://localhost', { method: 'POST' })
};
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
const res = await completeHandler(event);
expect(res.status).toBe(200);
const cnt = db.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any;
expect(Number(cnt.c || 0)).toBe(0);
});
it('sin participant/from_me: metadata no incluye claves opcionales', async () => {
const ins = db.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at)
VALUES ('Sin opcionales', NULL, ?, ?, 0, NULL)
`).run(GROUP_ID, USER) as any;
const taskId = Number(ins.lastInsertRowid);
const messageId = 'MSG-nopts-001';
db.prepare(`
INSERT OR REPLACE INTO task_origins (task_id, chat_id, message_id, created_at)
VALUES (?, ?, ?, ?)
`).run(taskId, GROUP_ID, messageId, toIsoSql(new Date()));
const event: any = {
locals: { userId: USER },
params: { id: String(taskId) },
request: new Request('http://localhost', { method: 'POST' })
};
const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts');
const res = await completeHandler(event);
expect(res.status).toBe(200);
const row = db.prepare(`SELECT metadata FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
const meta = JSON.parse(String(row.metadata || '{}'));
expect(meta).toEqual({
kind: 'reaction',
emoji: '✅',
chatId: GROUP_ID,
messageId
});
expect('fromMe' in meta).toBe(false);
expect('participant' in meta).toBe(false);
});
});
Loading…
Cancel
Save