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.
		
		
		
		
		
			
		
			
				
	
	
		
			231 lines
		
	
	
		
			7.9 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			231 lines
		
	
	
		
			7.9 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);
 | |
| 	});
 | |
| });
 |