mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Complete the public API roadmap on top of the existing key-auth layer (#290). Re-lands POST /api/v1/messages (lost when the stacked #291 never reached main) by extracting the dashboard send route's core into `sendMessageToConversation`, then adds: - Contacts: GET/POST /api/v1/contacts, GET/PATCH /api/v1/contacts/{id} - Conversations: GET list + /{id} + /{id}/messages - Broadcasts: POST /api/v1/broadcasts + GET /{id} (persist rows, fan out in after(), poll for status) All list endpoints share one keyset-pagination contract ({ data, meta: { next_cursor } }). No migration required — scopes and tables already exist. Versioned as 0.3.0 (folded into [Unreleased]); docs/public-api.md updated; outbound webhooks left as the sole roadmap item. API stays v1 (additive). Includes fixes from an adversarial code review: cursor validation against filter injection, broadcast counts deferred to the DB aggregate trigger, terminal broadcast status reflects failures, duplicate-phone de-dup, up-front send-param validation (no orphan conversations), PATCH null-clear semantics, error-checked tag sync, bounded tag filter, unified audit-user attribution, and a maxDuration on the broadcast fan-out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
262 lines
8.1 KiB
TypeScript
262 lines
8.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests for the `contact_id` send path (issue #296): sending an approved
|
|
// template to a single contact from the Contact detail view. The route must
|
|
// find-or-create the contact's conversation server-side, then run the normal
|
|
// send + persistence path — no inbound message required to bootstrap a thread.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Records of what the route wrote, so we can assert the right rows landed.
|
|
const conversationInserts: Array<Record<string, unknown>> = []
|
|
const messageInserts: Array<Record<string, unknown>> = []
|
|
|
|
// Toggles for the per-test scenario.
|
|
let existingConversation: Record<string, unknown> | null = null
|
|
let contactRow: Record<string, unknown> | null = null
|
|
// A conversation created during the request becomes retrievable by id —
|
|
// the shared send core re-loads the conversation (with its contact) from
|
|
// just the id, so the mock must model insert-then-select-by-id.
|
|
let createdConversation: Record<string, unknown> | null = null
|
|
|
|
const CONTACT = {
|
|
id: 'contact-1',
|
|
account_id: 'acct-1',
|
|
phone: '+15551234567',
|
|
}
|
|
|
|
// Chainable Supabase mock. A fresh builder per `.from()` call tracks whether
|
|
// `.insert()` ran so the terminal resolves to the inserted row for creates
|
|
// and the canned select row otherwise.
|
|
function makeSupabaseMock() {
|
|
function builder(table: string) {
|
|
let didInsert = false
|
|
|
|
const selectResult = () => {
|
|
switch (table) {
|
|
case 'profiles':
|
|
return { data: { account_id: 'acct-1' }, error: null }
|
|
case 'contacts':
|
|
return { data: contactRow, error: null }
|
|
case 'conversations':
|
|
// Once created this request, a by-id reload returns it (with
|
|
// its contact); otherwise fall back to the canned existing row.
|
|
return { data: createdConversation ?? existingConversation, error: null }
|
|
case 'whatsapp_config':
|
|
return {
|
|
data: {
|
|
id: 'cfg-1',
|
|
account_id: 'acct-1',
|
|
phone_number_id: 'PNID-1',
|
|
access_token: 'enc-token',
|
|
},
|
|
error: null,
|
|
}
|
|
case 'message_templates':
|
|
return { data: null, error: null }
|
|
default:
|
|
return { data: null, error: null }
|
|
}
|
|
}
|
|
|
|
const insertResult = () => {
|
|
switch (table) {
|
|
case 'conversations':
|
|
return {
|
|
data: {
|
|
id: 'conv-new',
|
|
account_id: 'acct-1',
|
|
contact_id: 'contact-1',
|
|
contact: CONTACT,
|
|
},
|
|
error: null,
|
|
}
|
|
case 'messages':
|
|
return { data: { id: 'msg-1' }, error: null }
|
|
default:
|
|
return { data: null, error: null }
|
|
}
|
|
}
|
|
|
|
const terminal = () =>
|
|
Promise.resolve(didInsert ? insertResult() : selectResult())
|
|
|
|
const b: Record<string, unknown> = {}
|
|
const chain = () => b
|
|
for (const m of ['select', 'eq', 'in', 'order', 'limit', 'update', 'delete']) {
|
|
b[m] = vi.fn(chain)
|
|
}
|
|
b.insert = vi.fn((payload: Record<string, unknown>) => {
|
|
didInsert = true
|
|
if (table === 'conversations') {
|
|
conversationInserts.push(payload)
|
|
createdConversation = {
|
|
id: 'conv-new',
|
|
account_id: 'acct-1',
|
|
contact_id: 'contact-1',
|
|
contact: CONTACT,
|
|
}
|
|
}
|
|
if (table === 'messages') messageInserts.push(payload)
|
|
return b
|
|
})
|
|
b.single = vi.fn(terminal)
|
|
b.maybeSingle = vi.fn(terminal)
|
|
b.then = (resolve: (v: unknown) => unknown) =>
|
|
resolve(didInsert ? insertResult() : selectResult())
|
|
return b
|
|
}
|
|
|
|
return {
|
|
auth: {
|
|
getUser: vi.fn(async () => ({
|
|
data: { user: { id: 'user-1' } },
|
|
error: null,
|
|
})),
|
|
},
|
|
from: vi.fn((table: string) => builder(table)),
|
|
}
|
|
}
|
|
|
|
let supabaseMock = makeSupabaseMock()
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: vi.fn(async () => supabaseMock),
|
|
}))
|
|
|
|
vi.mock('@/lib/flows/admin-client', () => ({
|
|
supabaseAdmin: () => ({
|
|
from: () => {
|
|
const b: Record<string, unknown> = {}
|
|
const chain = () => b
|
|
for (const m of ['update', 'eq', 'select']) b[m] = vi.fn(chain)
|
|
b.then = (resolve: (v: unknown) => unknown) =>
|
|
resolve({ data: null, error: null })
|
|
return b
|
|
},
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/whatsapp/encryption', () => ({
|
|
decrypt: vi.fn(() => 'plaintext-token'),
|
|
encrypt: vi.fn(() => 'enc-token'),
|
|
isLegacyFormat: vi.fn(() => false),
|
|
}))
|
|
|
|
const { sendTemplateMessage } = vi.hoisted(() => ({
|
|
sendTemplateMessage: vi.fn(async () => ({ messageId: 'wamid-1' })),
|
|
}))
|
|
vi.mock('@/lib/whatsapp/meta-api', () => ({
|
|
sendTemplateMessage,
|
|
sendTextMessage: vi.fn(),
|
|
sendMediaMessage: vi.fn(),
|
|
}))
|
|
|
|
import { POST } from './route'
|
|
|
|
function postContactTemplate(overrides: Record<string, unknown> = {}) {
|
|
return POST(
|
|
new Request('http://localhost/api/whatsapp/send', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
contact_id: 'contact-1',
|
|
message_type: 'template',
|
|
template_name: 'order_update',
|
|
template_language: 'en_US',
|
|
template_message_params: { body: ['Acme', '#1234'] },
|
|
template_params: ['Acme', '#1234'],
|
|
...overrides,
|
|
}),
|
|
}),
|
|
)
|
|
}
|
|
|
|
describe('POST /api/whatsapp/send — contact_id template path', () => {
|
|
beforeEach(() => {
|
|
conversationInserts.length = 0
|
|
messageInserts.length = 0
|
|
existingConversation = null
|
|
createdConversation = null
|
|
contactRow = CONTACT
|
|
supabaseMock = makeSupabaseMock()
|
|
sendTemplateMessage.mockClear()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('creates a conversation for a contact with none, then sends the template', async () => {
|
|
const res = await postContactTemplate()
|
|
const json = await res.json()
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(json.success).toBe(true)
|
|
expect(json.whatsapp_message_id).toBe('wamid-1')
|
|
|
|
// A conversation was created for this contact.
|
|
expect(conversationInserts).toHaveLength(1)
|
|
expect(conversationInserts[0]).toMatchObject({
|
|
account_id: 'acct-1',
|
|
contact_id: 'contact-1',
|
|
})
|
|
|
|
// The template was sent to the contact's number.
|
|
expect(sendTemplateMessage).toHaveBeenCalledTimes(1)
|
|
const args = (sendTemplateMessage.mock.calls[0] as unknown[])[0] as Record<
|
|
string,
|
|
unknown
|
|
>
|
|
// Meta wants the bare E.164 digits — sanitizePhoneForMeta strips the '+'.
|
|
expect(args.to).toBe('15551234567')
|
|
expect(args.templateName).toBe('order_update')
|
|
|
|
// The outbound message was persisted under the new conversation.
|
|
expect(messageInserts).toHaveLength(1)
|
|
expect(messageInserts[0]).toMatchObject({
|
|
conversation_id: 'conv-new',
|
|
content_type: 'template',
|
|
template_name: 'order_update',
|
|
sender_type: 'agent',
|
|
})
|
|
})
|
|
|
|
it('reuses an existing conversation instead of creating a duplicate', async () => {
|
|
existingConversation = {
|
|
id: 'conv-existing',
|
|
account_id: 'acct-1',
|
|
contact_id: 'contact-1',
|
|
contact: CONTACT,
|
|
}
|
|
|
|
const res = await postContactTemplate()
|
|
expect(res.status).toBe(200)
|
|
|
|
expect(conversationInserts).toHaveLength(0)
|
|
expect(messageInserts[0]).toMatchObject({ conversation_id: 'conv-existing' })
|
|
})
|
|
|
|
it('404s when the contact is not in the caller account', async () => {
|
|
contactRow = null
|
|
|
|
const res = await postContactTemplate()
|
|
const json = await res.json()
|
|
|
|
expect(res.status).toBe(404)
|
|
expect(json.error).toMatch(/contact not found/i)
|
|
expect(sendTemplateMessage).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('400s when neither conversation_id nor contact_id is provided', async () => {
|
|
const res = await POST(
|
|
new Request('http://localhost/api/whatsapp/send', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ message_type: 'template', template_name: 'x' }),
|
|
}),
|
|
)
|
|
expect(res.status).toBe(400)
|
|
})
|
|
})
|