* fix(whatsapp-inbox): erase the WhatsApp channel on account deletion whatsapp_phone_links relied on the auth.users ON DELETE CASCADE, but Accounted never deletes auth.users: account deletion is anonymize_user_account plus a ~100-year ban that keeps the auth row as a tombstone, so the cascade never fires and nothing revokes the link. After erasure the link stayed active with a decryptable phone_enc, lookupActiveLink kept resolving the number, and every further inbound message was persisted with body_text and the verbatim raw_payload while the bot kept replying: GDPR Art 17 plus continued collection with no lawful basis. The RPC is re-created verbatim from 20260724150000 with one added block that revokes and crypto-shreds the link, resets its conversation, nulls body_text/raw_payload on that link's messages and deletes outstanding link codes, plus a guarded repair pass for tombstones anonymized before this migration. Covered by a pg-real test that fails against the previous definition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(whatsapp-inbox): pepper the link-code hash and bound code minting hashLinkCode stored a bare sha256 over CODE_ALPHABET^6 = 30^6 values behind a fixed 'AC-' prefix. The module cited the invite-token pattern, but invite tokens are 256-bit random; this space enumerates offline in about a second, so hashing at rest protected nothing. The sibling phone-crypto.ts already states the team's own threat model for a LARGER space ("a plain sha256 would be brute-forceable ... hence the pepper"), so link codes now hash through the same env-mandated pepper. /link/start was also an authenticated unbounded INSERT that left every earlier code valid. Minting now burns the caller's unused codes (the code the panel shows is the only one that works) and is capped per TTL window, with the route answering 429 instead of throwing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(whatsapp-inbox): harden the conversation layer against the review findings Pre-merge hardening of the unshipped chat layer. Every change below has a test that fails without it. Lifecycle and races: - conversation writes go through updateConversation(), an optimistic compare-and-set on updated_at (the trigger makes it a revision counter). The ack winner, the answer worker, the pin refresh and the sweep hold different claims, so blind whole-jsonb writes resurrected answered questions, wiped pending_question and dropped queue entries. - terminal markStatus writes are guarded on processing_status='processing' so a losing worker cannot overwrite the winner's 'done' and null its inbox_item_id. - the message -> inbox item path is idempotent: a pre-check plus a 23505 fallback adopt the item a concurrent worker created, instead of throwing after the WORM document is already committed. - PROCESSING_STUCK_MS 90s -> 5 min. The enforced step budget of one media row already exceeds 90s, so the sweep was re-claiming live workers. - sweep 2b re-arms only when the conversation itself has been quiet, not just the rows: pending_ack=false plus unacked rows is also the state of a live finalize, which produced a duplicate combined ack. - pin expiry re-checks against fresh state instead of writing back a stale whole context, which reverted company choices applied mid-pass. - askNextQueuedQuestion claims the pop before sending, so two answer workers cannot ask the same question twice. Company question: - the state is rolled back when the M6 send fails, so the next receipt re-asks instead of parking receipts behind a question nobody received. - applyCompanyChoice claims the open question (company_options) rather than the state: a double tap confirms once, a transient membership-query error is no longer read as "not a member", and a LATE answer still lands. - at the 48h TTL the parked receipts are kept, not discarded: options and staged rows survive so a late digit or tap still files them, and only rows past Meta's ~30-day media window get the terminal marker. - an out-of-range digit or a typed company name now gets the options repeated instead of silence or the "I cannot answer questions" reply. Inline dispositions: - stop/start/byt/company answers run their side effect BEFORE the terminal wamid row, with a SELECT pre-check for dedupe. Writing the row 'done' first made them at-most-once: a crash in between lost the action forever. Copy and answers: - acks state the extracted currency instead of labelling every total 'kr'. - M17 stops promising "about 10 minutes" when the daily quota tripped. - M18 is sent once per message tracked by the outbound row, so a file whose first attempt died still reaches the sender, including from the max-attempts path. - M11 no longer claims the number is disconnected: 'stopp' pauses, and muted senders now persist no chat content at all. - 'byt' is recognized in every state but awaiting_company (m6-confirm teaches the word, and it was being stored as answer data instead). - text sent while a re-send question is open is kept as a note on THAT receipt with the question left open, instead of binding to another receipt's question. - a quoted reply wins over the pending question and is appended when the quoted question is already answered, so corrections stop landing on the wrong receipt. - context answers keep raw_answer + answered_at like representation does. - finalizeBurst checks the send result: on failure it rolls the question back and leaves the rows unacked for the sweep. PII: - the sender's plaintext number is stripped from raw_payload before it is persisted; replies decrypt the link's phone_enc instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(whatsapp-inbox): record the erasure path and the hardening decisions RoPA gains the account-deletion row (immediate, not via the cron: the auth.users cascade never fires because the row is tombstoned) plus the two new security measures, and its "never in the clear" phone claim is now true of the stored payload. DECISIONS.md records the non-obvious calls: revoke-not-delete on erasure, commit-then-roll-back for the company question, keeping expired company choices answerable, the compare-and-set conversation write, effect-before-terminal-row for inline dispositions, honest M11 copy, and the raw_payload redaction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(whatsapp-inbox): stop the answer re-claim from following a confirm with M16 A worker that died after applying an answer and sending its confirmation leaves the row 'processing'. The sweep re-runs it, resolveAnswerTarget finds the question already answered, and the user got "I did not understand" immediately after the confirmation they had just received. The fallback is now first-attempt only. The catch comment claiming the sweep retries these rows is corrected too: 'error' is terminal for the sweep, and nothing on the answer path throws anyway (interpretChatAnswer degrades, sends never throw, supabase-js returns errors), so the catch is a programming-error net. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(whatsapp-inbox): drop the amount floor on the representation question The Swedish compliance review on #1340 caught a real error in the trigger rules: the representation question only fired above 150 kr, but the duty to document deltagare and syfte is what makes the expense deductible at all (BFL 5 kap 6-7 §) and it is not conditioned on any amount. The 300 kr per person figure I had in mind is the VAT-deduction base cap, a different rule. A 120 kr business lunch would have been booked with no participant trail, which is exactly the deduction Skatteverket denies later. Noise stays bounded by the triggers that were already there: the question fires only for receipt-shaped documents from restaurant, cafe or hotel merchants, at most once per receipt, twice per burst and six times per sender per day, and a single "nej" dismisses it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
886 lines
33 KiB
TypeScript
886 lines
33 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, 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 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
|
|
}
|
|
|
|
/** 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)
|
|
})
|
|
})
|