resuelve un problema que hacía que no se pudiesen soltar tareas para algunos usuarios y crea tests que vigilan esa posible regresión
parent
fcaafc4600
commit
600bb46b09
@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue