* fix(whatsapp): app-side unmute, close silent intake paths, health visibility - add POST /link/unmute and a Reactivate control on the Pausad state - company resolution: transient query errors release the row for sweep retry; genuine zero-options sends M19 instead of parking silently - media from unlinked senders bypasses the hourly greeting throttle (10 min burst window, daily cap kept) - GET /link returns 7-day failed-delivery and parked-inbound counts; sweep summary logs outboundFailed24h Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(documents): real HEIC/HEIF magic-byte validation, bilingual upload errors - detect ISO-BMFF ftyp brands (heic/heix/heim/heis/hevc/hevx/hevm/hevs, mif1/msf1) instead of exempting image/heic from validation; declared heic/heif accepts either family member (iOS labels vary) - new INBOX_UPLOAD_* structured error codes replace raw English strings on the inbox upload and attach-document routes - registry doc corrected to the real 10 MB cap Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(inbox): staged upload with instant ack and deferred AI extraction - web uploads insert the inbox item as status processing and respond immediately; Bedrock extraction and supplier match run via after() with a CAS flip to received (email and WhatsApp channels keep the synchronous path) - widen invoice_inbox_items.status CHECK to include processing (migration 20260813180000, pg-real test included) - crash-recovery sweep cron (*/2) flips stale processing rows; bulk-book skips extraction_in_progress items - workspace: processing chip, in-flight rows disable actions, realtime flip, retry-extraction button for empty extractions - picker accept list drops HEIC/HEIF so iOS transcodes library photos to JPEG; server allowlists unchanged (supersedes 2026-08-01 HEIC decision, see DECISIONS.md) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): bump inbox processing-status migration past main's latest Main merged 20260813210000 while this PR was in flight; an inserted version older than the latest applied aborts the prod db push at merge. Renamed 20260813180000 to 20260813213000 and updated references. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(decisions): log preview-tracker orphan repair after migration rename Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1017 lines
39 KiB
TypeScript
1017 lines
39 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', errorDetail: null }),
|
|
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', errorDetail: null })
|
|
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('keeps the Meta error detail on a failed delivery status (#1552)', async () => {
|
|
const { enqueue, findCall } = mockSupabase()
|
|
enqueue({ data: null }) // update chain
|
|
|
|
await route.handler(
|
|
signedRequest(
|
|
envelope({
|
|
statuses: [
|
|
{
|
|
id: 'wamid.OUT9',
|
|
status: 'failed',
|
|
errors: [{ code: 131026, title: 'Message undeliverable' }],
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
)
|
|
const updateArgs = findCall('whatsapp_messages', 'update') as [Record<string, unknown>]
|
|
expect(updateArgs[0]).toEqual({
|
|
delivery_status: 'failed',
|
|
error_message: '131026: Message undeliverable',
|
|
})
|
|
})
|
|
|
|
it('persists a linked media message, arms the burst debounce 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 + debounce update
|
|
|
|
const before = Date.now()
|
|
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
|
|
|
|
// Debounce armed: pending_ack + a ~12s deadline pushed forward.
|
|
const [patch] = findCall('whatsapp_conversations', 'update') as [Record<string, unknown>]
|
|
expect(patch.pending_ack).toBe(true)
|
|
const deadline = new Date(patch.debounce_until as string).getTime() - before
|
|
expect(deadline).toBeGreaterThan(10_000)
|
|
expect(deadline).toBeLessThan(14_000)
|
|
|
|
expect(kickMock).toHaveBeenCalledWith(['msg-row-1'])
|
|
expect(sendTextMock).not.toHaveBeenCalled() // the combined 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 CONTENT 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
|
|
enqueue({ data: null, error: null }) // trace row insert ('done': no cap query)
|
|
|
|
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()
|
|
// #1552: a metadata-only trace row IS persisted, but it must carry no
|
|
// content: no body, no media reference, no raw payload, no link.
|
|
const inserts = findCalls('whatsapp_messages', 'insert')
|
|
expect(inserts).toHaveLength(1)
|
|
const [row] = inserts[0] as [Record<string, unknown>]
|
|
expect(row.processing_status).toBe('done')
|
|
expect(row.error_message).toContain('greeted')
|
|
expect(row.phone_link_id).toBeNull()
|
|
expect(row.body_text).toBeUndefined()
|
|
expect(row.media_id).toBeUndefined()
|
|
expect(row.raw_payload).toBeUndefined()
|
|
expect(kickMock).toHaveBeenCalledWith([])
|
|
})
|
|
|
|
it('does not re-greet a redelivered wamid (trace row dedupe)', async () => {
|
|
const { enqueue } = mockSupabase()
|
|
enqueue({ data: null })
|
|
enqueue({ data: { ok: true } })
|
|
enqueue({ data: [] }) // throttle window clear
|
|
enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } }) // trace insert
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [imageMessage()] })))
|
|
expect(sendTextMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('records a declined trace row when the M1 throttle window is exhausted', async () => {
|
|
const { enqueue, findCalls } = mockSupabase()
|
|
enqueue({ data: null })
|
|
enqueue({ data: { ok: true } })
|
|
enqueue({ data: [{ created_at: new Date().toISOString() }] }) // greeted within the hour
|
|
enqueue({ count: 0 }) // decline-trace day cap
|
|
enqueue({ data: null, error: null }) // trace row insert
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] })))
|
|
expect(sendTextMock).not.toHaveBeenCalled()
|
|
const inserts = findCalls('whatsapp_messages', 'insert')
|
|
expect(inserts).toHaveLength(1)
|
|
const [row] = inserts[0] as [Record<string, unknown>]
|
|
expect(row.processing_status).toBe('skipped')
|
|
expect(row.error_message).toContain('greeting throttled')
|
|
expect(row.body_text).toBeUndefined()
|
|
})
|
|
|
|
it('media inside the hour but outside the 10 min burst window still gets M1', async () => {
|
|
const { enqueue } = mockSupabase()
|
|
enqueue({ data: null }) // no active link
|
|
enqueue({ data: { ok: true } }) // sender quota RPC
|
|
enqueue({
|
|
data: [{ created_at: new Date(Date.now() - 30 * 60 * 1000).toISOString() }],
|
|
}) // one greeting 30 min ago: inside the text hour, outside the burst
|
|
enqueue({ data: null, error: null }) // trace row insert ('done')
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [imageMessage()] })))
|
|
|
|
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
|
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m1Unlinked)
|
|
// The privacy invariant holds: still no media touch for unlinked senders.
|
|
expect(vi.mocked(downloadMedia)).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('media inside the 10 min burst window stays silent (one M1 per burst)', async () => {
|
|
const { enqueue } = mockSupabase()
|
|
enqueue({ data: null })
|
|
enqueue({ data: { ok: true } })
|
|
enqueue({
|
|
data: [{ created_at: new Date(Date.now() - 2 * 60 * 1000).toISOString() }],
|
|
}) // greeted 2 min ago: same burst
|
|
enqueue({ count: 0 }) // decline-trace day cap
|
|
enqueue({ data: null, error: null }) // declined trace insert
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [imageMessage()] })))
|
|
expect(sendTextMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('a text message inside the hour stays silent (hour rule unchanged for text)', async () => {
|
|
const { enqueue } = mockSupabase()
|
|
enqueue({ data: null })
|
|
enqueue({ data: { ok: true } })
|
|
enqueue({
|
|
data: [{ created_at: new Date(Date.now() - 30 * 60 * 1000).toISOString() }],
|
|
}) // greeted 30 min ago: text keeps the 1/hour rule
|
|
enqueue({ count: 0 }) // decline-trace day cap
|
|
enqueue({ data: null, error: null }) // declined trace insert
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] })))
|
|
expect(sendTextMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('the fourth media greeting of the day stays silent (daily cap kept)', async () => {
|
|
const { enqueue } = mockSupabase()
|
|
enqueue({ data: null })
|
|
enqueue({ data: { ok: true } })
|
|
enqueue({
|
|
data: [
|
|
{ created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString() },
|
|
{ created_at: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString() },
|
|
{ created_at: new Date(Date.now() - 9 * 60 * 60 * 1000).toISOString() },
|
|
],
|
|
}) // GREETING_DAY_MAX greetings already sent today
|
|
enqueue({ count: 0 }) // decline-trace day cap
|
|
enqueue({ data: null, error: null }) // declined trace insert
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [imageMessage()] })))
|
|
expect(sendTextMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('stays silent but records the decline when the pre-binding quota is exhausted', async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: null })
|
|
mock.enqueue({ data: { ok: false, scope: 'minute', retry_after_sec: 60 } })
|
|
mock.enqueue({ count: 0 }) // decline-trace day cap
|
|
mock.enqueue({ data: null, error: null }) // trace row insert
|
|
|
|
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) }),
|
|
)
|
|
// The link lookup plus the metadata-only decline trace (#1552).
|
|
const tables = [...new Set(mock.calls.map((c) => c.table))]
|
|
expect(tables).toEqual(['whatsapp_phone_links', 'whatsapp_messages'])
|
|
const [row] = mock.findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
|
|
expect(row.error_message).toContain('over pre-binding quota')
|
|
})
|
|
|
|
it('stops recording decline traces past the per-day cap', async () => {
|
|
const { enqueue, findCalls } = mockSupabase()
|
|
enqueue({ data: null })
|
|
enqueue({ data: { ok: false } })
|
|
enqueue({ count: 20 }) // cap reached
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] })))
|
|
expect(findCalls('whatsapp_messages', 'insert')).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
/** Dispositions with a durable side effect run it BEFORE the terminal row
|
|
* is written, so the wamid dedupe becomes a pre-check and the insert
|
|
* lands last (see handleLinkedSender). */
|
|
function enqueueDurablePreamble(
|
|
mock: ReturnType<typeof mockSupabase>,
|
|
link: Record<string, unknown>,
|
|
conversation: Record<string, unknown> = { id: 'conv-1', state: 'idle', context: {} },
|
|
) {
|
|
mock.enqueue({ data: link }) // link lookup
|
|
mock.enqueue({ data: conversation }) // conversation
|
|
mock.enqueue({ data: null }) // wamid dedupe pre-check: not seen yet
|
|
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()
|
|
enqueueDurablePreamble(mock, makeLink())
|
|
mock.enqueue({ data: null }) // muted_at update
|
|
mock.enqueue({ data: null }) // terminal row insert
|
|
|
|
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()
|
|
enqueueDurablePreamble(mock, makeLink({ muted_at: '2026-08-01T10:00:00Z' }))
|
|
mock.enqueue({ data: null }) // muted_at cleared
|
|
mock.enqueue({ data: null }) // terminal row insert
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
describe('conversation layer routing', () => {
|
|
const awaitingCompany = () => ({
|
|
id: 'conv-1',
|
|
state: 'awaiting_company',
|
|
context: {
|
|
company_options: [
|
|
{ id: 'company-1', name: 'Bolag A AB' },
|
|
{ id: 'company-2', name: 'Bolag B AB' },
|
|
],
|
|
pending_question: {
|
|
type: 'company',
|
|
inbox_item_id: null,
|
|
asked_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
company_id: null,
|
|
})
|
|
|
|
it('a typed digit in awaiting_company applies the choice and kicks the parked rows', async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({ data: awaitingCompany() })
|
|
mock.enqueue({ data: null }) // wamid dedupe pre-check
|
|
mock.enqueue({ data: null }) // link last_message_at
|
|
mock.enqueue({ data: null }) // conversation window update
|
|
mock.enqueue({ data: { company_id: 'company-2' } }) // membership check
|
|
mock.enqueue({ data: null }) // guarded conversation pin update
|
|
mock.enqueue({ data: null }) // link last_company_id
|
|
mock.enqueue({ data: [{ id: 'stg-1' }, { id: 'stg-2' }] }) // staged reopen
|
|
mock.enqueue({ data: null }) // terminal row insert
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [textMessage('2')] })))
|
|
|
|
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
|
const confirm = sendTextMock.mock.calls[0][1]
|
|
expect(confirm.template).toBe(TEMPLATE.m6CompanyConfirm)
|
|
expect(confirm.body).toContain('Bolag B AB')
|
|
expect(kickMock).toHaveBeenCalledWith(['stg-1', 'stg-2'], { companySelectedVia: 'numbered' })
|
|
})
|
|
|
|
it('an interactive button reply in awaiting_company applies the tapped company', async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({ data: awaitingCompany() })
|
|
mock.enqueue({ data: null }) // wamid dedupe pre-check
|
|
mock.enqueue({ data: null })
|
|
mock.enqueue({ data: null })
|
|
mock.enqueue({ data: { company_id: 'company-1' } }) // membership check
|
|
mock.enqueue({ data: null }) // guarded conversation pin update
|
|
mock.enqueue({ data: null }) // link last_company_id
|
|
mock.enqueue({ data: [{ id: 'stg-1' }] }) // staged reopen
|
|
mock.enqueue({ data: null }) // terminal row insert
|
|
|
|
await route.handler(
|
|
signedRequest(
|
|
envelope({
|
|
messages: [
|
|
{
|
|
from: '46701234567',
|
|
id: 'wamid.IN1',
|
|
type: 'interactive',
|
|
interactive: {
|
|
type: 'button_reply',
|
|
button_reply: { id: 'company-1', title: 'Bolag A AB' },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
)
|
|
|
|
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m6CompanyConfirm)
|
|
expect(kickMock).toHaveBeenCalledWith(['stg-1'], { companySelectedVia: 'button' })
|
|
})
|
|
|
|
it('a stale interactive tap outside awaiting_company is silence', async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({ data: { id: 'conv-1', state: 'idle', context: {} } })
|
|
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: 'interactive',
|
|
interactive: {
|
|
type: 'button_reply',
|
|
button_reply: { id: 'company-1', title: 'Bolag A AB' },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
)
|
|
|
|
expect(sendTextMock).not.toHaveBeenCalled()
|
|
const [row] = mock.findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
|
|
expect(row.processing_status).toBe('skipped')
|
|
})
|
|
|
|
it("'byt' in idle clears the company pin and confirms", async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({
|
|
data: {
|
|
id: 'conv-1',
|
|
state: 'idle',
|
|
context: {
|
|
pin_expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
pin_source: 'button',
|
|
},
|
|
company_id: 'company-2',
|
|
},
|
|
})
|
|
mock.enqueue({ data: null }) // wamid dedupe pre-check
|
|
mock.enqueue({ data: null }) // link update
|
|
mock.enqueue({ data: null }) // conversation window update
|
|
mock.enqueue({ data: null }) // guarded pin clear update
|
|
mock.enqueue({ data: null }) // terminal row insert
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [textMessage('byt')] })))
|
|
|
|
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
|
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m6BytPin)
|
|
const pinClear = mock
|
|
.findCalls('whatsapp_conversations', 'update')
|
|
.map((args) => args[0] as Record<string, unknown>)
|
|
.find((patch) => 'company_id' in patch)
|
|
expect(pinClear?.company_id).toBeNull()
|
|
expect((pinClear?.context as Record<string, unknown>).pin_expires_at).toBeUndefined()
|
|
})
|
|
|
|
it('free text while awaiting_representation is deferred as an answer (never handled inline)', async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({
|
|
data: {
|
|
id: 'conv-1',
|
|
state: 'awaiting_representation',
|
|
context: {
|
|
pending_question: {
|
|
type: 'representation',
|
|
inbox_item_id: 'item-9',
|
|
asked_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
},
|
|
})
|
|
mock.enqueue({ data: { id: 'msg-row-9' } })
|
|
mock.enqueue({ data: null })
|
|
mock.enqueue({ data: null })
|
|
|
|
await route.handler(
|
|
signedRequest(envelope({ messages: [textMessage('Lunch med Anna Berg (Volvo)')] })),
|
|
)
|
|
|
|
const [row] = mock.findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
|
|
expect(row.processing_status).toBe('received') // durable job row for the worker
|
|
expect(kickMock).toHaveBeenCalledWith(['msg-row-9'])
|
|
expect(sendTextMock).not.toHaveBeenCalled() // no inline M16 while a question is open
|
|
})
|
|
|
|
it('idle free text quoting an earlier receipt routes as a late answer', async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({
|
|
data: {
|
|
id: 'conv-1',
|
|
state: 'idle',
|
|
context: {
|
|
recent_questions: [
|
|
{
|
|
type: 'context',
|
|
inbox_item_id: 'item-7',
|
|
asked_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
|
status: 'moved_to_app',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
mock.enqueue({ data: { inbox_item_id: 'item-7' } }) // quoted wamid lookup
|
|
mock.enqueue({ data: { id: 'msg-row-7' } })
|
|
mock.enqueue({ data: null })
|
|
mock.enqueue({ data: null })
|
|
|
|
await route.handler(
|
|
signedRequest(
|
|
envelope({
|
|
messages: [textMessage('taxi till kundmöte', { context: { id: 'wamid.QUOTED' } })],
|
|
}),
|
|
),
|
|
)
|
|
|
|
const [row] = mock.findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
|
|
expect(row.processing_status).toBe('received')
|
|
expect(kickMock).toHaveBeenCalledWith(['msg-row-7'])
|
|
expect(sendTextMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('hardening: dispositions, late company answers, payload redaction', () => {
|
|
const withOptions = (state: string) => ({
|
|
id: 'conv-1',
|
|
state,
|
|
context: {
|
|
company_options: [
|
|
{ id: 'company-1', name: 'Bolag A AB' },
|
|
{ id: 'company-2', name: 'Bolag B AB' },
|
|
],
|
|
},
|
|
company_id: null,
|
|
})
|
|
|
|
it('an out-of-range digit gets the options repeated instead of silence', async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({ data: withOptions('awaiting_company') })
|
|
mock.enqueue({ data: null }) // wamid dedupe pre-check
|
|
mock.enqueue({ data: null }) // link last_message_at
|
|
mock.enqueue({ data: null }) // conversation window
|
|
mock.enqueue({ data: null }) // terminal row insert
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [textMessage('9')] })))
|
|
|
|
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
|
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m6CompanyRetry)
|
|
expect(sendTextMock.mock.calls[0][1].body).toContain('Bolag B AB')
|
|
})
|
|
|
|
it('a typed company name gets the options repeated, not the M16 lecture', async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({ data: withOptions('awaiting_company') })
|
|
mock.enqueue({ data: { id: 'msg-row-1' } }) // insert (reply-only disposition)
|
|
mock.enqueue({ data: null })
|
|
mock.enqueue({ data: null })
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [textMessage('Bolag B AB')] })))
|
|
|
|
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
|
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m6CompanyRetry)
|
|
})
|
|
|
|
it('accepts a LATE company answer after the 48h reset (options still present)', async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({ data: withOptions('idle') })
|
|
mock.enqueue({ data: null }) // wamid dedupe pre-check
|
|
mock.enqueue({ data: null }) // link last_message_at
|
|
mock.enqueue({ data: null }) // conversation window
|
|
mock.enqueue({ data: { company_id: 'company-2' } }) // membership check
|
|
mock.enqueue({ data: null }) // guarded pin write
|
|
mock.enqueue({ data: null }) // link last_company_id
|
|
mock.enqueue({ data: [{ id: 'stg-1' }] }) // staged reopen
|
|
mock.enqueue({ data: null }) // terminal row insert
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [textMessage('2')] })))
|
|
|
|
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m6CompanyConfirm)
|
|
expect(kickMock).toHaveBeenCalledWith(['stg-1'], { companySelectedVia: 'numbered' })
|
|
})
|
|
|
|
it("recognises 'byt' inside an awaiting state, the word m6-confirm teaches", async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({
|
|
data: {
|
|
id: 'conv-1',
|
|
state: 'awaiting_context',
|
|
context: {
|
|
pin_expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
pending_question: {
|
|
type: 'context',
|
|
inbox_item_id: 'item-1',
|
|
asked_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
company_id: 'company-2',
|
|
},
|
|
})
|
|
mock.enqueue({ data: null }) // wamid dedupe pre-check
|
|
mock.enqueue({ data: null }) // link last_message_at
|
|
mock.enqueue({ data: null }) // conversation window
|
|
mock.enqueue({ data: null }) // guarded pin clear
|
|
mock.enqueue({ data: null }) // terminal row insert
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [textMessage('byt')] })))
|
|
|
|
expect(sendTextMock).toHaveBeenCalledTimes(1)
|
|
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m6BytPin)
|
|
expect(kickMock).toHaveBeenCalledWith([])
|
|
})
|
|
|
|
it('applies STOP before writing its terminal row, so a crash cannot lose the opt-out', async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({ data: { id: 'conv-1', state: 'idle', context: {} } })
|
|
mock.enqueue({ data: null }) // wamid dedupe pre-check
|
|
mock.enqueue({ data: null }) // link last_message_at
|
|
mock.enqueue({ data: null }) // conversation window
|
|
mock.enqueue({ data: null }) // muted_at
|
|
mock.enqueue({ data: null }) // terminal row insert
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [textMessage('stopp')] })))
|
|
|
|
const order = mock.calls
|
|
.filter(
|
|
(c) =>
|
|
(c.table === 'whatsapp_phone_links' &&
|
|
c.method === 'update' &&
|
|
(c.args[0] as Record<string, unknown>).muted_at != null) ||
|
|
(c.table === 'whatsapp_messages' && c.method === 'insert'),
|
|
)
|
|
.map((c) => `${c.table}.${c.method}`)
|
|
expect(order).toEqual(['whatsapp_phone_links.update', 'whatsapp_messages.insert'])
|
|
const [row] = mock.findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
|
|
expect(row.processing_status).toBe('done')
|
|
})
|
|
|
|
it('never persists the sender plaintext number in raw_payload', async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: makeLink() })
|
|
mock.enqueue({ data: { id: 'conv-1', state: 'idle', context: {} } })
|
|
mock.enqueue({ data: { id: 'msg-row-1' } })
|
|
mock.enqueue({ data: null })
|
|
mock.enqueue({ data: null })
|
|
|
|
await route.handler(signedRequest(envelope({ messages: [imageMessage()] })))
|
|
|
|
const [row] = mock.findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
|
|
expect(JSON.stringify(row.raw_payload)).not.toContain('46701234567')
|
|
expect(row.raw_payload).toMatchObject({ id: 'wamid.IN1', type: 'image' })
|
|
})
|
|
|
|
it('a muted sender contributes no chat content at all', async () => {
|
|
const mock = mockSupabase()
|
|
mock.enqueue({ data: makeLink({ muted_at: '2026-08-01T10:00:00Z' }) })
|
|
mock.enqueue({ data: { id: 'conv-1', state: 'idle', context: {} } })
|
|
mock.enqueue({ data: { id: 'msg-row-1' } })
|
|
mock.enqueue({ data: null })
|
|
mock.enqueue({ data: null })
|
|
|
|
await route.handler(
|
|
signedRequest(envelope({ messages: [textMessage('känslig text till en död linje')] })),
|
|
)
|
|
|
|
const [row] = mock.findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
|
|
expect(row.processing_status).toBe('skipped')
|
|
expect(row.body_text).toBeNull()
|
|
expect(row.raw_payload).toBeNull()
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|