test: añade pruebas de integración y actualiza docs para Fase 10

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
brobert 2 weeks ago
parent 94a7f19a3c
commit 513099f6ef

@ -314,7 +314,7 @@ Resultado (implementado)
- Idempotente: se ejecuta solo si la tabla tasks está vacía y con DEV_AUTOSEED_DB='true' en desarrollo; usa DEV_DEFAULT_USER numérico cuando está definido.
- Documentación actualizada en docs/operations.md con instrucciones para activar y regenerar la base de datos de desarrollo.
Fase 10 — Completar tarea sin responsable: auto-asignación al completador
Fase 10 — Completar tarea sin responsable: auto-asignación al completador — Estado: Completada
Objetivos
- Resolver el edge case: al completar una tarea sin responsables, debe aparecer en “Completadas (24h)” del usuario y permitir “Deshacer”.
- Mantener gating y trazabilidad coherentes.
@ -341,6 +341,12 @@ Archivos a editar
- apps/web/src/lib/ui/data/TaskItem.svelte (feedback UI)
- tests/web/* (casos de integración)
Resultado (implementado)
- Auto-asignación atómica al completar tareas de grupo sin responsables; se registra completed_by.
- Listado “Completadas (24 h)” incluye estas tareas gracias a la auto-asignación.
- Uncomplete permitido dentro de la ventana configurada, manteniendo la asignación creada.
- Tests de integración añadidos: complete-autoassign-recent, uncomplete-window y carrera de complete (con dos usuarios).
Criterios de aceptación
- Completar una tarea sin responsables la vincula al usuario y aparece inmediatamente en “Completadas (24h)”.
- “Deshacer completar” funciona para ese caso dentro de la ventana configurada.

@ -0,0 +1,86 @@
import { describe, it, beforeAll, afterAll, expect } from 'bun:test';
import { createTempDb } from './helpers/db';
import { startWebServer, type RunningServer } from './helpers/server';
describe('API Web - complete auto-assign y recientes', () => {
const USER = '34600123456';
const GROUP = 'g-1@g.us';
let dbHandle: any;
let cleanupDb: () => void;
let server: RunningServer;
let baseUrl: string;
let taskId: number;
beforeAll(async () => {
const tmp = createTempDb();
dbHandle = tmp.db;
cleanupDb = tmp.cleanup;
// Semilla mínima
dbHandle.prepare(`INSERT INTO users (id) VALUES (?) ON CONFLICT(id) DO NOTHING`).run(USER);
dbHandle
.prepare(`INSERT INTO groups (id, community_id, name, active) VALUES (?, 'comm-1', 'Test Group', 1)`)
.run(GROUP);
dbHandle.prepare(`INSERT INTO allowed_groups (group_id, status) VALUES (?, 'allowed')`).run(GROUP);
dbHandle
.prepare(
`INSERT INTO group_members (group_id, user_id, is_admin, is_active) VALUES (?, ?, 0, 1)
`
)
.run(GROUP, USER);
const ins = dbHandle
.prepare(`INSERT INTO tasks (description, group_id, created_by) VALUES ('Hacer algo', ?, ?)`)
.run(GROUP, USER);
taskId = Number(ins.lastInsertRowid);
server = await startWebServer({
port: 19101,
env: { DB_PATH: tmp.path, DEV_DEFAULT_USER: USER, TZ: 'UTC' }
});
baseUrl = server.baseUrl;
});
afterAll(async () => {
try {
await server.stop();
} catch {}
try {
cleanupDb?.();
} catch {}
});
it('completar una tarea sin responsables auto-asigna y aparece en recientes', async () => {
// Completar
const resComplete = await fetch(`${baseUrl}/api/tasks/${taskId}/complete`, { method: 'POST' });
expect(resComplete.status).toBe(200);
const jsonComplete = await resComplete.json();
expect(['updated', 'already']).toContain(jsonComplete?.status);
// Recientes (24h)
const resRecent = await fetch(`${baseUrl}/api/me/tasks?status=recent&limit=50`);
expect(resRecent.status).toBe(200);
const recentBody = await resRecent.json();
const items = Array.isArray(recentBody?.items) ? recentBody.items : [];
const found = items.find((it: any) => Number(it?.id) === taskId);
expect(found).toBeTruthy();
expect(found.completed).toBe(true);
expect(typeof found.completed_at === 'string' && found.completed_at.length > 0).toBe(true);
expect(Array.isArray(found.assignees)).toBe(true);
expect(found.assignees.map(String)).toContain(USER);
// Uncomplete inmediato permitido
const resUncomplete = await fetch(`${baseUrl}/api/tasks/${taskId}/uncomplete`, { method: 'POST' });
expect(resUncomplete.status).toBe(200);
const jsonUnc = await resUncomplete.json();
expect(jsonUnc?.status).toBe('updated');
// Ya no aparece en recientes tras reabrir
const resRecent2 = await fetch(`${baseUrl}/api/me/tasks?status=recent&limit=50`);
expect(resRecent2.status).toBe(200);
const recentBody2 = await resRecent2.json();
const items2 = Array.isArray(recentBody2?.items) ? recentBody2.items : [];
const found2 = items2.find((it: any) => Number(it?.id) === taskId);
expect(found2).toBeFalsy();
});
});

@ -0,0 +1,91 @@
import { describe, it, beforeAll, afterAll, expect } from 'bun:test';
import { createTempDb } from './helpers/db';
import { startWebServer, type RunningServer } from './helpers/server';
describe('API Web - carreras en complete con auto-asignación', () => {
const U1 = '34600123456';
const U2 = '34600123457';
const GROUP = 'g-3@g.us';
let dbHandle: any;
let cleanupDb: () => void;
let serverA: RunningServer;
let serverB: RunningServer;
let baseA: string;
let baseB: string;
let taskId: number;
beforeAll(async () => {
const tmp = createTempDb();
dbHandle = tmp.db;
cleanupDb = tmp.cleanup;
// Semilla mínima
dbHandle.prepare(`INSERT INTO users (id) VALUES (?) ON CONFLICT(id) DO NOTHING`).run(U1);
dbHandle.prepare(`INSERT INTO users (id) VALUES (?) ON CONFLICT(id) DO NOTHING`).run(U2);
dbHandle
.prepare(`INSERT INTO groups (id, community_id, name, active) VALUES (?, 'comm-1', 'G3', 1)`)
.run(GROUP);
dbHandle.prepare(`INSERT INTO allowed_groups (group_id, status) VALUES (?, 'allowed')`).run(GROUP);
dbHandle
.prepare(`INSERT INTO group_members (group_id, user_id, is_admin, is_active) VALUES (?, ?, 0, 1)`)
.run(GROUP, U1);
dbHandle
.prepare(`INSERT INTO group_members (group_id, user_id, is_admin, is_active) VALUES (?, ?, 0, 1)`)
.run(GROUP, U2);
const ins = dbHandle
.prepare(`INSERT INTO tasks (description, group_id, created_by) VALUES ('Carrera complete', ?, ?)`)
.run(GROUP, U1);
taskId = Number(ins.lastInsertRowid);
// Iniciar dos servidores contra la misma DB
serverA = await startWebServer({
port: 19111,
env: { DB_PATH: tmp.path, DEV_DEFAULT_USER: U1, TZ: 'UTC' }
});
baseA = serverA.baseUrl;
serverB = await startWebServer({
port: 19112,
env: { DB_PATH: tmp.path, DEV_DEFAULT_USER: U2, TZ: 'UTC' }
});
baseB = serverB.baseUrl;
});
afterAll(async () => {
try {
await serverA.stop();
} catch {}
try {
await serverB.stop();
} catch {}
try {
cleanupDb?.();
} catch {}
});
it('solo una actualización de completed; la asignación incluye al menos al completador', async () => {
const [r1, r2] = await Promise.all([
fetch(`${baseA}/api/tasks/${taskId}/complete`, { method: 'POST' }),
fetch(`${baseB}/api/tasks/${taskId}/complete`, { method: 'POST' })
]);
expect([200, 200]).toContain(r1.status);
expect([200, 200]).toContain(r2.status);
const row = dbHandle
.prepare(`SELECT COALESCE(completed,0) as completed, completed_by FROM tasks WHERE id = ?`)
.get(taskId) as any;
expect(Number(row?.completed || 0)).toBe(1);
const completedBy = String(row?.completed_by || '');
const assigns = dbHandle
.prepare(`SELECT user_id FROM task_assignments WHERE task_id = ? ORDER BY assigned_at ASC`)
.all(taskId) as any[];
// Debe existir al menos una asignación y contener al que completó
expect(assigns.length >= 1 && assigns.length <= 2).toBe(true);
const assignedUsers = assigns.map((r) => String(r.user_id));
expect([U1, U2]).toContain(completedBy);
expect(assignedUsers).toContain(completedBy);
});
});

@ -0,0 +1,67 @@
import { describe, it, beforeAll, afterAll, expect } from 'bun:test';
import { createTempDb } from './helpers/db';
import { startWebServer, type RunningServer } from './helpers/server';
describe('API Web - uncomplete dentro de ventana', () => {
const USER = '34600123456';
const GROUP = 'g-2@g.us';
let dbHandle: any;
let cleanupDb: () => void;
let server: RunningServer;
let baseUrl: string;
let taskId: number;
beforeAll(async () => {
const tmp = createTempDb();
dbHandle = tmp.db;
cleanupDb = tmp.cleanup;
// Semilla mínima
dbHandle.prepare(`INSERT INTO users (id) VALUES (?) ON CONFLICT(id) DO NOTHING`).run(USER);
dbHandle
.prepare(`INSERT INTO groups (id, community_id, name, active) VALUES (?, 'comm-1', 'G2', 1)`)
.run(GROUP);
dbHandle.prepare(`INSERT INTO allowed_groups (group_id, status) VALUES (?, 'allowed')`).run(GROUP);
dbHandle
.prepare(`INSERT INTO group_members (group_id, user_id, is_admin, is_active) VALUES (?, ?, 0, 1)`)
.run(GROUP, USER);
const ins = dbHandle
.prepare(`INSERT INTO tasks (description, group_id, created_by) VALUES ('Probar ventana', ?, ?)`)
.run(GROUP, USER);
taskId = Number(ins.lastInsertRowid);
server = await startWebServer({
port: 19102,
env: { DB_PATH: tmp.path, DEV_DEFAULT_USER: USER, TZ: 'UTC' }
});
baseUrl = server.baseUrl;
});
afterAll(async () => {
try {
await server.stop();
} catch {}
try {
cleanupDb?.();
} catch {}
});
it('uncomplete permitido inmediatamente después de completar y es idempotente', async () => {
// Completar
const resComplete = await fetch(`${baseUrl}/api/tasks/${taskId}/complete`, { method: 'POST' });
expect(resComplete.status).toBe(200);
// Uncomplete (dentro de ventana)
const resUncomplete = await fetch(`${baseUrl}/api/tasks/${taskId}/uncomplete`, { method: 'POST' });
expect(resUncomplete.status).toBe(200);
const jsonUnc = await resUncomplete.json();
expect(jsonUnc?.status).toBe('updated');
// Uncomplete de nuevo (idempotente → already)
const resUncomplete2 = await fetch(`${baseUrl}/api/tasks/${taskId}/uncomplete`, { method: 'POST' });
expect(resUncomplete2.status).toBe(200);
const jsonUnc2 = await resUncomplete2.json();
expect(jsonUnc2?.status).toBe('already');
});
});
Loading…
Cancel
Save