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.
taskbot/tests/web/api.me.tasks.test.ts

338 lines
12 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { Database } from 'bun:sqlite';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { startWebServer } from './helpers/server';
import { initializeDatabase, ensureUserExists } from '../../src/db';
async function sha256Hex(input: string): Promise<string> {
const enc = new TextEncoder().encode(input);
const buf = await crypto.subtle.digest('SHA-256', enc);
const bytes = new Uint8Array(buf);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
function toIsoSql(d = new Date()): string {
return d.toISOString().replace('T', ' ').replace('Z', '');
}
describe('Web API - GET /api/me/tasks', () => {
const USER = '34600123456';
const OTHER = '34600999888';
const GROUP_OK = '123@g.us';
const GROUP_BAD = '999@g.us';
let dbPath: string;
let server: Awaited<ReturnType<typeof startWebServer>> | null = null;
let tmpDir: string;
let sid = 'sid-test-tasks';
beforeAll(async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'webtest-'));
dbPath = join(tmpDir, 'tasks.db');
const db = new Database(dbPath);
initializeDatabase(db);
// Asegurar usuarios
const uid = ensureUserExists(USER, db)!;
const oid = ensureUserExists(OTHER, db)!;
// Sembrar grupos y gating
db.prepare(`
INSERT INTO groups (id, community_id, name, active, last_verified)
VALUES (?, 'comm-1', 'Group OK', 1, strftime('%Y-%m-%d %H:%M:%f','now'))
`).run(GROUP_OK);
db.prepare(`
INSERT INTO groups (id, community_id, name, active, last_verified)
VALUES (?, 'comm-1', 'Group BAD', 1, strftime('%Y-%m-%d %H:%M:%f','now'))
`).run(GROUP_BAD);
db.prepare(`INSERT INTO allowed_groups (group_id, status, updated_at) VALUES (?, 'allowed', strftime('%Y-%m-%d %H:%M:%f','now'))`).run(GROUP_OK);
db.prepare(`INSERT INTO allowed_groups (group_id, status, updated_at) VALUES (?, 'blocked', strftime('%Y-%m-%d %H:%M:%f','now'))`).run(GROUP_BAD);
// Membresía activa solo en GROUP_OK
const nowIso = toIsoSql(new Date());
db.prepare(`
INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at, last_role_change_at)
VALUES (?, ?, 0, 1, ?, ?, ?)
`).run(GROUP_OK, uid, nowIso, nowIso, nowIso);
// Tareas en GROUP_OK: una con due_date, otra sin due_date
const insertTask = db.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, display_code)
VALUES (?, ?, ?, ?, ?)
`);
const insertAssign = db.prepare(`
INSERT INTO task_assignments (task_id, user_id, assigned_by)
VALUES (?, ?, ?)
`);
const r1 = insertTask.run('alpha', '2025-01-02', GROUP_OK, uid, 101) as any;
const t1 = Number(r1.lastInsertRowid);
insertAssign.run(t1, uid, uid);
const r2 = insertTask.run('beta_100% exacta', null, GROUP_OK, uid, 102) as any;
const t2 = Number(r2.lastInsertRowid);
insertAssign.run(t2, uid, uid);
// Tarea en GROUP_BAD asignada al usuario (debe ser filtrada por gating)
const r3 = insertTask.run('gamma filtrada', '2025-02-01', GROUP_BAD, oid, 103) as any;
const t3 = Number(r3.lastInsertRowid);
insertAssign.run(t3, uid, oid);
// Crear sesión válida
const hash = await sha256Hex(sid);
db.prepare(`
INSERT INTO web_sessions (session_hash, user_id, created_at, last_seen_at, expires_at)
VALUES (?, ?, ?, ?, ?)
`).run(hash, uid, nowIso, nowIso, toIsoSql(new Date(Date.now() + 60 * 60 * 1000)));
db.close();
// Arrancar servidor
server = await startWebServer({
port: 19101,
env: { DB_PATH: dbPath, TZ: 'UTC' }
});
});
afterAll(async () => {
try { await server?.stop(); } catch {}
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
});
it('aplica gating y ordena por due_date asc con NULL al final', async () => {
const res = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10`, {
headers: { Cookie: `sid=${sid}` }
});
expect(res.status).toBe(200);
const json = await res.json();
const ids = json.items.map((it: any) => it.display_code ?? it.id);
// Deben venir solo t1 (101) y t2 (102), en ese orden (t1 con fecha primero, luego NULL)
expect(ids).toEqual([101, 102]);
});
it('filtra por búsqueda escapando % y _ correctamente', async () => {
const q = encodeURIComponent('beta_100%');
const res = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10&search=${q}`, {
headers: { Cookie: `sid=${sid}` }
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.items.length).toBe(1);
expect(json.items[0].description).toContain('beta_100%');
});
it('permite actualizar la descripción de una tarea con PATCH', async () => {
// Localizar la tarea por búsqueda
const q = encodeURIComponent('beta_100%');
const listRes = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10&search=${q}`, {
headers: { Cookie: `sid=${sid}` }
});
expect(listRes.status).toBe(200);
const list = await listRes.json();
expect(list.items.length).toBe(1);
const taskId = list.items[0].id;
// Actualizar descripción
const newDesc = 'beta renombrada';
const patchRes = await fetch(`${server!.baseUrl}/api/tasks/${taskId}`, {
method: 'PATCH',
headers: {
Cookie: `sid=${sid}`,
'content-type': 'application/json'
},
body: JSON.stringify({ description: newDesc })
});
expect(patchRes.status).toBe(200);
const patched = await patchRes.json();
expect(patched.task.description).toBe(newDesc);
// Verificar en el listado
const q2 = encodeURIComponent('renombrada');
const listRes2 = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10&search=${q2}`, {
headers: { Cookie: `sid=${sid}` }
});
expect(listRes2.status).toBe(200);
const list2 = await listRes2.json();
expect(list2.items.length).toBe(1);
expect(list2.items[0].description).toContain('renombrada');
});
it('rechaza descripciones vacías o demasiado largas', async () => {
// Reutiliza la misma tarea localizada arriba (buscar por 'renombrada')
const q = encodeURIComponent('renombrada');
const listRes = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10&search=${q}`, {
headers: { Cookie: `sid=${sid}` }
});
expect(listRes.status).toBe(200);
const list = await listRes.json();
expect(list.items.length).toBe(1);
const taskId = list.items[0].id;
// Vacía/espacios
const resEmpty = await fetch(`${server!.baseUrl}/api/tasks/${taskId}`, {
method: 'PATCH',
headers: {
Cookie: `sid=${sid}`,
'content-type': 'application/json'
},
body: JSON.stringify({ description: ' ' })
});
expect(resEmpty.status).toBe(400);
// Demasiado larga (>1000)
const longText = 'a'.repeat(1001);
const resLong = await fetch(`${server!.baseUrl}/api/tasks/${taskId}`, {
method: 'PATCH',
headers: {
Cookie: `sid=${sid}`,
'content-type': 'application/json'
},
body: JSON.stringify({ description: longText })
});
expect(resLong.status).toBe(400);
});
it('permite completar una tarea asignada y aparece en recent', async () => {
// Buscar una tarea abierta por texto
const q = encodeURIComponent('alpha');
const listRes = await fetch(`${server!.baseUrl}/api/me/tasks?limit=10&search=${q}`, {
headers: { Cookie: `sid=${sid}` }
});
expect(listRes.status).toBe(200);
const list = await listRes.json();
expect(list.items.length).toBe(1);
const taskId = list.items[0].id;
// Completar
const resComplete = await fetch(`${server!.baseUrl}/api/tasks/${taskId}/complete`, {
method: 'POST',
headers: { Cookie: `sid=${sid}` }
});
expect(resComplete.status).toBe(200);
const done = await resComplete.json();
expect(done.status === 'updated' || done.status === 'already').toBe(true);
// Ver en recent
const recentRes = await fetch(`${server!.baseUrl}/api/me/tasks?status=recent&limit=10`, {
headers: { Cookie: `sid=${sid}` }
});
expect(recentRes.status).toBe(200);
const recent = await recentRes.json();
const ids = recent.items.map((it: any) => it.id);
expect(ids.includes(taskId)).toBe(true);
});
it('oculta tareas de grupo archivado aunque esté asignada y allowed', async () => {
// Abrir la misma DB del servidor para sembrar datos adicionales
const db2 = new Database(dbPath);
const nowIso = toIsoSql(new Date());
// Grupo activo inicialmente
db2.prepare(`
INSERT INTO groups (id, community_id, name, active, last_verified, archived)
VALUES (?, 'comm-1', 'Group ARCH', 1, ?, 0)
`).run('arch@g.us', nowIso);
db2.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, status, updated_at) VALUES ('arch@g.us','allowed',?)`).run(nowIso);
db2.prepare(`
INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
VALUES ('arch@g.us', ?, 0, 1, ?, ?)
`).run(USER, nowIso, nowIso);
// Tarea asignada al usuario en ese grupo
const r = db2.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, display_code)
VALUES ('delta archivada', '2025-03-01', 'arch@g.us', ?, 150)
`).run(USER) as any;
const tid = Number(r.lastInsertRowid);
db2.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?)`).run(tid, USER, USER);
// Archivar el grupo
db2.prepare(`UPDATE groups SET archived = 1 WHERE id = 'arch@g.us'`).run();
db2.close();
const q = encodeURIComponent('delta');
const res = await fetch(`${server!.baseUrl}/api/me/tasks?limit=50&search=${q}`, {
headers: { Cookie: `sid=${sid}` }
});
expect(res.status).toBe(200);
const json = await res.json();
const descs = json.items.map((it: any) => String(it.description));
expect(descs.some((d: string) => d.includes('delta archivada'))).toBe(false);
});
it('oculta tareas de grupo inactivo (active=0) aunque esté asignada y allowed', async () => {
const db2 = new Database(dbPath);
const nowIso = toIsoSql(new Date());
db2.prepare(`
INSERT INTO groups (id, community_id, name, active, last_verified, archived)
VALUES ('inact@g.us', 'comm-1', 'Group INACT', 1, ?, 0)
`).run(nowIso);
db2.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, status, updated_at) VALUES ('inact@g.us','allowed',?)`).run(nowIso);
db2.prepare(`
INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
VALUES ('inact@g.us', ?, 0, 1, ?, ?)
`).run(USER, nowIso, nowIso);
const r = db2.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, display_code)
VALUES ('omega inactiva', '2025-04-01', 'inact@g.us', ?, 151)
`).run(USER) as any;
const tid = Number(r.lastInsertRowid);
db2.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?)`).run(tid, USER, USER);
// Desactivar el grupo
db2.prepare(`UPDATE groups SET active = 0 WHERE id = 'inact@g.us'`).run();
db2.close();
const q = encodeURIComponent('omega inactiva');
const res = await fetch(`${server!.baseUrl}/api/me/tasks?limit=50&search=${q}`, {
headers: { Cookie: `sid=${sid}` }
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.items.length).toBe(0);
});
it('recent no muestra tareas completadas de grupos archivados', async () => {
const db2 = new Database(dbPath);
const nowIso = toIsoSql(new Date());
db2.prepare(`
INSERT INTO groups (id, community_id, name, active, last_verified, archived)
VALUES ('rec@g.us', 'comm-1', 'Group REC', 1, ?, 0)
`).run(nowIso);
db2.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, status, updated_at) VALUES ('rec@g.us','allowed',?)`).run(nowIso);
db2.prepare(`
INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at)
VALUES ('rec@g.us', ?, 0, 1, ?, ?)
`).run(USER, nowIso, nowIso);
const r = db2.prepare(`
INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at, display_code)
VALUES ('epsilon reciente', '2025-05-01', 'rec@g.us', ?, 1, ?, 152)
`).run(USER, nowIso) as any;
const tid = Number(r.lastInsertRowid);
db2.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?)`).run(tid, USER, USER);
// Archivar el grupo
db2.prepare(`UPDATE groups SET archived = 1 WHERE id = 'rec@g.us'`).run();
db2.close();
const q = encodeURIComponent('epsilon reciente');
const res = await fetch(`${server!.baseUrl}/api/me/tasks?status=recent&limit=50&search=${q}`, {
headers: { Cookie: `sid=${sid}` }
});
expect(res.status).toBe(200);
const json = await res.json();
expect(json.items.length).toBe(0);
});
});