/** * Sender canonicalization regression tests. * * Verifies that when a message arrives in a group with only * participant (LID-based) and no participantAlt, the sender is * resolved through the alias table to the canonical real * WhatsApp number. * * This prevents "no la tenías asignada" errors when a user * tries to soltar a task assigned to their real number but * their sender is resolved as a LID-based ID. */ import { describe, test, expect } from 'bun:test'; import { WebhookServer } from '../../src/server'; import { TaskService } from '../../src/tasks/service'; import { IdentityService } from '../../src/services/identity'; import { GroupSyncService } from '../../src/services/group-sync'; import { SimulatedResponseQueue } from '../helpers/queue'; import { createTestRequest, registerServerTestLifecycle } from '../helpers/server-test-harness'; import { ensureUserExists } from '../../src/db'; const testDb = registerServerTestLifecycle(); // ── Helpers ──────────────────────────────────────────────────────────── /** Create a task with given assignments. Returns the display_code. */ function createTaskWithAssignees(opts: { description: string; groupId?: string; creator: string; assignees: string[]; dueDate?: string; }): { id: number; displayCode: number } { const creator = ensureUserExists(opts.creator, testDb)!; const assigneeIds = opts.assignees .map(uid => ensureUserExists(uid, testDb)!) .filter(Boolean); const taskId = TaskService.createTask( { description: opts.description, due_date: opts.dueDate ?? '2026-12-31', group_id: opts.groupId || null, created_by: creator, }, assigneeIds.map(uid => ({ user_id: uid, assigned_by: creator })), ); const row = testDb .prepare('SELECT display_code FROM tasks WHERE id = ?') .get(taskId) as { display_code: number } | undefined; const dc = row?.display_code ?? 1; return { id: taskId, displayCode: dc }; } /** Code-pad a display_code for use in command messages. */ function code4(n: number): string { return String(n).padStart(4, '0'); } /** Seed a LID → real-number alias in the identity system. */ function seedAlias(lidId: string, realNumber: string): void { IdentityService.upsertAlias(lidId, `${realNumber}@s.whatsapp.net`, 'test-seed'); } // ── Tests ────────────────────────────────────────────────────────────── describe('Sender canonicalization via alias table', () => { test('soltar succeeds when sender is LID-based and assignment uses real number', async () => { // 1) Set up alias: LID → real number seedAlias('userXYZ@lid', '666777888'); // 2) Create a task assigned to the real number, with a group_id // so soltar actually unassigns (single-assignee + no-group = forbidden) const { displayCode } = createTaskWithAssignees({ description: 'Tarea de prueba', creator: '2222222222', assignees: ['666777888'], groupId: 'group-id@g.us', }); // 3) User sends t soltar from a group, with ONLY participant (LID), // NO participantAlt — simulating the fallback path that triggers // the canonicalization code. const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: 'userXYZ@lid', // intentionally omit participantAlt }, message: { conversation: `t soltar ${displayCode}` }, }, }; const response = await WebhookServer.handleRequest(createTestRequest(payload)); expect(response.status).toBe(200); const out = SimulatedResponseQueue.get(); const msg = out.map(r => r.message).join('\n'); // Without canonicalization, the sender would be 'userXYZ' (LID) and // soltar would fail with "no la tenías asignada" because the assignment // uses '666777888' (real number). With canonicalization, the sender is // resolved to '666777888' and soltar succeeds. expect(msg).toContain('queda sin responsable'); }); test('soltar via DM with real sender works end-to-end', async () => { // 1) Create alias and task seedAlias('userXYZ@lid', '666777888'); const { displayCode } = createTaskWithAssignees({ description: 'Tarea DM test', creator: '2222222222', assignees: ['666777888'], }); // 2) Send from DM — remoteJid is the user's JID, no LID ambiguity const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: '666777888@s.whatsapp.net', }, message: { conversation: `t soltar ${displayCode}` }, }, }; const response = await WebhookServer.handleRequest(createTestRequest(payload)); expect(response.status).toBe(200); const out = SimulatedResponseQueue.get(); const msg = out.map(r => r.message).join('\n'); // The soltar should succeed — task is personal (single assignee, no group) // so it shows "No puedes soltar una tarea personal" // OR if created with a group_id, it would show the unassign success message expect(msg).toMatch(/No puedes soltar|Has soltado|queda sin responsable/); }); test('canonicalization does not alter already-canonical sender', async () => { // When participantAlt (real number) is present, resolveSenderJid uses it // directly. The canonicalization resolves the alias table but finds no // mapping (real→real is the identity), so it's a no-op. const { displayCode } = createTaskWithAssignees({ description: 'Already canonical', creator: '2222222222', assignees: ['666777888'], groupId: 'group-id@g.us', }); const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: 'userXYZ@lid', participantAlt: '666777888@s.whatsapp.net', }, message: { conversation: `t soltar ${displayCode}` }, }, }; const response = await WebhookServer.handleRequest(createTestRequest(payload)); expect(response.status).toBe(200); const out = SimulatedResponseQueue.get(); const msg = out.map(r => r.message).join('\n'); // Soltar succeeds because participantAlt provides the canonical real number. // The key thing: it didn't crash or produce a wrong-identity error. expect(msg).toContain('queda sin responsable'); }); test('soltar without alias mapping falls through to raw sender', async () => { // No alias seeded — sender stays as-is (whatever normalizeWhatsAppId produces). // This tests the non-regression path: the canonicalization gracefully // returns null and the raw sender is used unchanged. const { displayCode } = createTaskWithAssignees({ description: 'No alias task', creator: '2222222222', assignees: ['666777888'], groupId: 'group-id@g.us', }); // A different user (LID 'otherLID', no alias) tries to soltar. // Without alias mapping, sender stays as 'otherLID'. const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: 'group-id@g.us', participant: 'otherLID@lid', // no participantAlt }, message: { conversation: `t soltar ${displayCode}` }, }, }; const response = await WebhookServer.handleRequest(createTestRequest(payload)); expect(response.status).toBe(200); const out = SimulatedResponseQueue.get(); const msg = out.map(r => r.message).join('\n'); // "no la tenías asignada" because 'otherLID' != '666777888'. // This confirms the canonicalization gracefully handles missing aliases. expect(msg).toContain('no la tenías asignada'); }); test('t todas resolves unassigned tasks with canonicalized sender', async () => { // 1) Seed alias LID → real number seedAlias('userXYZ@lid', '666777888'); // 2) Add user as active member of a group testDb.exec(` INSERT OR IGNORE INTO groups (id, name, active, community_id) VALUES ('another-group@g.us', 'Another Group', 1, '') `); ensureUserExists('666777888', testDb); testDb.exec(` INSERT OR IGNORE INTO group_members (group_id, user_id, is_active, is_admin) VALUES ('another-group@g.us', '666777888', 1, 0) `); // 3) Refresh the active groups cache (GroupSyncService as any).cacheActiveGroups(); // 4) Create two unassigned tasks in that group TaskService.createTask( { description: 'Sin dueño A', due_date: '2026-12-31', group_id: 'another-group@g.us', created_by: '2222222222' }, [], ); TaskService.createTask( { description: 'Sin dueño B', due_date: '2026-12-31', group_id: 'another-group@g.us', created_by: '2222222222' }, [], ); // 5) Also create a task assigned to the user createTaskWithAssignees({ description: 'Mi tarea', creator: '2222222222', assignees: ['666777888'], groupId: 'another-group@g.us', }); // 6) Send t todas from DM with canonical (real) sender const payload = { event: 'messages.upsert', instance: 'test-instance', data: { key: { remoteJid: '666777888@s.whatsapp.net', }, message: { conversation: 't todas' }, }, }; const response = await WebhookServer.handleRequest(createTestRequest(payload)); expect(response.status).toBe(200); const out = SimulatedResponseQueue.get(); const msg = out.map(r => r.message).join('\n'); // Should show "Tus tareas" section with the assigned task expect(msg).toContain('Tus tareas'); expect(msg).toContain('Mi tarea'); // Should show the "Sin responsable" section for the other group expect(msg).toContain('Sin responsable'); expect(msg).toContain('Sin dueño A'); expect(msg).toContain('Sin dueño B'); }); });