test: añade tests web con plan programático (bun.test)
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>webui
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…
Reference in New Issue