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.
		
		
		
		
		
			
		
			
				
	
	
		
			229 lines
		
	
	
		
			8.5 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			229 lines
		
	
	
		
			8.5 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
 | |
|     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
 | |
|   });
 | |
| });
 |