wacrm/src/lib/whatsapp/resolve-conversation.test.ts
Arnas Donauskas faf0f740a4
fix: stop inbound chats fragmenting into duplicate conversations (#363)
An inbound message could create a second conversation for a contact
under a race (Meta retries a delivery, or a batch fans out to concurrent
after() runs). There was no UNIQUE index and no unique-violation
backstop on conversations, unlike contacts (migration 022). Worse, once
two conversations existed the `.single()` lookup errored on every later
message and the webhook created yet another conversation each time,
snowballing into a wall of duplicate chats.

- findOrCreateConversation now resolves oldest-first with .limit(1)
  instead of .single(), so pre-existing duplicates converge, and catches
  23505 on insert to re-resolve the winning row.
- Same hardening applied to the public-API resolver (resolve-conversation).
- Migration 036 merges existing duplicate conversations into the oldest
  thread (re-pointing messages, reactions, deals, flow_runs,
  notifications, ai_usage_log — no data loss) then adds a
  UNIQUE (account_id, contact_id) index as the authoritative guarantee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:27:46 +03:00

211 lines
7.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { SupabaseClient } from '@supabase/supabase-js';
import { resolveConversationByPhone } from './resolve-conversation';
import { SendMessageError } from './send-message';
// ------------------------------------------------------------
// Chainable Supabase stub, scripted per table. Terminal methods
// (like/maybeSingle/single) resolve to configured data; the builder
// itself is thenable so an awaited `update().eq()` resolves cleanly.
// ------------------------------------------------------------
type ContactRow = { id: string; phone: string; name?: string | null };
interface Script {
config?: { user_id: string } | null; // whatsapp_config.maybeSingle
contactCandidates?: ContactRow[]; // contacts .like (same every call)
/** Per-call `.like` results — overrides contactCandidates. Lets a
* test simulate "miss, then hit" for the unique-race path. */
contactCandidatesByCall?: ContactRow[][];
insertedContactId?: string; // contacts insert -> single
insertContactError?: { code?: string } | null;
/** Conversation lookup result (oldest-first `.order().limit(1)`).
* A single row or null; wrapped into a one-element array internally. */
existingConversation?: { id: string } | null; // conversations select.limit(1)
/** Per-call conversation lookup results — overrides existingConversation.
* Lets a test simulate "miss, then hit" for the unique-race path. */
existingConversationByCall?: (({ id: string } | null))[];
insertedConversationId?: string; // conversations insert -> single
insertConversationError?: { code?: string } | null;
}
function makeDb(script: Script): SupabaseClient {
let table = '';
let mode: 'select' | 'insert' | 'update' = 'select';
let likeCalls = 0;
let convLookupCalls = 0;
const builder: Record<string, unknown> = {
select: () => builder,
insert: () => {
mode = 'insert';
return builder;
},
update: () => {
mode = 'update';
return builder;
},
eq: () => builder,
order: () => builder,
limit: () => {
// Only the conversation lookup terminates on `.limit(1)`.
if (table === 'conversations' && mode === 'select') {
const row = script.existingConversationByCall
? (script.existingConversationByCall[convLookupCalls] ?? null)
: (script.existingConversation ?? null);
convLookupCalls++;
return Promise.resolve({ data: row ? [row] : [], error: null });
}
return Promise.resolve({ data: [], error: null });
},
like: () => {
const data = script.contactCandidatesByCall
? (script.contactCandidatesByCall[likeCalls] ?? [])
: (script.contactCandidates ?? []);
likeCalls++;
return Promise.resolve({ data, error: null });
},
maybeSingle: () => {
if (table === 'whatsapp_config')
return Promise.resolve({ data: script.config ?? null, error: null });
return Promise.resolve({ data: null, error: null });
},
single: () => {
if (table === 'contacts' && mode === 'insert') {
if (script.insertContactError)
return Promise.resolve({
data: null,
error: script.insertContactError,
});
return Promise.resolve({
data: { id: script.insertedContactId },
error: null,
});
}
if (table === 'conversations' && mode === 'insert') {
if (script.insertConversationError)
return Promise.resolve({
data: null,
error: script.insertConversationError,
});
return Promise.resolve({
data: { id: script.insertedConversationId },
error: null,
});
}
return Promise.resolve({ data: null, error: null });
},
// Thenable: `await db.from().update().eq()` lands here.
then: (resolve: (v: { data: null; error: null }) => void) =>
resolve({ data: null, error: null }),
};
return {
from: (t: string) => {
table = t;
mode = 'select';
return builder;
},
} as unknown as SupabaseClient;
}
describe('resolveConversationByPhone', () => {
it('rejects an invalid phone before any DB call', async () => {
const db = {
from() {
throw new Error('should not query');
},
} as unknown as SupabaseClient;
await expect(
resolveConversationByPhone(db, 'acct', 'not-a-phone')
).rejects.toBeInstanceOf(SendMessageError);
});
it('fails with whatsapp_not_configured when no config owner exists', async () => {
const db = makeDb({ config: null });
await resolveConversationByPhone(db, 'acct', '+14155550123').catch(
(e: SendMessageError) => {
expect(e.code).toBe('whatsapp_not_configured');
expect(e.status).toBe(400);
}
);
await expect(
resolveConversationByPhone(db, 'acct', '+14155550123')
).rejects.toBeInstanceOf(SendMessageError);
});
it('returns the existing contact + conversation without creating', async () => {
const db = makeDb({
config: { user_id: 'owner-1' },
contactCandidates: [{ id: 'c1', phone: '14155550123' }],
existingConversation: { id: 'cv1' },
});
const res = await resolveConversationByPhone(
db,
'acct',
'+1 (415) 555-0123'
);
expect(res).toEqual({
conversationId: 'cv1',
contactId: 'c1',
contactCreated: false,
});
});
it('creates contact + conversation when none exist', async () => {
const db = makeDb({
config: { user_id: 'owner-1' },
contactCandidates: [],
insertedContactId: 'c2',
existingConversation: null,
insertedConversationId: 'cv2',
});
const res = await resolveConversationByPhone(
db,
'acct',
'+14155550199',
'Jane'
);
expect(res).toEqual({
conversationId: 'cv2',
contactId: 'c2',
contactCreated: true,
});
});
it('re-resolves an existing contact when the insert loses a unique race', async () => {
// First lookup misses (→ we attempt an insert), the insert hits a
// 23505 unique violation, and the post-race re-lookup now returns
// the row a concurrent writer created.
const db = makeDb({
config: { user_id: 'owner-1' },
contactCandidatesByCall: [[], [{ id: 'c-raced', phone: '14155550123' }]],
insertContactError: { code: '23505' },
existingConversation: { id: 'cv-raced' },
});
const res = await resolveConversationByPhone(db, 'acct', '+14155550123');
expect(res.contactId).toBe('c-raced');
expect(res.contactCreated).toBe(false);
expect(res.conversationId).toBe('cv-raced');
});
it('re-resolves the conversation when the insert loses a unique race', async () => {
// Existing contact, conversation lookup misses first (→ attempt an
// insert), the insert hits a 23505 from a concurrent create, and the
// post-race re-lookup returns the winning conversation — no duplicate
// conversation is created (issue #363).
const db = makeDb({
config: { user_id: 'owner-1' },
contactCandidates: [{ id: 'c1', phone: '14155550123' }],
existingConversationByCall: [null, { id: 'cv-raced' }],
insertConversationError: { code: '23505' },
});
const res = await resolveConversationByPhone(db, 'acct', '+14155550123');
expect(res).toEqual({
conversationId: 'cv-raced',
contactId: 'c1',
contactCreated: false,
});
});
});