wacrm/src/app/api/whatsapp/send/route.test.ts
Arnas Donauskas 8cb5792823 feat(api): public REST API v1 — messages, contacts, conversations, broadcasts (#245)
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>
2026-07-01 14:10:19 +03:00

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)
})
})