diff --git a/docs/plan-web-fases.md b/docs/plan-web-fases.md index 58d46cd..a2cae65 100644 --- a/docs/plan-web-fases.md +++ b/docs/plan-web-fases.md @@ -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. diff --git a/tests/web/api.tasks.complete-autoassign-recent.test.ts b/tests/web/api.tasks.complete-autoassign-recent.test.ts new file mode 100644 index 0000000..3830fad --- /dev/null +++ b/tests/web/api.tasks.complete-autoassign-recent.test.ts @@ -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(); + }); +}); diff --git a/tests/web/api.tasks.complete-concurrency.test.ts b/tests/web/api.tasks.complete-concurrency.test.ts new file mode 100644 index 0000000..16b04aa --- /dev/null +++ b/tests/web/api.tasks.complete-concurrency.test.ts @@ -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); + }); +}); diff --git a/tests/web/api.tasks.uncomplete-window.test.ts b/tests/web/api.tasks.uncomplete-window.test.ts new file mode 100644 index 0000000..d024448 --- /dev/null +++ b/tests/web/api.tasks.uncomplete-window.test.ts @@ -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'); + }); +});