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