You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

230 lines
8.6 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { Database } from 'bun:sqlite';
import { initializeDatabase } from '../../../src/db';
import { ResponseQueue } from '../../../src/services/response-queue';
let testDb: Database;
let envBackup: NodeJS.ProcessEnv;
let originalDbInstance: Database;
describe('ResponseQueue (persistent add)', () => {
beforeAll(() => {
envBackup = { ...process.env };
testDb = new Database(':memory:');
initializeDatabase(testDb);
// Guardar e inyectar DB de pruebas
originalDbInstance = (ResponseQueue as any).dbInstance;
(ResponseQueue as any).dbInstance = testDb;
});
afterAll(() => {
process.env = envBackup;
// No cerramos ni restablecemos la DB aquí; se hará al final del bloque de reintentos.
});
beforeEach(() => {
// Limpiar tabla entre tests
testDb.exec('DELETE FROM response_queue');
// Valor por defecto del número del bot (se puede cambiar en tests)
process.env.CHATBOT_PHONE_NUMBER = '1234567890';
});
test('should persist queued responses to database', async () => {
const before = testDb.query("SELECT COUNT(*) as count FROM response_queue").get() as any;
expect(before.count).toBe(0);
await ResponseQueue.add([
{ recipient: '111', message: 'hola 1' },
{ recipient: '222', message: 'hola 2' },
]);
const after = testDb.query("SELECT COUNT(*) as count FROM response_queue").get() as any;
expect(after.count).toBe(2);
const rows = testDb.query("SELECT recipient, message, status FROM response_queue ORDER BY id").all() as any[];
expect(rows[0].recipient).toBe('111');
expect(rows[0].message).toBe('hola 1');
expect(rows[0].status).toBe('queued');
expect(rows[1].recipient).toBe('222');
expect(rows[1].message).toBe('hola 2');
expect(rows[1].status).toBe('queued');
});
test('should skip messages addressed to the bot number', async () => {
process.env.CHATBOT_PHONE_NUMBER = '555111222';
await ResponseQueue.add([
{ recipient: '555111222', message: 'no debe encolarse' },
{ recipient: '333', message: 'debe encolarse' },
]);
const count = testDb.query("SELECT COUNT(*) as count FROM response_queue").get() as any;
expect(count.count).toBe(1);
const row = testDb.query("SELECT recipient, message FROM response_queue").get() as any;
expect(row.recipient).toBe('333');
expect(row.message).toBe('debe encolarse');
});
test('should ignore entries without recipient or message', async () => {
await ResponseQueue.add([
// inválidos:
{ recipient: '', message: 'sin destinatario' } as any,
{ recipient: '444', message: '' } as any,
// válido:
{ recipient: '444', message: 'ok' },
]);
const rows = testDb.query("SELECT recipient, message FROM response_queue ORDER BY id").all() as any[];
expect(rows.length).toBe(1);
expect(rows[0].recipient).toBe('444');
expect(rows[0].message).toBe('ok');
});
test('should persist mentions in metadata when provided', async () => {
await ResponseQueue.add([
{ recipient: '555', message: 'hola con menciones', mentions: ['111@s.whatsapp.net', '222@s.whatsapp.net'] },
]);
const row = testDb.query("SELECT metadata FROM response_queue ORDER BY id DESC LIMIT 1").get() as any;
expect(row).toBeTruthy();
const meta = JSON.parse(row.metadata);
expect(Array.isArray(meta.mentioned)).toBe(true);
expect(meta.mentioned).toEqual(['111@s.whatsapp.net', '222@s.whatsapp.net']);
});
test('should throw if database error occurs (e.g., missing table)', async () => {
// Provocar error: eliminar tabla
testDb.exec('DROP TABLE response_queue');
await expect(ResponseQueue.add([{ recipient: '999', message: 'x' }]))
.rejects
.toBeTruthy();
// Restaurar esquema para no afectar otros tests (forzar recreación de tablas)
testDb.exec('DROP TABLE IF EXISTS schema_migrations');
initializeDatabase(testDb);
});
});
describe('ResponseQueue (retries/backoff)', () => {
// Reutiliza la misma DB inyectada en el bloque anterior
afterAll(() => {
// Restaurar DB original y cerrar la de prueba al finalizar todos los tests de reintentos
(ResponseQueue as any).dbInstance = originalDbInstance;
testDb.close();
});
beforeEach(() => {
// Limpiar tabla entre tests
testDb.exec('DELETE FROM response_queue');
});
function isoNow(): string {
return new Date().toISOString().replace('T', ' ').replace('Z', '');
}
function isoFuture(ms: number): string {
return new Date(Date.now() + ms).toISOString().replace('T', ' ').replace('Z', '');
}
test('claimNextBatch should respect next_attempt_at (only eligible items are claimed)', () => {
const readyAt = isoNow();
const laterAt = isoFuture(60_000);
testDb.prepare(`
INSERT INTO response_queue (recipient, message, next_attempt_at)
VALUES ('111', 'ready', ?)
`).run(readyAt);
testDb.prepare(`
INSERT INTO response_queue (recipient, message, next_attempt_at)
VALUES ('222', 'later', ?)
`).run(laterAt);
const claimed = (ResponseQueue as any).claimNextBatch(10) as any[];
expect(Array.isArray(claimed)).toBe(true);
expect(claimed.length).toBe(1);
expect(claimed[0].recipient).toBe('111');
expect(claimed[0].message).toBe('ready');
const rows = testDb.query(`SELECT recipient, status FROM response_queue ORDER BY id`).all() as any[];
expect(rows[0].status).toBe('processing');
expect(rows[1].status).toBe('queued');
});
test('markFailed should set failed status, increment attempts and store status code and error (4xx definitive)', () => {
const now = isoNow();
testDb.prepare(`
INSERT INTO response_queue (recipient, message, next_attempt_at)
VALUES ('333', 'bad request', ?)
`).run(now);
const claimed = (ResponseQueue as any).claimNextBatch(1) as any[];
expect(claimed.length).toBe(1);
const item = claimed[0];
(ResponseQueue as any).markFailed(item.id, 'bad request', 400, (item.attempts || 0) + 1);
const row = testDb.query(`SELECT status, attempts, last_status_code, last_error FROM response_queue WHERE id = ?`).get(item.id) as any;
expect(row.status).toBe('failed');
expect(Number(row.attempts)).toBe(1);
expect(Number(row.last_status_code)).toBe(400);
expect(String(row.last_error)).toContain('bad request');
});
test('requeueWithBackoff should set queued status, increment attempts and schedule next_attempt_at (5xx retry)', () => {
const now = isoNow();
testDb.prepare(`
INSERT INTO response_queue (recipient, message, next_attempt_at)
VALUES ('444', 'server error', ?)
`).run(now);
const claimed = (ResponseQueue as any).claimNextBatch(1) as any[];
expect(claimed.length).toBe(1);
const item = claimed[0];
const attemptsNow = (item.attempts || 0) + 1;
const futureWhen = isoFuture(60_000);
(ResponseQueue as any).requeueWithBackoff(item.id, attemptsNow, futureWhen, 500, 'server error');
const row = testDb.query(`SELECT status, attempts, next_attempt_at, last_status_code, last_error FROM response_queue WHERE id = ?`).get(item.id) as any;
expect(row.status).toBe('queued');
expect(Number(row.attempts)).toBe(attemptsNow);
expect(String(row.next_attempt_at)).toBe(futureWhen);
expect(Number(row.last_status_code)).toBe(500);
expect(String(row.last_error)).toContain('server error');
// No debería ser reclamable aún
const claimedAgain = (ResponseQueue as any).claimNextBatch(1) as any[];
expect(claimedAgain.length).toBe(0);
});
test('computeDelayMs should return values in [0, max] for several attempts', () => {
const attempts = [1, 2, 3, 4, 5];
for (const a of attempts) {
const max = Math.min(
(ResponseQueue as any).MAX_BACKOFF_MS,
(ResponseQueue as any).BASE_BACKOFF_MS * (2 ** Math.max(0, a - 1))
);
// correr varias veces para validar límites (sin probar distribución)
for (let i = 0; i < 5; i++) {
const d = (ResponseQueue as any).computeDelayMs(a);
expect(typeof d).toBe('number');
expect(d).toBeGreaterThanOrEqual(0);
expect(d).toBeLessThanOrEqual(max);
}
}
});
test('add() should persist next_attempt_at (scheduler field) along with queued items', async () => {
await (ResponseQueue as any).add([
{ recipient: '555', message: 'hola con next' },
]);
const row = testDb.query(`SELECT next_attempt_at, status FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any;
expect(row).toBeTruthy();
expect(row.status).toBe('queued');
expect(row.next_attempt_at).toBeTruthy(); // formato texto no vacío
});
});