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.

436 lines
15 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test';
import { Database } from 'bun:sqlite';
import { initializeDatabase, ensureUserExists } from '../../../src/db';
import { TaskService } from '../../../src/tasks/service';
import { setDb, resetDb } from '../../../src/db/locator';
let memDb: Database;
beforeAll(() => {
memDb = new Database(':memory:');
initializeDatabase(memDb);
setDb(memDb);
});
afterAll(() => {
try { resetDb(); memDb.close(); } catch {}
});
beforeEach(() => {
// Reafirmar el locator y limpiar tablas relevantes entre tests
setDb(memDb);
try { memDb.exec('DELETE FROM task_assignments'); } catch {}
try { memDb.exec('DELETE FROM tasks'); } catch {}
try { memDb.exec('DELETE FROM users'); } catch {}
try { memDb.exec('DELETE FROM groups'); } catch {}
});
describe('TaskService.listGroupUnassigned / countGroupUnassigned', () => {
it('devuelve solo tareas sin dueño del grupo, en orden por fecha (NULL al final) y respeta el límite', () => {
// Sembrar grupos para FK
memDb.prepare(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES (?, ?, ?, 1)`)
.run('g1@g.us', 'c1', 'G1');
memDb.prepare(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES (?, ?, ?, 1)`)
.run('g2@g.us', 'c1', 'G2');
// En g1: 3 sin dueño con distintas fechas, 1 asignada (excluir), 1 sin dueño completada (excluir)
const tEarly = TaskService.createTask({
description: 'Sin dueño pronto',
due_date: '2025-08-01',
group_id: 'g1@g.us',
created_by: '1000000000',
});
const tLater = TaskService.createTask({
description: 'Sin dueño tarde',
due_date: '2025-10-01',
group_id: 'g1@g.us',
created_by: '1000000000',
});
const tNull = TaskService.createTask({
description: 'Sin dueño sin fecha',
due_date: null,
group_id: 'g1@g.us',
created_by: '1000000000',
});
TaskService.createTask({
description: 'Asignada (no debe salir)',
due_date: '2025-09-01',
group_id: 'g1@g.us',
created_by: '1000000000',
}, [{ user_id: '2000000000', assigned_by: '1000000000' }]);
// Completar una sin dueño para que no aparezca
memDb.prepare(`UPDATE tasks SET completed = 1, completed_at = CURRENT_TIMESTAMP WHERE id = ?`).run(tEarly);
// En g2: sin dueño (no deben aparecer al listar g1)
for (let i = 1; i <= 3; i++) {
TaskService.createTask({
description: `G2 ${i}`,
due_date: '2025-12-31',
group_id: 'g2@g.us',
created_by: '1000000000',
});
}
const list = TaskService.listGroupUnassigned('g1@g.us', 10);
expect(Array.isArray(list)).toBe(true);
// Deben quedar 2: 'Sin dueño tarde' (fecha) y 'Sin dueño sin fecha' (NULL al final)
expect(list.length).toBe(2);
expect(list[0].description).toBe('Sin dueño tarde');
expect(list[1].description).toBe('Sin dueño sin fecha');
expect(list[0].assignees).toEqual([]);
expect(list[1].assignees).toEqual([]);
expect(list[0].group_id).toBe('g1@g.us');
expect(list[1].group_id).toBe('g1@g.us');
// Límite
// Crear más en g1 para forzar límite 1
TaskService.createTask({
description: 'Otra sin dueño',
due_date: '2025-11-01',
group_id: 'g1@g.us',
created_by: '1000000000',
});
const limited = TaskService.listGroupUnassigned('g1@g.us', 1);
expect(limited.length).toBe(1);
});
it('countGroupUnassigned cuenta solo sin dueño pendientes (excluye completadas y asignadas)', () => {
// Limpiar por si acaso
memDb.exec('DELETE FROM tasks');
memDb.exec('DELETE FROM task_assignments');
// Sembrar grupo
memDb.prepare(`INSERT OR IGNORE INTO groups (id, community_id, name, active) VALUES (?, ?, ?, 1)`)
.run('g1@g.us', 'c1', 'G1');
const a = TaskService.createTask({
description: 'Sin dueño 1',
due_date: '2025-09-10',
group_id: 'g1@g.us',
created_by: '3333333333',
});
const b = TaskService.createTask({
description: 'Sin dueño 2',
due_date: null,
group_id: 'g1@g.us',
created_by: '3333333333',
});
// Asignada (no cuenta como sin dueño)
TaskService.createTask({
description: 'Asignada',
due_date: '2025-09-12',
group_id: 'g1@g.us',
created_by: '3333333333',
}, [{ user_id: '4444444444', assigned_by: '3333333333' }]);
// Completar una sin dueño
memDb.prepare(`UPDATE tasks SET completed = 1, completed_at = CURRENT_TIMESTAMP WHERE id = ?`).run(a);
const cnt = TaskService.countGroupUnassigned('g1@g.us');
expect(cnt).toBe(1); // sólo queda b
});
});
describe('TaskService.createTask', () => {
it('crea una tarea mínima con created_by y sin due_date ni group_id', () => {
const creatorRaw = '555111222@s.whatsapp.net';
const createdBy = ensureUserExists(creatorRaw, memDb);
expect(createdBy).toBeTruthy();
const id = TaskService.createTask(
{
description: 'Comprar agua',
created_by: createdBy!,
due_date: null,
group_id: null,
},
[] // sin asignaciones
);
expect(typeof id).toBe('number');
const task = memDb
.prepare(
`SELECT id, description, due_date, group_id, created_by, completed
FROM tasks WHERE id = ?`
)
.get(id) as any;
expect(task).toBeTruthy();
expect(task.description).toBe('Comprar agua');
expect(task.due_date).toBeNull();
expect(task.group_id).toBeNull();
expect(task.created_by).toBe(createdBy);
expect(task.completed).toBe(0); // BOOLEAN en SQLite como 0/1
});
it('guarda due_date como cadena YYYY-MM-DD y group_id como JID completo', () => {
const creatorRaw = '555333444@s.whatsapp.net';
const createdBy = ensureUserExists(creatorRaw, memDb)!;
const due = '2025-09-10';
const groupId = '12345@g.us';
// Sembrar el grupo para satisfacer la FK de tasks.group_id -> groups.id
memDb
.prepare(`INSERT INTO groups (id, community_id, name, active) VALUES (?, ?, ?, TRUE)`)
.run(groupId, 'test-community', 'Grupo de prueba');
const id = TaskService.createTask(
{
description: 'Pagar servicios',
created_by: createdBy,
due_date: due,
group_id: groupId,
},
[]
);
const row = memDb
.prepare(
`SELECT description, due_date, group_id, created_by
FROM tasks WHERE id = ?`
)
.get(id) as any;
expect(row.due_date).toBe(due);
expect(row.group_id).toBe(groupId);
expect(row.created_by).toBe(createdBy);
});
it('inserta asignaciones y evita duplicados por usuario', () => {
const creator = ensureUserExists('555000001@s.whatsapp.net', memDb)!;
const assigneeA = ensureUserExists('555000002@s.whatsapp.net', memDb)!;
const assigneeB = ensureUserExists('555000003@s.whatsapp.net', memDb)!;
const id = TaskService.createTask(
{
description: 'Organizar reunión',
created_by: creator,
due_date: null,
group_id: null,
},
[
{ user_id: assigneeA, assigned_by: creator },
{ user_id: assigneeA, assigned_by: creator }, // duplicado
{ user_id: assigneeB, assigned_by: creator },
]
);
const count = memDb
.prepare(
`SELECT COUNT(*) AS c FROM task_assignments WHERE task_id = ?`
)
.get(id) as any;
expect(count.c).toBe(2);
const users = memDb
.prepare(
`SELECT user_id FROM task_assignments WHERE task_id = ? ORDER BY user_id`
)
.all(id) as any[];
expect(users.map(u => u.user_id)).toEqual([assigneeA, assigneeB].sort());
});
it('asegura usuarios inexistentes en asignaciones y no viola FK', () => {
const creator = ensureUserExists('555010101@s.whatsapp.net', memDb)!;
const id = TaskService.createTask(
{
description: 'Tarea con asignado nuevo',
created_by: creator,
due_date: null,
group_id: null,
},
[
// Usuario no existente: TaskService debe asegurar y crear
{ user_id: '555099999', assigned_by: creator },
]
);
const counts = memDb
.prepare(
`SELECT
(SELECT COUNT(*) FROM tasks) AS tasks_count,
(SELECT COUNT(*) FROM task_assignments WHERE task_id = ?) AS assigns_count`
)
.get(id) as any;
expect(counts.tasks_count).toBe(1);
expect(counts.assigns_count).toBe(1);
});
it('lanza error si created_by es inválido (no normalizable) y no inserta la tarea', () => {
const invalidCreator = 'invalid-id!';
expect(() =>
TaskService.createTask(
{
description: 'No debería insertarse',
created_by: invalidCreator,
due_date: null,
group_id: null,
},
[]
)
).toThrow();
const count = memDb
.prepare(`SELECT COUNT(*) AS c FROM tasks`)
.get() as any;
expect(count.c).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Access control for completeTask
// ---------------------------------------------------------------------------
describe('TaskService.completeTask - access control', () => {
let memDb: Database;
const ADMIN = '1000000001';
const USER_ASSIGNED = '2000000001';
const USER_NOT_ASSIGNED = '2000000002';
const USER_GROUP_MEMBER = '2000000003';
const USER_GROUP_OUTSIDER = '2000000004';
const GROUP_ID = 'testgroup@g.us';
beforeAll(() => {
memDb = new Database(':memory:');
initializeDatabase(memDb);
setDb(memDb);
});
afterAll(() => {
try { resetDb(); memDb.close(); } catch {}
});
beforeEach(() => {
process.env.NODE_ENV = 'test';
setDb(memDb);
try { memDb.exec('DELETE FROM task_assignments'); } catch {}
try { memDb.exec('DELETE FROM tasks'); } catch {}
try { memDb.exec('DELETE FROM group_members'); } catch {}
try { memDb.exec('DELETE FROM users'); } catch {}
try { memDb.exec('DELETE FROM groups'); } catch {}
delete process.env.ADMIN_USERS;
});
function setupGroup(): void {
memDb.prepare(`
INSERT OR IGNORE INTO groups (id, community_id, name, active)
VALUES (?, 'comm-1', 'Test Group', 1)
`).run(GROUP_ID);
}
function createTask(opts: { description?: string; groupId?: string | null; createdBy: string; assignees?: string[] }): number {
const creator = ensureUserExists(opts.createdBy, memDb)!;
const taskId = TaskService.createTask(
{
description: opts.description || 'Test task',
due_date: null,
group_id: opts.groupId ?? null,
created_by: creator,
},
(opts.assignees || []).map(uid => ({ user_id: ensureUserExists(uid, memDb)!, assigned_by: creator }))
);
return taskId;
}
// --- Personal tasks (no group) ---
it('permite completar tarea personal si el usuario está asignado', () => {
const taskId = createTask({ createdBy: USER_ASSIGNED, assignees: [USER_ASSIGNED] });
const res = TaskService.completeTask(taskId, USER_ASSIGNED);
expect(res.status).toBe('updated');
});
it('rechaza tarea personal si el usuario no está asignado', () => {
const taskId = createTask({ createdBy: USER_ASSIGNED, assignees: [USER_ASSIGNED] });
const res = TaskService.completeTask(taskId, USER_NOT_ASSIGNED);
expect(res.status).toBe('forbidden');
});
it('rechaza tarea personal sin asignatarios para no-asignados', () => {
const taskId = createTask({ createdBy: USER_ASSIGNED });
const res = TaskService.completeTask(taskId, USER_NOT_ASSIGNED);
expect(res.status).toBe('forbidden');
});
// --- Group tasks ---
it('permite completar tarea de grupo si el usuario está asignado', () => {
setupGroup();
const taskId = createTask({ createdBy: USER_ASSIGNED, groupId: GROUP_ID, assignees: [USER_ASSIGNED] });
const res = TaskService.completeTask(taskId, USER_ASSIGNED);
expect(res.status).toBe('updated');
});
it('permite completar tarea de grupo si el usuario es miembro activo (aunque no esté asignado)', () => {
setupGroup();
ensureUserExists(USER_GROUP_MEMBER, memDb);
memDb.prepare(`INSERT OR REPLACE INTO group_members (group_id, user_id, is_active) VALUES (?, ?, 1)`).run(GROUP_ID, USER_GROUP_MEMBER);
const taskId = createTask({ createdBy: USER_ASSIGNED, groupId: GROUP_ID, assignees: [USER_ASSIGNED] });
const res = TaskService.completeTask(taskId, USER_GROUP_MEMBER);
expect(res.status).toBe('updated');
});
it('rechaza tarea de grupo si el usuario no está asignado ni es miembro', () => {
setupGroup();
const taskId = createTask({ createdBy: USER_ASSIGNED, groupId: GROUP_ID, assignees: [USER_ASSIGNED] });
const res = TaskService.completeTask(taskId, USER_GROUP_OUTSIDER);
expect(res.status).toBe('forbidden');
});
it('rechaza tarea de grupo si el usuario es miembro inactivo y no está asignado', () => {
setupGroup();
ensureUserExists(USER_GROUP_MEMBER, memDb);
memDb.prepare(`INSERT OR REPLACE INTO group_members (group_id, user_id, is_active) VALUES (?, ?, 0)`).run(GROUP_ID, USER_GROUP_MEMBER);
const taskId = createTask({ createdBy: USER_ASSIGNED, groupId: GROUP_ID, assignees: [USER_ASSIGNED] });
const res = TaskService.completeTask(taskId, USER_GROUP_MEMBER);
expect(res.status).toBe('forbidden');
});
// --- Admin override ---
it('permite a un admin completar tarea personal sin estar asignado', () => {
process.env.ADMIN_USERS = ADMIN;
const taskId = createTask({ createdBy: USER_ASSIGNED, assignees: [USER_ASSIGNED] });
const res = TaskService.completeTask(taskId, ADMIN);
expect(res.status).toBe('updated');
});
it('permite a un admin completar tarea de grupo sin estar asignado ni ser miembro', () => {
process.env.ADMIN_USERS = ADMIN;
setupGroup();
const taskId = createTask({ createdBy: USER_ASSIGNED, groupId: GROUP_ID, assignees: [USER_ASSIGNED] });
const res = TaskService.completeTask(taskId, ADMIN);
expect(res.status).toBe('updated');
});
it('permite a un admin con ID raw (con sufijo @s.whatsapp.net) completar', () => {
process.env.ADMIN_USERS = ADMIN;
const taskId = createTask({ createdBy: USER_ASSIGNED, assignees: [USER_ASSIGNED] });
const res = TaskService.completeTask(taskId, ADMIN + '@s.whatsapp.net');
expect(res.status).toBe('updated');
});
// --- Already completed / not found (unchanged behavior) ---
it('devuelve already si la tarea ya estaba completada', () => {
const taskId = createTask({ createdBy: USER_ASSIGNED, assignees: [USER_ASSIGNED] });
TaskService.completeTask(taskId, USER_ASSIGNED);
const res = TaskService.completeTask(taskId, USER_ASSIGNED);
expect(res.status).toBe('already');
});
it('devuelve not_found si la tarea no existe', () => {
const res = TaskService.completeTask(999999, USER_ASSIGNED);
expect(res.status).toBe('not_found');
});
});