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