From af6c170115e5c14226fcee08273cf68ddcce7faf Mon Sep 17 00:00:00 2001 From: brobert Date: Sun, 19 Oct 2025 00:46:41 +0200 Subject: [PATCH] fix: ajusta countAllActive para excluir grupos archivados e inactivos Co-authored-by: aider (openrouter/openai/gpt-5) --- src/tasks/service.ts | 10 +- tests/unit/services/admin.test.ts | 63 ++++++++++- .../services/group-sync.scheduler.test.ts | 41 +++++++ tests/unit/tasks/service.list-active.test.ts | 22 ++++ tests/web/api.integrations.feeds.test.ts | 29 +++++ tests/web/api.me.tasks.test.ts | 107 ++++++++++++++++++ 6 files changed, 269 insertions(+), 3 deletions(-) diff --git a/src/tasks/service.ts b/src/tasks/service.ts index 81afe44..0aa5437 100644 --- a/src/tasks/service.ts +++ b/src/tasks/service.ts @@ -605,8 +605,14 @@ export class TaskService { const row = this.dbInstance .prepare(` SELECT COUNT(*) AS cnt - FROM tasks - WHERE COALESCE(completed, 0) = 0 AND completed_at IS NULL + FROM tasks t + WHERE COALESCE(t.completed, 0) = 0 AND t.completed_at IS NULL + AND (t.group_id IS NULL OR EXISTS ( + SELECT 1 FROM groups g2 + WHERE g2.id = t.group_id + AND COALESCE(g2.active,1)=1 + AND COALESCE(g2.archived,0)=0 + )) `) .get() as any; return Number(row?.cnt || 0); diff --git a/tests/unit/services/admin.test.ts b/tests/unit/services/admin.test.ts index e2a87b0..76a6612 100644 --- a/tests/unit/services/admin.test.ts +++ b/tests/unit/services/admin.test.ts @@ -5,12 +5,14 @@ import { AllowedGroups } from '../../../src/services/allowed-groups'; describe('AdminService - comandos básicos', () => { const envBackup = process.env; + let memdb: any; beforeEach(() => { process.env = { ...envBackup, NODE_ENV: 'test', ADMIN_USERS: '34600123456' }; - const memdb = makeMemDb(); + memdb = makeMemDb(); (AdminService as any).dbInstance = memdb; (AllowedGroups as any).dbInstance = memdb; + AllowedGroups.resetForTests(); }); it('rechaza a usuarios no admin', async () => { @@ -58,4 +60,63 @@ describe('AdminService - comandos básicos', () => { expect(out.length).toBe(1); expect(AllowedGroups.isAllowed('g2@g.us')).toBe(true); }); + + it('archivar-aquí: marca archived=1, active=0; revoca tokens; desactiva membresías; bloquea allowed_groups', async () => { + // Sembrar grupo, token, membresía y allowed + memdb.exec(`INSERT INTO groups (id, community_id, name, active, archived, last_verified) VALUES ('g1@g.us','comm-1','G1',1,0,strftime('%Y-%m-%d %H:%M:%f','now'))`); + memdb.exec(`INSERT INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('g1@g.us','allowed',strftime('%Y-%m-%d %H:%M:%f','now'),strftime('%Y-%m-%d %H:%M:%f','now'))`); + memdb.exec(`INSERT INTO users (id, first_seen, last_seen) VALUES ('34600123456',strftime('%Y-%m-%d %H:%M:%f','now'),strftime('%Y-%m-%d %H:%M:%f','now'))`); + memdb.exec(`INSERT INTO calendar_tokens (type, user_id, group_id, token_hash, created_at) VALUES ('group','34600123456','g1@g.us','h1',strftime('%Y-%m-%d %H:%M:%f','now'))`); + memdb.exec(`INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) VALUES ('g1@g.us','34600123456',0,1,strftime('%Y-%m-%d %H:%M:%f','now'),strftime('%Y-%m-%d %H:%M:%f','now'))`); + + const out = await AdminService.handle({ + sender: '34600123456', + groupId: 'g1@g.us', + message: '/admin archivar-aquí' + }); + expect(out.length).toBe(1); + + const g = memdb.query(`SELECT active, archived FROM groups WHERE id='g1@g.us'`).get() as any; + expect(Number(g.active)).toBe(0); + expect(Number(g.archived)).toBe(1); + + const tok = memdb.query(`SELECT revoked_at FROM calendar_tokens WHERE group_id='g1@g.us'`).get() as any; + expect(tok && tok.revoked_at).toBeTruthy(); + + const gm = memdb.query(`SELECT is_active FROM group_members WHERE group_id='g1@g.us'`).get() as any; + expect(Number(gm.is_active)).toBe(0); + + // allowed_groups bloqueado + const ag = memdb.query(`SELECT status FROM allowed_groups WHERE group_id='g1@g.us'`).get() as any; + expect(String(ag.status)).toBe('blocked'); + }); + + it('borrar-aquí: borra tasks, assignments, grupo y allowed_groups', async () => { + memdb.exec(`INSERT INTO groups (id, community_id, name, active) VALUES ('g2@g.us','comm-1','G2',1)`); + memdb.exec(`INSERT INTO allowed_groups (group_id, status, discovered_at, updated_at) VALUES ('g2@g.us','allowed',strftime('%Y-%m-%d %H:%M:%f','now'),strftime('%Y-%m-%d %H:%M:%f','now'))`); + memdb.exec(`INSERT INTO users (id, first_seen, last_seen) VALUES ('34600123456',strftime('%Y-%m-%d %H:%M:%f','now'),strftime('%Y-%m-%d %H:%M:%f','now'))`); + const r1 = memdb.query(`INSERT INTO tasks (description, group_id, created_by) VALUES ('t1','g2@g.us','34600123456') RETURNING id`).get() as any; + const r2 = memdb.query(`INSERT INTO tasks (description, group_id, created_by) VALUES ('t2','g2@g.us','34600123456') RETURNING id`).get() as any; + memdb.exec(`INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (${Number(r1.id)}, '34600123456', '34600123456')`); + memdb.exec(`INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (${Number(r2.id)}, '34600123456', '34600123456')`); + + const out = await AdminService.handle({ + sender: '34600123456', + groupId: 'g2@g.us', + message: '/admin borrar-aquí' + }); + expect(out.length).toBe(1); + + const tcount = memdb.query(`SELECT COUNT(*) AS c FROM tasks WHERE group_id='g2@g.us'`).get() as any; + expect(Number(tcount.c)).toBe(0); + + const g = memdb.query(`SELECT 1 FROM groups WHERE id='g2@g.us'`).get() as any; + expect(g).toBeUndefined(); + + const ag = memdb.query(`SELECT 1 FROM allowed_groups WHERE group_id='g2@g.us'`).get() as any; + expect(ag).toBeUndefined(); + + const acount = memdb.query(`SELECT COUNT(*) AS c FROM task_assignments`).get() as any; + expect(Number(acount.c)).toBe(0); + }); }); diff --git a/tests/unit/services/group-sync.scheduler.test.ts b/tests/unit/services/group-sync.scheduler.test.ts index 0363a7c..76619ae 100644 --- a/tests/unit/services/group-sync.scheduler.test.ts +++ b/tests/unit/services/group-sync.scheduler.test.ts @@ -1,5 +1,8 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import Database from 'bun:sqlite'; +import { initializeDatabase, ensureUserExists } from '../../../src/db'; import { GroupSyncService } from '../../../src/services/group-sync'; +import { ResponseQueue } from '../../../src/services/response-queue'; const envBackup = { ...process.env }; let originalSyncMembers: any; @@ -81,4 +84,42 @@ describe('GroupSyncService - scheduler de miembros', () => { GroupSyncService.stopGroupsScheduler(); expect(called).toBeGreaterThanOrEqual(1); }); + + test('al desactivarse un grupo en sync: revoca tokens, desactiva membresía y notifica admins', async () => { + process.env = { ...envBackup, NODE_ENV: 'development', ADMIN_USERS: '34600123456' }; + + const memdb = new Database(':memory:'); + initializeDatabase(memdb); + (GroupSyncService as any).dbInstance = memdb; + (ResponseQueue as any).dbInstance = memdb; + + // Sembrar grupo activo con miembro y token de calendario + memdb.exec(`INSERT INTO groups (id, community_id, name, active, archived, last_verified) VALUES ('g1@g.us','comm-1','G1',1,0,strftime('%Y-%m-%d %H:%M:%f','now'))`); + const uid = ensureUserExists('34600123456', memdb)!; + memdb.exec(`INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) VALUES ('g1@g.us','${uid}',0,1,strftime('%Y-%m-%d %H:%M:%f','now'),strftime('%Y-%m-%d %H:%M:%f','now'))`); + memdb.exec(`INSERT INTO calendar_tokens (type, user_id, group_id, token_hash, created_at) VALUES ('group','${uid}','g1@g.us','h1',strftime('%Y-%m-%d %H:%M:%f','now'))`); + + // Stub: API devuelve 0 grupos → el existente pasa a inactivo + const originalFetch = (GroupSyncService as any).fetchGroupsFromAPI; + (GroupSyncService as any).fetchGroupsFromAPI = async () => []; + + try { + await GroupSyncService.syncGroups(true); + } finally { + // Restaurar stub + (GroupSyncService as any).fetchGroupsFromAPI = originalFetch; + } + + // Tokens revocados + const tok = memdb.query(`SELECT revoked_at FROM calendar_tokens WHERE group_id='g1@g.us'`).get() as any; + expect(tok && tok.revoked_at).toBeTruthy(); + + // Membresías desactivadas + const mem = memdb.query(`SELECT is_active FROM group_members WHERE group_id='g1@g.us'`).get() as any; + expect(Number(mem?.is_active || 0)).toBe(0); + + // Notificación encolada a admins + const msg = memdb.query(`SELECT message FROM response_queue ORDER BY id DESC LIMIT 1`).get() as any; + expect(msg && String(msg.message)).toContain('/admin archivar-grupo g1@g.us'); + }); }); diff --git a/tests/unit/tasks/service.list-active.test.ts b/tests/unit/tasks/service.list-active.test.ts index c357d6b..396ff85 100644 --- a/tests/unit/tasks/service.list-active.test.ts +++ b/tests/unit/tasks/service.list-active.test.ts @@ -60,4 +60,26 @@ describe('TaskService - listAllActive', () => { const rows = TaskService.listAllActive(2); expect(rows.length).toBe(2); }); + + it('excluye tareas de grupos archivados o inactivos en listAllActive y countAllActive', () => { + seedGroup('g1@g.us', 'G1'); + seedGroup('g2@g.us', 'G2'); + + const c = '34600123456'; + createTask('G1 A', '2025-11-01', 'g1@g.us', c); + createTask('G2 A', '2025-11-02', 'g2@g.us', c); + + // Archivar g1 -> solo debe aparecer G2 A + memdb.prepare(`UPDATE groups SET archived = 1 WHERE id = ?`).run('g1@g.us'); + let rows = TaskService.listAllActive(10); + expect(rows.map(r => r.description)).toEqual(['G2 A']); + expect(TaskService.countAllActive()).toBe(1); + + // Reactivar g1 y desactivar g2 -> solo debe aparecer G1 A + memdb.prepare(`UPDATE groups SET archived = 0 WHERE id = ?`).run('g1@g.us'); + memdb.prepare(`UPDATE groups SET active = 0 WHERE id = ?`).run('g2@g.us'); + rows = TaskService.listAllActive(10); + expect(rows.map(r => r.description)).toEqual(['G1 A']); + expect(TaskService.countAllActive()).toBe(1); + }); }); diff --git a/tests/web/api.integrations.feeds.test.ts b/tests/web/api.integrations.feeds.test.ts index 0d48fb4..a3dc156 100644 --- a/tests/web/api.integrations.feeds.test.ts +++ b/tests/web/api.integrations.feeds.test.ts @@ -92,4 +92,33 @@ describe('API - /api/integrations/feeds', () => { expect(typeof bodyRotate.url === 'string' && bodyRotate.url.endsWith('.ics')).toBe(true); expect(bodyRotate.url).not.toBe(previousGroupUrl); }); + + it('el feed de grupo devuelve 410 cuando el grupo está archivado y 200 cuando está activo', async () => { + server = await serverPromise; + + const sidHash = await sidHashPromise; + // Asegurar sesión + db.exec(` + INSERT OR REPLACE INTO web_sessions (id, user_id, session_hash, created_at, last_seen_at, expires_at) + VALUES ('sess-2', '${USER}', '${sidHash}', '${toIsoSql()}', '${toIsoSql()}', '${toIsoSql(new Date(Date.now() + 2 * 60 * 60 * 1000))}') + `); + + // Obtener URL de grupo + const res = await fetch(`${BASE}/api/integrations/feeds`, { + headers: { cookie: `sid=${SID}` } + }); + expect(res.status).toBe(200); + const body = await res.json(); + const groupFeed = (body.groups || []).find((g: any) => g.groupId === GROUP); + expect(groupFeed).toBeDefined(); + + // Activo: debe devolver 200 + const ok1 = await fetch(groupFeed.url); + expect(ok1.status).toBe(200); + + // Archivar grupo y verificar 410 + db.exec(`UPDATE groups SET archived = 1 WHERE id = '${GROUP}'`); + const gone = await fetch(groupFeed.url); + expect(gone.status).toBe(410); + }); }); diff --git a/tests/web/api.me.tasks.test.ts b/tests/web/api.me.tasks.test.ts index c64050c..a36b405 100644 --- a/tests/web/api.me.tasks.test.ts +++ b/tests/web/api.me.tasks.test.ts @@ -227,4 +227,111 @@ describe('Web API - GET /api/me/tasks', () => { const ids = recent.items.map((it: any) => it.id); expect(ids.includes(taskId)).toBe(true); }); + + it('oculta tareas de grupo archivado aunque esté asignada y allowed', async () => { + // Abrir la misma DB del servidor para sembrar datos adicionales + const db2 = new Database(dbPath); + const nowIso = toIsoSql(new Date()); + + // Grupo activo inicialmente + db2.prepare(` + INSERT INTO groups (id, community_id, name, active, last_verified, archived) + VALUES (?, 'comm-1', 'Group ARCH', 1, ?, 0) + `).run('arch@g.us', nowIso); + db2.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, status, updated_at) VALUES ('arch@g.us','allowed',?)`).run(nowIso); + db2.prepare(` + INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) + VALUES ('arch@g.us', ?, 0, 1, ?, ?) + `).run(USER, nowIso, nowIso); + + // Tarea asignada al usuario en ese grupo + const r = db2.prepare(` + INSERT INTO tasks (description, due_date, group_id, created_by, display_code) + VALUES ('delta archivada', '2025-03-01', 'arch@g.us', ?, 150) + `).run(USER) as any; + const tid = Number(r.lastInsertRowid); + db2.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?)`).run(tid, USER, USER); + + // Archivar el grupo + db2.prepare(`UPDATE groups SET archived = 1 WHERE id = 'arch@g.us'`).run(); + + db2.close(); + + const q = encodeURIComponent('delta'); + const res = await fetch(`${server!.baseUrl}/api/me/tasks?limit=50&search=${q}`, { + headers: { Cookie: `sid=${sid}` } + }); + expect(res.status).toBe(200); + const json = await res.json(); + const descs = json.items.map((it: any) => String(it.description)); + expect(descs.some((d: string) => d.includes('delta archivada'))).toBe(false); + }); + + it('oculta tareas de grupo inactivo (active=0) aunque esté asignada y allowed', async () => { + const db2 = new Database(dbPath); + const nowIso = toIsoSql(new Date()); + + db2.prepare(` + INSERT INTO groups (id, community_id, name, active, last_verified, archived) + VALUES ('inact@g.us', 'comm-1', 'Group INACT', 1, ?, 0) + `).run(nowIso); + db2.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, status, updated_at) VALUES ('inact@g.us','allowed',?)`).run(nowIso); + db2.prepare(` + INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) + VALUES ('inact@g.us', ?, 0, 1, ?, ?) + `).run(USER, nowIso, nowIso); + + const r = db2.prepare(` + INSERT INTO tasks (description, due_date, group_id, created_by, display_code) + VALUES ('omega inactiva', '2025-04-01', 'inact@g.us', ?, 151) + `).run(USER) as any; + const tid = Number(r.lastInsertRowid); + db2.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?)`).run(tid, USER, USER); + + // Desactivar el grupo + db2.prepare(`UPDATE groups SET active = 0 WHERE id = 'inact@g.us'`).run(); + db2.close(); + + const q = encodeURIComponent('omega inactiva'); + const res = await fetch(`${server!.baseUrl}/api/me/tasks?limit=50&search=${q}`, { + headers: { Cookie: `sid=${sid}` } + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.items.length).toBe(0); + }); + + it('recent no muestra tareas completadas de grupos archivados', async () => { + const db2 = new Database(dbPath); + const nowIso = toIsoSql(new Date()); + + db2.prepare(` + INSERT INTO groups (id, community_id, name, active, last_verified, archived) + VALUES ('rec@g.us', 'comm-1', 'Group REC', 1, ?, 0) + `).run(nowIso); + db2.prepare(`INSERT OR REPLACE INTO allowed_groups (group_id, status, updated_at) VALUES ('rec@g.us','allowed',?)`).run(nowIso); + db2.prepare(` + INSERT INTO group_members (group_id, user_id, is_admin, is_active, first_seen_at, last_seen_at) + VALUES ('rec@g.us', ?, 0, 1, ?, ?) + `).run(USER, nowIso, nowIso); + + const r = db2.prepare(` + INSERT INTO tasks (description, due_date, group_id, created_by, completed, completed_at, display_code) + VALUES ('epsilon reciente', '2025-05-01', 'rec@g.us', ?, 1, ?, 152) + `).run(USER, nowIso) as any; + const tid = Number(r.lastInsertRowid); + db2.prepare(`INSERT INTO task_assignments (task_id, user_id, assigned_by) VALUES (?, ?, ?)`).run(tid, USER, USER); + + // Archivar el grupo + db2.prepare(`UPDATE groups SET archived = 1 WHERE id = 'rec@g.us'`).run(); + db2.close(); + + const q = encodeURIComponent('epsilon reciente'); + const res = await fetch(`${server!.baseUrl}/api/me/tasks?status=recent&limit=50&search=${q}`, { + headers: { Cookie: `sid=${sid}` } + }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.items.length).toBe(0); + }); });