From df27161216bcb94a4365367be69ee1dcbd71e275 Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 2 Nov 2025 11:44:38 +0100 Subject: [PATCH] test: agregar pruebas de datetime, mantenimiento y API de completar Co-authored-by: aider (openrouter/openai/gpt-5) --- tests/unit/services/maintenance.test.ts | 92 +++++++++++++ tests/unit/web.datetime.test.ts | 28 ++++ tests/web/api.tasks.complete.errors.test.ts | 144 ++++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 tests/unit/services/maintenance.test.ts create mode 100644 tests/unit/web.datetime.test.ts create mode 100644 tests/web/api.tasks.complete.errors.test.ts diff --git a/tests/unit/services/maintenance.test.ts b/tests/unit/services/maintenance.test.ts new file mode 100644 index 0000000..9aa4f07 --- /dev/null +++ b/tests/unit/services/maintenance.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it } from 'bun:test'; +import Database from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { MaintenanceService } from '../../../src/services/maintenance'; +import { toIsoSqlUTC } from '../../../src/utils/datetime'; + +function makeMem(): any { + const db = new Database(':memory:'); + initializeDatabase(db); + return db; +} + +describe('MaintenanceService', () => { + let memdb: any; + + beforeEach(() => { + memdb = makeMem(); + }); + + it('cleanupInactiveMembersOnce elimina miembros inactivos más antiguos que el umbral', async () => { + // Grupo y usuarios + memdb.exec(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES ('g1@g.us','comm','G',1);`); + memdb.exec(`INSERT OR IGNORE INTO users (id) VALUES ('111');`); + memdb.exec(`INSERT OR IGNORE INTO users (id) VALUES ('222');`); + + // last_seen_at muy antiguo → debe borrarse + memdb.exec(` + INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) + VALUES ('g1@g.us','111',0,0,'2020-01-01 00:00:00','2020-01-01 00:00:00'); + `); + // last_seen_at reciente → no debe borrarse + const recent = toIsoSqlUTC(new Date(Date.now() - 12 * 60 * 60 * 1000)); + memdb.exec(` + INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) + VALUES ('g1@g.us','222',0,0,'${recent}','${recent}'); + `); + + const deleted = await MaintenanceService.cleanupInactiveMembersOnce(memdb, 1); + expect(deleted).toBe(1); + + const rows = memdb.prepare(`SELECT user_id FROM group_members ORDER BY user_id`).all() as any[]; + const remaining = rows.map(r => r.user_id); + expect(remaining).toEqual(['222']); + }); + + it('cleanupInactiveMembersOnce no hace nada si retención <= 0', async () => { + const deleted = await MaintenanceService.cleanupInactiveMembersOnce(memdb, 0); + expect(deleted).toBe(0); + }); + + it('reconcileAliasUsersOnce fusiona alias a usuario real en tablas principales', async () => { + // Sembrar usuarios + memdb.exec(`INSERT OR IGNORE INTO users (id, created_at, updated_at) VALUES ('alias-1', '2024-01-01 00:00:00', '2024-01-01 00:00:00');`); + memdb.exec(`INSERT OR IGNORE INTO users (id, created_at, updated_at) VALUES ('real-1', '2024-01-01 00:00:00', '2024-01-01 00:00:00');`); + + // Tarea creada por alias y asignaciones usando alias + const res = memdb.prepare(` + INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at) + VALUES ('T1', NULL, NULL, 'alias-1', 0, NULL) + `).run() as any; + const taskId = Number(res.lastInsertRowid); + memdb.exec(` + INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by) + VALUES (${taskId}, 'alias-1', 'alias-1'); + `); + + // Tabla de alias + memdb.exec(` + INSERT OR IGNORE INTO user_aliases (alias, user_id, source) + VALUES ('alias-1', 'real-1', 'test'); + `); + + const merged = await MaintenanceService.reconcileAliasUsersOnce(memdb); + expect(merged).toBeGreaterThanOrEqual(1); + + const tRow = memdb.prepare(`SELECT created_by FROM tasks WHERE id = ?`).get(taskId) as any; + expect(String(tRow.created_by)).toBe('real-1'); + + const aRows = memdb.prepare(`SELECT user_id, assigned_by FROM task_assignments WHERE task_id = ?`).all(taskId) as any[]; + expect(aRows.length).toBe(1); + expect(String(aRows[0].user_id)).toBe('real-1'); + expect(String(aRows[0].assigned_by)).toBe('real-1'); + }); + + it('reconcileAliasUsersOnce retorna 0 si no existe la tabla user_aliases', async () => { + try { + memdb.exec(`DROP TABLE IF EXISTS user_aliases;`); + } catch {} + const merged = await MaintenanceService.reconcileAliasUsersOnce(memdb); + expect(merged).toBe(0); + }); +}); diff --git a/tests/unit/web.datetime.test.ts b/tests/unit/web.datetime.test.ts new file mode 100644 index 0000000..a2ce094 --- /dev/null +++ b/tests/unit/web.datetime.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'bun:test'; +import { toIsoSqlUTC, ymdUTC, addMonthsUTC } from '../../apps/web/src/lib/server/datetime.ts'; + +describe('apps/web datetime wrapper', () => { + it('toIsoSqlUTC serializa en UTC con milisegundos', () => { + const d = new Date(Date.UTC(2024, 0, 31, 23, 59, 59, 123)); + expect(toIsoSqlUTC(d)).toBe('2024-01-31 23:59:59.123'); + }); + + it('ymdUTC devuelve YYYY-MM-DD en UTC', () => { + const d = new Date(Date.UTC(2024, 7, 9, 10, 11, 12)); // 2024-08-09 + expect(ymdUTC(d)).toBe('2024-08-09'); + }); + + it('addMonthsUTC suma meses en UTC preservando día cuando es posible', () => { + const d1 = new Date(Date.UTC(2024, 0, 15)); // 2024-01-15 + const plus1 = addMonthsUTC(d1, 1); + expect(ymdUTC(plus1)).toBe('2024-02-15'); + + const d2 = new Date(Date.UTC(2024, 1, 29)); // 2024-02-29 (bisiesto) + const plus1b = addMonthsUTC(d2, 1); + expect(ymdUTC(plus1b)).toBe('2024-03-29'); + + const d3 = new Date(Date.UTC(2024, 11, 15)); // 2024-12-15 + const plus2 = addMonthsUTC(d3, 2); + expect(ymdUTC(plus2)).toBe('2025-02-15'); + }); +}); diff --git a/tests/web/api.tasks.complete.errors.test.ts b/tests/web/api.tasks.complete.errors.test.ts new file mode 100644 index 0000000..972efd0 --- /dev/null +++ b/tests/web/api.tasks.complete.errors.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, afterEach, describe, expect, it } from 'bun:test'; +import { createTempDb } from './helpers/db'; + +// Reutilizamos el estilo de import dinámico del handler como en otros tests web. + +describe('Web API - completar tarea (rutas de error y gating)', () => { + let cleanup: () => void; + let db: any; + let path: string; + + const USER = '34600123456'; + const GROUP_ID = '12345-67890@g.us'; + + beforeEach(() => { + const tmp = createTempDb(); + cleanup = tmp.cleanup; + db = tmp.db; + path = tmp.path; + + process.env.NODE_ENV = 'test'; + process.env.DB_PATH = path; + process.env.REACTIONS_ENABLED = 'true'; + process.env.REACTIONS_SCOPE = 'groups'; + process.env.REACTIONS_TTL_DAYS = '14'; + process.env.GROUP_GATING_MODE = 'enforce'; + }); + + afterEach(async () => { + try { + const { closeDb } = await import('../../apps/web/src/lib/server/db.ts'); + closeDb(); + } catch {} + if (cleanup) cleanup(); + delete process.env.DB_PATH; + delete process.env.REACTIONS_ENABLED; + delete process.env.REACTIONS_SCOPE; + delete process.env.REACTIONS_TTL_DAYS; + delete process.env.GROUP_GATING_MODE; + }); + + it('401 si no hay session (userId)', async () => { + const event: any = { + locals: { userId: null }, + params: { id: '1' }, + request: new Request('http://localhost', { method: 'POST' }) + }; + const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts'); + const res = await completeHandler(event); + expect(res.status).toBe(401); + }); + + it('400 si id no es válido', async () => { + const event: any = { + locals: { userId: USER }, + params: { id: 'abc' }, + request: new Request('http://localhost', { method: 'POST' }) + }; + const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts'); + const res = await completeHandler(event); + expect(res.status).toBe(400); + }); + + it('404 si la tarea no existe', async () => { + const event: any = { + locals: { userId: USER }, + params: { id: '999999' }, + request: new Request('http://localhost', { method: 'POST' }) + }; + // Sembrar usuario para evitar FKs indirectas (no imprescindible aquí) + db.prepare(`INSERT OR IGNORE INTO users (id) VALUES (?)`).run(USER); + + const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts'); + const res = await completeHandler(event); + expect(res.status).toBe(404); + }); + + it('403 si grupo no allowed', async () => { + db.prepare(`INSERT OR IGNORE INTO users (id) VALUES (?)`).run(USER); + // Tarea con group_id; no insertamos en allowed_groups + const taskId = Number( + db.prepare(` + INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at) + VALUES ('No allowed', NULL, ?, ?, 0, NULL) + `).run(GROUP_ID, USER).lastInsertRowid + ); + + // El usuario podría incluso ser miembro activo; sigue siendo 403 por falta de allowed + db.prepare(`INSERT OR REPLACE INTO group_members (group_id, user_id, is_admin, is_active) VALUES (?, ?, 0, 1)`).run(GROUP_ID, USER); + + const event: any = { + locals: { userId: USER }, + params: { id: String(taskId) }, + request: new Request('http://localhost', { method: 'POST' }) + }; + const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts'); + const res = await completeHandler(event); + expect(res.status).toBe(403); + }); + + it('403 si grupo allowed pero usuario no es miembro activo', async () => { + db.prepare(`INSERT OR IGNORE INTO users (id) VALUES (?)`).run(USER); + db.prepare(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES (?, 'comm', 'G', 1)`).run(GROUP_ID); + db.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, label, status) VALUES (?, 'G', 'allowed')`).run(GROUP_ID); + + const taskId = Number( + db.prepare(` + INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at) + VALUES ('No active member', NULL, ?, ?, 0, NULL) + `).run(GROUP_ID, USER).lastInsertRowid + ); + + // No sembramos group_members activo para USER + + const event: any = { + locals: { userId: USER }, + params: { id: String(taskId) }, + request: new Request('http://localhost', { method: 'POST' }) + }; + const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts'); + const res = await completeHandler(event); + expect(res.status).toBe(403); + }); + + it('403 en tarea personal si no está asignada al usuario', async () => { + db.prepare(`INSERT OR IGNORE INTO users (id) VALUES (?)`).run(USER); + const taskId = Number( + db.prepare(` + INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at) + VALUES ('Personal sin asignación', NULL, NULL, ?, 0, NULL) + `).run(USER).lastInsertRowid + ); + + // No insertamos task_assignments + + const event: any = { + locals: { userId: USER }, + params: { id: String(taskId) }, + request: new Request('http://localhost', { method: 'POST' }) + }; + const { POST: completeHandler } = await import('../../apps/web/src/routes/api/tasks/[id]/complete/+server.ts'); + const res = await completeHandler(event); + expect(res.status).toBe(403); + }); +});