From 0f96c2792846c81433a9b532657c9052f8952064 Mon Sep 17 00:00:00 2001 From: borja Date: Sun, 7 Sep 2025 01:01:59 +0200 Subject: [PATCH] test: agregar pruebas unitarias para RemindersService y CommandService Co-authored-by: aider (openrouter/openai/gpt-5) --- .../services/command.reminders-config.test.ts | 93 ++++++++++ tests/unit/services/reminders.test.ts | 170 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 tests/unit/services/command.reminders-config.test.ts create mode 100644 tests/unit/services/reminders.test.ts diff --git a/tests/unit/services/command.reminders-config.test.ts b/tests/unit/services/command.reminders-config.test.ts new file mode 100644 index 0000000..dae298a --- /dev/null +++ b/tests/unit/services/command.reminders-config.test.ts @@ -0,0 +1,93 @@ +import { describe, it, beforeEach, expect } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { Migrator } from '../../../src/db/migrator'; +import { CommandService } from '../../../src/services/command'; + +describe('CommandService - configurar recordatorios', () => { + let memdb: Database; + const SENDER = '34600123456'; + + beforeEach(async () => { + process.env.NODE_ENV = 'test'; + process.env.TZ = 'Europe/Madrid'; + + memdb = new Database(':memory:'); + initializeDatabase(memdb); + await Migrator.migrateToLatest(memdb); + + // Inyectar DB + (CommandService as any).dbInstance = memdb; + + // Limpiar tablas + memdb.exec(`DELETE FROM response_queue;`); + memdb.exec(`DELETE FROM user_preferences;`); + memdb.exec(`DELETE FROM users;`); + }); + + async function runCmd(msg: string) { + return await CommandService.handle({ + sender: SENDER, + groupId: '123@g.us', + message: msg, + mentions: [] + }); + } + + function getPref(): { freq: string; time: string | null } | null { + const row = memdb.prepare(`SELECT reminder_freq AS freq, reminder_time AS time FROM user_preferences WHERE user_id = ?`).get(SENDER) as any; + if (!row) return null; + return { freq: String(row.freq), time: row.time ? String(row.time) : null }; + } + + it('configurar daily guarda preferencia y responde confirmación', async () => { + const res = await runCmd('/t configurar daily'); + expect(res).toHaveLength(1); + expect(res[0].recipient).toBe(SENDER); + expect(res[0].message).toContain('✅ Recordatorios: diario'); + + const pref = getPref(); + expect(pref).not.toBeNull(); + expect(pref!.freq).toBe('daily'); + expect(pref!.time).toBe('08:30'); // default + }); + + it('configurar weekly guarda preferencia y responde confirmación', async () => { + const res = await runCmd('/t configurar weekly'); + expect(res).toHaveLength(1); + expect(res[0].message).toContain('semanal (lunes 08:30)'); + + const pref = getPref(); + expect(pref).not.toBeNull(); + expect(pref!.freq).toBe('weekly'); + }); + + it('configurar off guarda preferencia y responde confirmación', async () => { + const res = await runCmd('/t configurar off'); + expect(res).toHaveLength(1); + expect(res[0].message).toContain('apagado'); + + const pref = getPref(); + expect(pref).not.toBeNull(); + expect(pref!.freq).toBe('off'); + }); + + it('configurar con opción inválida devuelve uso correcto y no escribe en DB', async () => { + const res = await runCmd('/t configurar foo'); + expect(res).toHaveLength(1); + expect(res[0].message).toContain('Uso: /t configurar daily|weekly|off'); + + const pref = getPref(); + expect(pref).toBeNull(); + }); + + it('upsert idempotente: cambiar de daily a off actualiza la fila existente', async () => { + await runCmd('/t configurar daily'); + let pref = getPref(); + expect(pref!.freq).toBe('daily'); + + await runCmd('/t configurar off'); + pref = getPref(); + expect(pref!.freq).toBe('off'); + }); +}); diff --git a/tests/unit/services/reminders.test.ts b/tests/unit/services/reminders.test.ts new file mode 100644 index 0000000..3f7b878 --- /dev/null +++ b/tests/unit/services/reminders.test.ts @@ -0,0 +1,170 @@ +import { describe, it, beforeEach, expect } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { Migrator } from '../../../src/db/migrator'; +import { TaskService } from '../../../src/tasks/service'; +import { RemindersService } from '../../../src/services/reminders'; +import { ResponseQueue } from '../../../src/services/response-queue'; + +function toIso(dt: Date): string { + return dt.toISOString().replace('T', ' ').replace('Z', ''); +} + +describe('RemindersService', () => { + let memdb: Database; + const USER = '34600123456'; + + beforeEach(() => { + process.env.NODE_ENV = 'test'; + process.env.TZ = 'Europe/Madrid'; + + memdb = new Database(':memory:'); + initializeDatabase(memdb); + // Migraciones para user_preferences + return Migrator.migrateToLatest(memdb).then(() => { + // Inyectar DB en servicios + (TaskService as any).dbInstance = memdb; + (RemindersService as any).dbInstance = memdb; + (ResponseQueue as any).dbInstance = memdb; + + // Limpiar tablas entre tests por seguridad + memdb.exec(`DELETE FROM response_queue;`); + memdb.exec(`DELETE FROM user_preferences;`); + memdb.exec(`DELETE FROM users;`); + memdb.exec(`DELETE FROM tasks;`); + memdb.exec(`DELETE FROM task_assignments;`); + + // Asegurar usuario + memdb.exec(` + INSERT INTO users (id, first_seen, last_seen) + VALUES ('${USER}', '${toIso(new Date())}', '${toIso(new Date())}') + ON CONFLICT(id) DO NOTHING; + `); + }); + }); + + function insertPref(freq: 'daily' | 'weekly' | 'off', time: string = '08:30', last: string | null = null) { + memdb.prepare(` + INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) + VALUES (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f', 'now')) + ON CONFLICT(user_id) DO UPDATE SET + reminder_freq = excluded.reminder_freq, + reminder_time = excluded.reminder_time, + last_reminded_on = excluded.last_reminded_on, + updated_at = excluded.updated_at + `).run(USER, freq, time, last); + } + + function countQueued(): number { + const row = memdb.prepare(`SELECT COUNT(*) AS c FROM response_queue`).get() as any; + return Number(row?.c || 0); + } + + function getLastReminded(): string | null { + const row = memdb.prepare(`SELECT last_reminded_on AS d FROM user_preferences WHERE user_id = ?`).get(USER) as any; + return row?.d ?? null; + } + + it('envía recordatorio diario cuando hora alcanzada y no enviado hoy', async () => { + insertPref('daily', '08:30', null); + // Lunes 2025-09-08 09:30 Europe/Madrid ≈ 07:30Z + const now = new Date('2025-09-08T07:30:00.000Z'); + + // Crear 1 tarea asignada al usuario + TaskService.createTask( + { description: 'Tarea A', due_date: '2025-09-10', group_id: null, created_by: USER }, + [{ user_id: USER, assigned_by: USER }] + ); + + await RemindersService.runOnce(now); + + expect(countQueued()).toBe(1); + expect(getLastReminded()).toBe('2025-09-08'); // YYYY-MM-DD en TZ + }); + + it('no duplica recordatorio diario en el mismo día', async () => { + insertPref('daily', '08:30', null); + const now = new Date('2025-09-08T07:40:00.000Z'); // ≥ 08:30 local + + TaskService.createTask( + { description: 'Tarea B', due_date: '2025-09-11', group_id: null, created_by: USER }, + [{ user_id: USER, assigned_by: USER }] + ); + + await RemindersService.runOnce(now); + expect(countQueued()).toBe(1); + + // Segunda ejecución el mismo día + await RemindersService.runOnce(new Date('2025-09-08T08:00:00.000Z')); + expect(countQueued()).toBe(1); // sin incremento + }); + + it('no envía diario antes de la hora configurada', async () => { + insertPref('daily', '08:30', null); + const now = new Date('2025-09-08T05:00:00.000Z'); // 07:00 local < 08:30 + + TaskService.createTask( + { description: 'Tarea C', due_date: '2025-09-12', group_id: null, created_by: USER }, + [{ user_id: USER, assigned_by: USER }] + ); + + await RemindersService.runOnce(now); + expect(countQueued()).toBe(0); + expect(getLastReminded()).toBeNull(); + }); + + it('no envía si el usuario no tiene tareas; no marca last_reminded_on', async () => { + insertPref('daily', '08:30', null); + const now = new Date('2025-09-08T07:40:00.000Z'); // ≥ 08:30 local + + await RemindersService.runOnce(now); + expect(countQueued()).toBe(0); + expect(getLastReminded()).toBeNull(); + }); + + it('weekly: solo envía en lunes y a la hora, no en martes', async () => { + insertPref('weekly', '08:30', null); + + // Crear 1 tarea + TaskService.createTask( + { description: 'Tarea W', due_date: '2025-09-15', group_id: null, created_by: USER }, + [{ user_id: USER, assigned_by: USER }] + ); + + // Lunes (Mon) 2025-09-08 09:00 local (07:00Z) -> antes de hora: no envía + await RemindersService.runOnce(new Date('2025-09-08T07:00:00.000Z')); + expect(countQueued()).toBe(0); + expect(getLastReminded()).toBeNull(); + + // Lunes 09:30 local (07:30Z) -> envía + await RemindersService.runOnce(new Date('2025-09-08T07:30:00.000Z')); + expect(countQueued()).toBe(1); + expect(getLastReminded()).toBe('2025-09-08'); + + // Martes 2025-09-09 ≥ hora -> no debe enviar (weekly solo lunes) + await RemindersService.runOnce(new Date('2025-09-09T07:40:00.000Z')); + expect(countQueued()).toBe(1); // sin incremento + // last_reminded_on permanece en lunes + expect(getLastReminded()).toBe('2025-09-08'); + }); + + it('incluye “… y X más” cuando hay más de 10 tareas (tope de listado)', async () => { + insertPref('daily', '08:30', null); + const now = new Date('2025-09-08T07:40:00.000Z'); + + // Crear 12 tareas asignadas al usuario + for (let i = 0; i < 12; i++) { + TaskService.createTask( + { description: `Tarea ${i + 1}`, due_date: '2025-09-20', group_id: null, created_by: USER }, + [{ user_id: USER, assigned_by: USER }] + ); + } + + await RemindersService.runOnce(now); + expect(countQueued()).toBe(1); + + const row = memdb.prepare(`SELECT message FROM response_queue LIMIT 1`).get() as any; + const msg: string = String(row?.message || ''); + expect(msg.includes('… y 2 más')).toBe(true); + }); +});