Files
accounted/extensions/general/whatsapp-inbox/__tests__/webhook-post.test.ts
T
398c734b93 feat(whatsapp-inbox): intake extension with webhook, phone linking and receipt ack (#1338)
Webhook lifecycle: GET hub.challenge handshake (constant-time verify-token
compare); POST verifies X-Hub-Signature-256 over the RAW body before any
parse, Zod-parses the envelope, persists inbound rows (partial-unique wamid
= dedupe against Meta's up-to-7-day redelivery), acks 200 fast and defers
media processing via the after() idiom. Rejected and rate-limited content
always acks 200 and lands as skipped/error rows, never a retryable status.

Linking: the settings panel (Installningar -> WhatsApp) mints AC- one-time
codes (sha256 stored, 10 min TTL, single use, ambiguity-free alphabet); the
webhook consumes the code, binds phone to user (HMAC-peppered hash + AES-256-
GCM at rest) and confirms with M3. Keyword commands stopp/start/hjalp;
unknown senders get one throttled M1 greeting (1/h, 3/day) behind the
sender-quota RPC, with no media download and no content persistence.

Intake worker: atomic claim on the message row (the durable job record),
company resolution (default -> sole membership -> M6 fallback, no item),
per-company inbox quota (ack-and-drop, M17 once per 10 min per sender),
MIME allowlist, 10 MB stream-checked media download, exact sha256 duplicate
check, then the shared uploadAndExtract funnel (source 'whatsapp',
channel_context caption, whatsapp_message_id) and the M4 ack with extracted
merchant/total/date. Failures wrap to 'error' + error_message + one M18.

uploadAndExtract widened: source 'whatsapp', optional channelMeta + actorId;
email/upload paths behaviorally unchanged.

Deferred to PR4: burst debounce + combined ack (M5), in-chat company choice
(M6 buttons + 8h pin), clarifying questions M7-M10, interpret-answer LLM
call, sweep cron, retention cron.

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:47:08 +02:00

500 lines
18 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import crypto from 'crypto'
import { createQueuedMockSupabase } from '@/tests/helpers'
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(),
}))
vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => {
const actual = await vi.importActual<
typeof import('@/extensions/general/whatsapp-inbox/lib/graph-api')
>('@/extensions/general/whatsapp-inbox/lib/graph-api')
return {
...actual,
sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }),
markReadWithTyping: vi.fn().mockResolvedValue(undefined),
downloadMedia: vi.fn(),
getDisplayPhoneNumber: vi.fn().mockResolvedValue(null),
}
})
vi.mock('@/extensions/general/whatsapp-inbox/lib/process-inbound', () => ({
kickInboundProcessing: vi.fn(),
}))
import { createClient } from '@supabase/supabase-js'
import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox'
import { sendText, downloadMedia } from '@/extensions/general/whatsapp-inbox/lib/graph-api'
import { kickInboundProcessing } from '@/extensions/general/whatsapp-inbox/lib/process-inbound'
import { TEMPLATE } from '@/extensions/general/whatsapp-inbox/lib/messages'
import { hashLinkCode } from '@/extensions/general/whatsapp-inbox/lib/linking'
const SECRET = 'meta-app-secret'
const createClientMock = vi.mocked(createClient)
const sendTextMock = vi.mocked(sendText)
const kickMock = vi.mocked(kickInboundProcessing)
function findRoute(method: string, path: string) {
return whatsappInboxExtension.apiRoutes!.find((r) => r.method === method && r.path === path)!
}
const route = findRoute('POST', '/webhook')
function signedRequest(body: unknown, secret = SECRET): Request {
const raw = JSON.stringify(body)
const signature =
'sha256=' + crypto.createHmac('sha256', secret).update(raw, 'utf8').digest('hex')
return new Request('http://localhost:3000/api/extensions/ext/whatsapp-inbox/webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Hub-Signature-256': signature },
body: raw,
})
}
function envelope(value: Record<string, unknown>) {
return {
object: 'whatsapp_business_account',
entry: [
{
id: 'waba-1',
changes: [
{
field: 'messages',
value: { messaging_product: 'whatsapp', ...value },
},
],
},
],
}
}
function textMessage(body: string, overrides: Record<string, unknown> = {}) {
return {
from: '46701234567',
id: 'wamid.IN1',
timestamp: '1754000000',
type: 'text',
text: { body },
...overrides,
}
}
function imageMessage(overrides: Record<string, unknown> = {}) {
return {
from: '46701234567',
id: 'wamid.IN1',
timestamp: '1754000000',
type: 'image',
image: { id: 'media-1', mime_type: 'image/jpeg', sha256: 'abc', caption: 'kvitto' },
...overrides,
}
}
function makeLink(overrides: Record<string, unknown> = {}) {
return {
id: 'link-1',
user_id: 'user-1',
phone_hash: 'hash-x',
phone_enc: 'enc',
phone_masked: '+46 70 *** ** 67',
default_company_id: null,
revoked_at: null,
muted_at: null,
...overrides,
}
}
describe('POST /webhook', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
vi.clearAllMocks()
sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT' })
process.env.WHATSAPP_APP_SECRET = SECRET
process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper'
process.env.WHATSAPP_PHONE_ENCRYPTION_KEY = 'a'.repeat(64)
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
})
afterEach(() => {
process.env = { ...originalEnv }
})
function mockSupabase() {
const mock = createQueuedMockSupabase()
createClientMock.mockReturnValue(mock.supabase as never)
return mock
}
it('503s when the app secret is not configured', async () => {
delete process.env.WHATSAPP_APP_SECRET
const response = await route.handler(signedRequest(envelope({ messages: [] })))
expect(response.status).toBe(503)
})
it('401s on an invalid signature before touching anything', async () => {
mockSupabase()
const response = await route.handler(
signedRequest(envelope({ messages: [textMessage('hej')] }), 'wrong-secret'),
)
expect(response.status).toBe(401)
expect(sendTextMock).not.toHaveBeenCalled()
expect(kickMock).not.toHaveBeenCalled()
})
it('updates outbound delivery status from statuses[]', async () => {
const { enqueue, findCall } = mockSupabase()
enqueue({ data: null }) // update chain
const response = await route.handler(
signedRequest(envelope({ statuses: [{ id: 'wamid.OUT9', status: 'delivered' }] })),
)
expect(response.status).toBe(200)
const updateArgs = findCall('whatsapp_messages', 'update') as [Record<string, unknown>]
expect(updateArgs[0]).toEqual({ delivery_status: 'delivered' })
})
it('persists a linked media message and defers processing', async () => {
const { enqueue, findCall } = mockSupabase()
enqueue({ data: makeLink() }) // active link lookup
enqueue({ data: { id: 'conv-1' } }) // conversation lookup
enqueue({ data: { id: 'msg-row-1' } }) // message insert
enqueue({ data: null }) // phone_links last_message_at update
enqueue({ data: null }) // conversation window update
const response = await route.handler(
signedRequest(envelope({ messages: [imageMessage()] })),
)
expect(response.status).toBe(200)
const [row] = findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
expect(row.direction).toBe('inbound')
expect(row.wamid).toBe('wamid.IN1')
expect(row.processing_status).toBe('received')
expect(row.media_id).toBe('media-1')
expect(row.media_mime).toBe('image/jpeg')
expect(row.body_text).toBe('kvitto') // caption travels in body_text
expect(kickMock).toHaveBeenCalledWith(['msg-row-1'])
expect(sendTextMock).not.toHaveBeenCalled() // per-receipt ack comes from the worker
})
it('dedupes a redelivered wamid via the unique index (23505): no reply, no processing', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: makeLink() })
enqueue({ data: { id: 'conv-1' } })
enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } })
const response = await route.handler(
signedRequest(envelope({ messages: [imageMessage()] })),
)
expect(response.status).toBe(200)
expect(kickMock).toHaveBeenCalledWith([])
expect(sendTextMock).not.toHaveBeenCalled()
})
describe('unknown senders', () => {
it('greets once with M1: no media download, no message persistence', async () => {
const { enqueue, findCalls } = mockSupabase()
enqueue({ data: null }) // no active link
enqueue({ data: { ok: true } }) // sender quota RPC
enqueue({ data: [] }) // greeting throttle: nothing sent before
const response = await route.handler(
signedRequest(envelope({ messages: [imageMessage()] })),
)
expect(response.status).toBe(200)
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m1Unlinked)
expect(vi.mocked(downloadMedia)).not.toHaveBeenCalled()
expect(findCalls('whatsapp_messages', 'insert')).toHaveLength(0)
expect(kickMock).toHaveBeenCalledWith([])
})
it('stays silent when the M1 throttle window is exhausted', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({ data: [{ created_at: new Date().toISOString() }] }) // greeted within the hour
await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] })))
expect(sendTextMock).not.toHaveBeenCalled()
})
it('stays silent when the pre-binding sender quota is exhausted', async () => {
const mock = mockSupabase()
mock.enqueue({ data: null })
mock.enqueue({ data: { ok: false, scope: 'minute', retry_after_sec: 60 } })
await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] })))
expect(sendTextMock).not.toHaveBeenCalled()
expect(mock.supabase.rpc).toHaveBeenCalledWith(
'check_and_increment_whatsapp_sender_quota',
expect.objectContaining({ p_phone_hash: expect.any(String) }),
)
// Only the link lookup ever touched a table.
const tables = [...new Set(mock.calls.map((c) => c.table))]
expect(tables).toEqual(['whatsapp_phone_links'])
})
})
describe('link codes', () => {
it('binds a valid code: creates link + conversation, replies M3 with the company name', async () => {
const { enqueue, findCall } = mockSupabase()
enqueue({ data: null }) // no active link
enqueue({ data: { ok: true } }) // quota
enqueue({
data: {
id: 'code-1',
user_id: 'user-1',
expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
used_at: null,
},
}) // code lookup
enqueue({ data: { id: 'code-1' } }) // code claim
enqueue({ data: null }) // revoke by phone hash
enqueue({ data: null }) // revoke by user
enqueue({ data: { id: 'link-9', user_id: 'user-1' } }) // link insert
enqueue({ data: { id: 'conv-9' } }) // conversation insert
enqueue({ data: null }) // content-free code-message row
enqueue({ data: [{ company_id: 'company-1' }] }) // memberships
enqueue({ data: { name: 'Bolaget AB' } }) // company name
const response = await route.handler(
signedRequest(
envelope({
contacts: [{ wa_id: '46701234567', profile: { name: 'Jakob' } }],
messages: [textMessage('ac-7kp4qf')],
}),
),
)
expect(response.status).toBe(200)
const [linkRow] = findCall('whatsapp_phone_links', 'insert') as [Record<string, unknown>]
expect(linkRow.user_id).toBe('user-1')
expect(linkRow.wa_profile_name).toBe('Jakob')
expect(linkRow.phone_masked).toBe('+46 70 *** ** 67')
// The code message row is persisted content-free (dedupe only).
const [codeRow] = findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
expect(codeRow.wamid).toBe('wamid.IN1')
expect(codeRow.body_text).toBeUndefined()
expect(codeRow.raw_payload).toBeUndefined()
// The code was claimed single-use.
const [claim] = findCall('whatsapp_link_codes', 'update') as [Record<string, unknown>]
expect(claim.used_at).toBeTruthy()
expect(sendTextMock).toHaveBeenCalledTimes(1)
const reply = sendTextMock.mock.calls[0][1]
expect(reply.template).toBe(TEMPLATE.m3Linked)
expect(reply.body).toContain('Bolaget AB')
})
it('replies M2 to an unknown code', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({ data: null }) // code lookup: nothing
await route.handler(signedRequest(envelope({ messages: [textMessage('AC-7KP4QF')] })))
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m2BadCode)
})
it('replies M2 to an expired code', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({
data: {
id: 'code-1',
user_id: 'user-1',
expires_at: new Date(Date.now() - 1000).toISOString(),
used_at: null,
},
})
await route.handler(signedRequest(envelope({ messages: [textMessage('AC-7KP4QF')] })))
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m2BadCode)
})
it('replies M2 to a reused code (single use)', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({
data: {
id: 'code-1',
user_id: 'user-1',
expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
used_at: new Date().toISOString(),
},
})
await route.handler(signedRequest(envelope({ messages: [textMessage('AC-7KP4QF')] })))
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m2BadCode)
})
it('hashLinkCode matches what the webhook looks up', () => {
// Regression guard: panel mints, webhook consumes; both must hash alike.
expect(hashLinkCode('AC-7KP4QF')).toMatch(/^[0-9a-f]{64}$/)
})
})
describe('keywords', () => {
function enqueueLinkedTextPreamble(
mock: ReturnType<typeof mockSupabase>,
link: Record<string, unknown>,
) {
mock.enqueue({ data: link }) // link lookup
mock.enqueue({ data: { id: 'conv-1' } }) // conversation
mock.enqueue({ data: { id: 'msg-row-1' } }) // insert
mock.enqueue({ data: null }) // link last_message_at
mock.enqueue({ data: null }) // conversation window
}
it('stopp mutes the link and confirms with M11', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink())
mock.enqueue({ data: null }) // muted_at update
await route.handler(signedRequest(envelope({ messages: [textMessage('Stopp')] })))
const linkUpdates = mock.findCalls('whatsapp_phone_links', 'update')
const mutedUpdate = linkUpdates.find(
(args) => (args[0] as Record<string, unknown>).muted_at != null,
)
expect(mutedUpdate).toBeTruthy()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m11Stop)
})
it('while muted everything except start is silence', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink({ muted_at: '2026-08-01T10:00:00Z' }))
await route.handler(signedRequest(envelope({ messages: [textMessage('hej, är du där?')] })))
expect(sendTextMock).not.toHaveBeenCalled()
const [row] = mock.findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
expect(row.processing_status).toBe('skipped')
})
it('while muted media is also silence', async () => {
const mock = mockSupabase()
mock.enqueue({ data: makeLink({ muted_at: '2026-08-01T10:00:00Z' }) })
mock.enqueue({ data: { id: 'conv-1' } })
mock.enqueue({ data: { id: 'msg-row-1' } })
mock.enqueue({ data: null })
mock.enqueue({ data: null })
await route.handler(signedRequest(envelope({ messages: [imageMessage()] })))
expect(sendTextMock).not.toHaveBeenCalled()
expect(kickMock).toHaveBeenCalledWith([])
})
it('start unmutes and welcomes back with M12', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink({ muted_at: '2026-08-01T10:00:00Z' }))
mock.enqueue({ data: null }) // muted_at cleared
await route.handler(signedRequest(envelope({ messages: [textMessage('start')] })))
const linkUpdates = mock.findCalls('whatsapp_phone_links', 'update')
const unmute = linkUpdates.find(
(args) => (args[0] as Record<string, unknown>).muted_at === null,
)
expect(unmute).toBeTruthy()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m12Start)
})
it('hjälp escalates to a human with M13', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink())
await route.handler(signedRequest(envelope({ messages: [textMessage('Hjälp')] })))
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m13Help)
expect(sendTextMock.mock.calls[0][1].body).toContain('support@accounted.se')
})
it('other free text gets the M16 fallback', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink())
await route.handler(
signedRequest(envelope({ messages: [textMessage('kan du bokföra allt åt mig?')] })),
)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m16Fallback)
})
it('voice notes get M14', async () => {
const mock = mockSupabase()
mock.enqueue({ data: makeLink() })
mock.enqueue({ data: { id: 'conv-1' } })
mock.enqueue({ data: { id: 'msg-row-1' } })
mock.enqueue({ data: null })
mock.enqueue({ data: null })
await route.handler(
signedRequest(
envelope({
messages: [
{
from: '46701234567',
id: 'wamid.IN1',
type: 'audio',
audio: { id: 'media-2', mime_type: 'audio/ogg', voice: true },
},
],
}),
),
)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m14Voice)
expect(kickMock).toHaveBeenCalledWith([])
})
it('stickers get M15', async () => {
const mock = mockSupabase()
mock.enqueue({ data: makeLink() })
mock.enqueue({ data: { id: 'conv-1' } })
mock.enqueue({ data: { id: 'msg-row-1' } })
mock.enqueue({ data: null })
mock.enqueue({ data: null })
await route.handler(
signedRequest(
envelope({
messages: [
{
from: '46701234567',
id: 'wamid.IN1',
type: 'sticker',
sticker: { id: 'media-3', mime_type: 'image/webp' },
},
],
}),
),
)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m15Unsupported)
})
})
it('acks signed-but-unparseable bodies without redelivery bait', async () => {
mockSupabase()
const raw = 'not json'
const signature =
'sha256=' + crypto.createHmac('sha256', SECRET).update(raw, 'utf8').digest('hex')
const response = await route.handler(
new Request('http://localhost:3000/api/extensions/ext/whatsapp-inbox/webhook', {
method: 'POST',
headers: { 'X-Hub-Signature-256': signature },
body: raw,
}),
)
expect(response.status).toBe(200)
})
})