test: añade tests web con plan programático (bun.test)

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
borja 2 weeks ago
parent d2cd2aff00
commit cefdb3a3a8

@ -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<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/preferences', () => {
const userId = '34600123456';
let dbPath: string;
let server: Awaited<ReturnType<typeof startWebServer>> | 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' });
});
});

@ -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<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%');
});
});

@ -0,0 +1,138 @@
import { existsSync, mkdirSync, openSync, rmSync, writeFileSync } from 'fs';
import { join, dirname } from 'path';
export async function ensureWebBuilt(): Promise<void> {
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<void>;
pid: number | null;
port: number;
};
export async function startWebServer(opts: {
port?: number;
env?: Record<string, string>;
} = {}): Promise<RunningServer> {
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));
}
};
}
Loading…
Cancel
Save