diff --git a/tests/web/api.me.preferences.test.ts b/tests/web/api.me.preferences.test.ts new file mode 100644 index 0000000..3e3d65e --- /dev/null +++ b/tests/web/api.me.preferences.test.ts @@ -0,0 +1,76 @@ +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 } from '../../src/db'; + +async function sha256Hex(input: string): Promise { + 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/preferences', () => { + const userId = '34600123456'; + let dbPath: string; + let server: Awaited> | null = null; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'webtest-')); + dbPath = join(tmpDir, 'tasks.db'); + + // Inicializar DB en archivo (como en prod) + const db = new Database(dbPath); + initializeDatabase(db); + + // Crear sesión válida + const sid = 'sid-test-pref'; + const hash = await sha256Hex(sid); + const now = new Date(); + const nowIso = toIsoSql(now); + const expIso = toIsoSql(new Date(now.getTime() + 60 * 60 * 1000)); // +1h + + db.prepare(` + INSERT INTO web_sessions (session_hash, user_id, created_at, last_seen_at, expires_at) + VALUES (?, ?, ?, ?, ?) + `).run(hash, userId, nowIso, nowIso, expIso); + db.close(); + + // Arrancar web apuntando a este DB + server = await startWebServer({ + port: 19100, + env: { DB_PATH: dbPath, TZ: 'UTC' } + }); + + // Probar que el endpoint responde (no asertivo aún) + const res = await fetch(`${server.baseUrl}/api/me/preferences`, { + headers: { Cookie: `sid=${sid}` } + }); + expect(res.status).toBe(200); + }); + + afterAll(async () => { + try { await server?.stop(); } catch {} + try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} + }); + + it('devuelve valores por defecto cuando no hay preferencias guardadas', async () => { + const sid = 'sid-test-pref'; + const res = await fetch(`${server!.baseUrl}/api/me/preferences`, { + headers: { Cookie: `sid=${sid}` } + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json).toEqual({ freq: 'off', time: '08:30' }); + }); +}); diff --git a/tests/web/api.me.tasks.test.ts b/tests/web/api.me.tasks.test.ts new file mode 100644 index 0000000..37bfd0a --- /dev/null +++ b/tests/web/api.me.tasks.test.ts @@ -0,0 +1,129 @@ +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 { + 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> | 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%'); + }); +}); diff --git a/tests/web/helpers/server.ts b/tests/web/helpers/server.ts new file mode 100644 index 0000000..def1b8e --- /dev/null +++ b/tests/web/helpers/server.ts @@ -0,0 +1,138 @@ +import { existsSync, mkdirSync, openSync, rmSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; + +export async function ensureWebBuilt(): Promise { + const buildEntry = join('apps', 'web', 'build', 'index.js'); + if (existsSync(buildEntry)) return; + + const lockFile = join('apps', 'web', '.build.lock'); + + let haveLock = false; + try { + // Intentar crear lock (exclusivo). Si existe, esperar a que termine la otra build. + openSync(lockFile, 'wx'); + haveLock = true; + } catch { + // Otra build en progreso o ya hecha. Esperar hasta que exista el build. + const timeoutMs = 60_000; + const start = Date.now(); + while (!existsSync(buildEntry)) { + if (Date.now() - start > timeoutMs) { + throw new Error('Timeout esperando a que termine el build de apps/web'); + } + // Dormir 100ms + await new Promise((res) => setTimeout(res, 100)); + } + return; + } + + try { + // Asegurar carpeta build + try { + mkdirSync(dirname(buildEntry), { recursive: true }); + } catch {} + + // Ejecutar "bun run build" dentro de apps/web + const proc = Bun.spawn({ + cmd: [process.execPath, 'run', 'build'], + cwd: join('apps', 'web'), + stdout: 'inherit', + stderr: 'inherit', + env: { + ...process.env, + NODE_ENV: 'production' + } + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`Fallo al construir apps/web (exit ${exitCode})`); + } + } finally { + // Liberar lock + try { + rmSync(lockFile, { force: true }); + } catch {} + } +} + +export type RunningServer = { + baseUrl: string; + stop: () => Promise; + pid: number | null; + port: number; +}; + +export async function startWebServer(opts: { + port?: number; + env?: Record; +} = {}): Promise { + await ensureWebBuilt(); + + const port = Number(opts.port || 19080); + + // Lanzar servidor Node adapter: "bun ./build/index.js" en apps/web + const child = Bun.spawn({ + cmd: [process.execPath, './build/index.js'], + cwd: join('apps', 'web'), + stdout: 'pipe', + stderr: 'pipe', + env: { + ...process.env, + PORT: String(port), + NODE_ENV: 'test', + ...(opts.env || {}) + } + }); + + // Esperar a que esté arriba (ping a "/") + const baseUrl = `http://127.0.0.1:${port}`; + const startedAt = Date.now(); + const timeoutMs = 30_000; + let lastErr: any = null; + + while (Date.now() - startedAt < timeoutMs) { + try { + const res = await fetch(baseUrl + '/', { method: 'GET' }); + if (res) break; + } catch (e) { + lastErr = e; + } + await new Promise((res) => setTimeout(res, 100)); + } + + if (Date.now() - startedAt >= timeoutMs) { + try { child.kill(); } catch {} + throw new Error(`Timeout esperando al servidor web: ${lastErr?.message || lastErr}`); + } + + // Conectar logs a consola (opcional) + (async () => { + try { + for await (const chunk of child.stdout) { + try { + process.stderr.write(`[web] ${new TextDecoder().decode(chunk)}`); + } catch {} + } + } catch {} + })(); + (async () => { + try { + for await (const chunk of child.stderr) { + try { + process.stderr.write(`[web] ${new TextDecoder().decode(chunk)}`); + } catch {} + } + } catch {} + })(); + + return { + baseUrl, + port, + pid: child.pid, + stop: async () => { + try { child.kill(); } catch {} + // Pequeña espera para liberar puerto + await new Promise((res) => setTimeout(res, 50)); + } + }; +}