Files
accounted/extensions/general/whatsapp-inbox/__tests__/process-inbound.test.ts
T
88760ae6f6 fix(whatsapp-inbox): harden against adversarial review findings (#1342)
* 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>
2026-08-05 15:47:23 +02:00

568 lines
22 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
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(),
}
})
vi.mock('@/extensions/general/whatsapp-inbox/lib/company-question', async () => {
const actual = await vi.importActual<
typeof import('@/extensions/general/whatsapp-inbox/lib/company-question')
>('@/extensions/general/whatsapp-inbox/lib/company-question')
return {
...actual,
askCompanyQuestion: vi.fn().mockResolvedValue(true),
}
})
vi.mock('@/extensions/general/invoice-inbox/lib/upload-and-extract', () => ({
uploadAndExtract: vi.fn(),
}))
vi.mock('@/lib/rate-limits/inbox', () => ({
checkInboxUploadRateLimit: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/rate-limits/agent', () => ({
checkAgentRateLimit: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: vi.fn().mockResolvedValue('event-1'),
}))
vi.mock('@/lib/core/documents/document-service', () => ({
computeSHA256: vi.fn().mockResolvedValue('sha-abc'),
}))
import {
sendText,
markReadWithTyping,
downloadMedia,
GraphApiError,
} from '@/extensions/general/whatsapp-inbox/lib/graph-api'
import { askCompanyQuestion } from '@/extensions/general/whatsapp-inbox/lib/company-question'
import { uploadAndExtract } from '@/extensions/general/invoice-inbox/lib/upload-and-extract'
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { processInboundMessage } from '@/extensions/general/whatsapp-inbox/lib/process-inbound'
import {
STAGED_AWAITING_COMPANY,
} from '@/extensions/general/whatsapp-inbox/lib/conversation'
import { TEMPLATE } from '@/extensions/general/whatsapp-inbox/lib/messages'
const sendTextMock = vi.mocked(sendText)
const downloadMediaMock = vi.mocked(downloadMedia)
const uploadAndExtractMock = vi.mocked(uploadAndExtract)
const rateLimitMock = vi.mocked(checkInboxUploadRateLimit)
const appendHistoryMock = vi.mocked(appendProcessingHistory)
const askCompanyQuestionMock = vi.mocked(askCompanyQuestion)
function makeRow(overrides: Record<string, unknown> = {}) {
return {
id: 'msg-1',
direction: 'inbound',
wamid: 'wamid.IN1',
sender_phone_hash: 'hash-1',
phone_link_id: 'link-1',
conversation_id: 'conv-1',
message_type: 'image',
body_text: 'lunch med kund',
media_id: 'media-1',
media_mime: 'image/jpeg',
media_sha256: null,
media_filename: null,
raw_payload: { from: '46701234567' },
processing_status: 'received',
attempts: 0,
error_message: null,
inbox_item_id: null,
delivery_status: null,
correlation_id: 'corr-1',
acked_at: null,
created_at: '2026-08-01T10:00:00Z',
updated_at: '2026-08-01T10:00:00Z',
...overrides,
}
}
function makeLink(overrides: Record<string, unknown> = {}) {
return {
id: 'link-1',
user_id: 'user-1',
phone_hash: 'hash-1',
phone_enc: 'enc',
phone_masked: '+46 70 *** ** 67',
wa_profile_name: null,
default_company_id: null,
last_company_id: null,
verified_at: '2026-08-01T09:00:00Z',
revoked_at: null,
muted_at: null,
last_message_at: null,
created_at: '2026-08-01T09:00:00Z',
updated_at: '2026-08-01T09:00:00Z',
...overrides,
}
}
function makeConversation(overrides: Record<string, unknown> = {}) {
return {
id: 'conv-1',
phone_link_id: 'link-1',
state: 'idle',
context: {},
company_id: null,
service_window_expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
debounce_until: new Date().toISOString(),
pending_ack: true,
last_inbound_at: null,
last_outbound_at: null,
created_at: '2026-08-01T09:00:00Z',
updated_at: '2026-08-01T09:00:00Z',
...overrides,
}
}
function lastUpdate(findCalls: (table: string, method: string) => unknown[][]): Record<string, unknown> {
const updates = findCalls('whatsapp_messages', 'update')
return updates[updates.length - 1][0] as Record<string, unknown>
}
describe('processInboundMessage (media intake)', () => {
beforeEach(() => {
vi.clearAllMocks()
sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT' })
askCompanyQuestionMock.mockResolvedValue(true)
rateLimitMock.mockResolvedValue({ ok: true })
downloadMediaMock.mockResolvedValue({
buffer: new Uint8Array([1, 2, 3, 4]).buffer,
mime: 'image/jpeg',
fileSize: 4,
})
uploadAndExtractMock.mockResolvedValue({
document_id: 'doc-1',
inbox_item_id: 'item-1',
status: 'received',
extracted_data: {
supplier: { name: 'Espresso House' },
totals: { total: 450 },
invoice: { invoiceDate: '2026-07-30' },
},
matched_supplier_id: null,
matched_transaction_id: null,
extraction_skipped: false,
skip_reason: null,
page_count: null,
} as never)
})
afterEach(() => {
vi.clearAllMocks()
})
it('happy path: claims, ingests, marks done, and sends NO ack (the burst winner acks)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() }) // load row
enqueue({ data: { id: 'msg-1' } }) // claim
enqueue({ data: makeLink() }) // load link
enqueue({ data: makeConversation() }) // load conversation
enqueue({ data: [{ company_id: 'company-1' }] }) // sole membership
enqueue({ data: null }) // sha256 dup check: none
enqueue({ data: null }) // item channel_context load
enqueue({ data: null }) // item channel_context update
enqueue({ data: null }) // final markStatus done
const outcome = await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(outcome).toEqual({ kind: 'media_processed', conversationId: 'conv-1' })
expect(markReadWithTyping).toHaveBeenCalledWith('wamid.IN1')
expect(downloadMediaMock).toHaveBeenCalledWith('media-1')
expect(uploadAndExtractMock).toHaveBeenCalledWith(
supabase,
'user-1',
'company-1',
expect.objectContaining({ type: 'image/jpeg' }),
'whatsapp',
undefined,
undefined,
{
channelMeta: { whatsappMessageId: 'msg-1', caption: 'lunch med kund' },
actorId: 'whatsapp-inbound',
},
)
// company_selected_via lands on the item.
const itemUpdates = findCalls('invoice_inbox_items', 'update')
expect(itemUpdates).toHaveLength(1)
const contextArg = (itemUpdates[0][0] as { channel_context: Record<string, unknown> })
.channel_context
expect(contextArg.company_selected_via).toBe('single')
const finalUpdate = lastUpdate(findCalls)
expect(finalUpdate.processing_status).toBe('done')
expect(finalUpdate.inbox_item_id).toBe('item-1')
// The per-receipt ack is debounced: this worker never sends it.
expect(sendTextMock).not.toHaveBeenCalled()
})
it('does nothing when the claim is lost (already processing)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: null }) // claim matched no row
const outcome = await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(outcome).toEqual({ kind: 'none' })
expect(downloadMediaMock).not.toHaveBeenCalled()
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(sendTextMock).not.toHaveBeenCalled()
})
it('rejects disallowed MIME types with M15, skipped, and no download', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow({ media_mime: 'video/mp4', message_type: 'document' }) })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(downloadMediaMock).not.toHaveBeenCalled()
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m15Unsupported)
expect(lastUpdate(findCalls).processing_status).toBe('skipped')
})
it('multi-company sender without pin/default: parks the row and asks the company question', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }, { company_id: 'company-2' }] })
enqueue({ data: null }) // markStatus skipped (staged)
enqueue({ data: null, count: 1 }) // staged count
const outcome = await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(outcome).toEqual({ kind: 'media_staged', conversationId: 'conv-1' })
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(downloadMediaMock).not.toHaveBeenCalled()
// Parked with the staged marker, not dropped.
const staged = lastUpdate(findCalls)
expect(staged.processing_status).toBe('skipped')
expect(staged.error_message).toBe(STAGED_AWAITING_COMPANY)
expect(askCompanyQuestionMock).toHaveBeenCalledTimes(1)
expect(askCompanyQuestionMock.mock.calls[0][1]).toMatchObject({
to: '46701234567',
stagedCount: 1,
})
})
it('uses a live conversation pin over everything and stamps via=pin', async () => {
const pinExpiry = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString()
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink({ default_company_id: 'company-7' }) })
enqueue({
data: makeConversation({ company_id: 'company-9', context: { pin_expires_at: pinExpiry } }),
})
enqueue({ data: { company_id: 'company-9' } }) // pin membership check
enqueue({ data: null }) // sliding pin refresh (context update)
enqueue({ data: null }) // dup check
enqueue({ data: null }) // item context load
enqueue({ data: null }) // item context update
enqueue({ data: null }) // markStatus done
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(uploadAndExtractMock).toHaveBeenCalledWith(
supabase,
'user-1',
'company-9',
expect.anything(),
'whatsapp',
undefined,
undefined,
expect.anything(),
)
// Sliding TTL: the pin refresh pushed pin_expires_at forward.
const conversationUpdates = findCalls('whatsapp_conversations', 'update')
const refresh = conversationUpdates.find((args) => {
const patch = args[0] as { context?: { pin_expires_at?: string } }
return patch.context?.pin_expires_at != null
})
expect(refresh).toBeTruthy()
const refreshed = (refresh![0] as { context: { pin_expires_at: string } }).context
.pin_expires_at
expect(new Date(refreshed).getTime()).toBeGreaterThan(new Date(pinExpiry).getTime())
// Item records that a pin resolved the company.
const itemUpdate = findCalls('invoice_inbox_items', 'update')[0][0] as {
channel_context: Record<string, unknown>
}
expect(itemUpdate.channel_context.company_selected_via).toBe('pin')
})
it('an EXPIRED pin no longer resolves: multi-company sender is asked again', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({
data: makeConversation({
company_id: 'company-9',
context: { pin_expires_at: new Date(Date.now() - 1000).toISOString() },
}),
})
enqueue({ data: [{ company_id: 'company-1' }, { company_id: 'company-2' }] })
enqueue({ data: null }) // markStatus skipped
enqueue({ data: null, count: 1 }) // staged count
const outcome = await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(outcome.kind).toBe('media_staged')
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(askCompanyQuestionMock).toHaveBeenCalledTimes(1)
})
it('uses the default company when set and still a member', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink({ default_company_id: 'company-7' }) })
enqueue({ data: makeConversation() })
enqueue({ data: { company_id: 'company-7' } }) // membership check for default
enqueue({ data: null }) // dup check
enqueue({ data: null }) // item context load
enqueue({ data: null }) // item context update
enqueue({ data: null }) // markStatus done
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(uploadAndExtractMock).toHaveBeenCalledWith(
supabase,
'user-1',
'company-7',
expect.anything(),
'whatsapp',
undefined,
undefined,
expect.anything(),
)
})
it('rate limit: drops with M17 once, records RateLimitedDropped, never a retryable status', async () => {
rateLimitMock.mockResolvedValue({ ok: false, scope: 'minute', retryAfterSec: 60 })
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // M17 notice check: none sent yet
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(appendHistoryMock).toHaveBeenCalledWith(
expect.objectContaining({ eventType: 'RateLimitedDropped', companyId: 'company-1' }),
)
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m17RateLimited)
expect(downloadMediaMock).not.toHaveBeenCalled()
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(lastUpdate(findCalls).processing_status).toBe('skipped')
})
it('rate limit: stays silent when an M17 already went out inside the window', async () => {
rateLimitMock.mockResolvedValue({ ok: false, scope: 'minute', retryAfterSec: 60 })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: { id: 'earlier-m17' } }) // notice already sent
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(sendTextMock).not.toHaveBeenCalled()
})
it('exact sha256 duplicate: M4-duplicate ack and no item created', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // no inbox item for this message yet
enqueue({ data: { id: 'existing-doc' } }) // dup found
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m4Duplicate)
expect(lastUpdate(findCalls).processing_status).toBe('skipped')
})
it('a new file while awaiting_resend supersedes the old item and closes the question', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({
data: makeConversation({
state: 'awaiting_resend',
context: {
pending_question: {
type: 'resend',
inbox_item_id: 'item-old',
asked_at: '2026-08-01T09:30:00Z',
},
recent_questions: [
{
type: 'resend',
inbox_item_id: 'item-old',
asked_at: '2026-08-01T09:30:00Z',
status: 'open',
},
],
},
}),
})
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // dup check
enqueue({ data: null }) // new item context load
enqueue({ data: null }) // new item context update
enqueue({
data: {
channel_context: {
channel: 'whatsapp',
quality: { resend_requested_at: '2026-08-01T09:30:00Z' },
pending_question: { type: 'resend', asked_at: '2026-08-01T09:30:00Z', status: 'open' },
},
},
}) // old item context load
enqueue({ data: null }) // old item context update
enqueue({ data: null }) // conversation -> idle
enqueue({ data: { company_id: 'company-1', correlation_id: null } }) // history item lookup
enqueue({ data: null }) // markStatus done
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
const itemUpdates = findCalls('invoice_inbox_items', 'update')
const oldItemPatch = itemUpdates[1][0] as { channel_context: Record<string, unknown> }
expect(oldItemPatch.channel_context.quality).toMatchObject({
resent: true,
superseded: true,
})
expect(
(oldItemPatch.channel_context.pending_question as { status: string }).status,
).toBe('answered')
const conversationUpdates = findCalls('whatsapp_conversations', 'update')
const idleUpdate = conversationUpdates.find(
(args) => (args[0] as { state?: string }).state === 'idle',
)
expect(idleUpdate).toBeTruthy()
})
it('wraps failures: error status + error_message + a single M18', async () => {
downloadMediaMock.mockRejectedValue(new GraphApiError('Media download failed (500)', 500))
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // no inbox item yet
enqueue({ data: null }) // markStatus error
enqueue({ data: null }) // no M18 sent yet
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
const finalUpdate = lastUpdate(findCalls)
expect(finalUpdate.processing_status).toBe('error')
expect(String(finalUpdate.error_message)).toContain('download failed')
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m18Error)
})
it('still sends M18 on a re-claim when the first attempt died before the catch', async () => {
// Gating M18 on `attempt <= 1` made it unreachable for exactly the crash
// the sweep exists for: the dead first attempt already burned its
// attempt, so the sender heard nothing about that receipt at all.
downloadMediaMock.mockRejectedValue(new GraphApiError('still failing'))
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow({ attempts: 1 }) })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // no inbox item yet
enqueue({ data: null }) // markStatus error
enqueue({ data: null }) // no M18 sent for this message yet
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(lastUpdate(findCalls).processing_status).toBe('error')
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m18Error)
})
it('never repeats M18 when one already went out for the same message', async () => {
downloadMediaMock.mockRejectedValue(new GraphApiError('still failing'))
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow({ attempts: 2 }) })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // no inbox item yet
enqueue({ data: null }) // markStatus error
enqueue({ data: { id: 'out-1' } }) // an M18 for this correlation exists
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(lastUpdate(findCalls).processing_status).toBe('error')
expect(sendTextMock).not.toHaveBeenCalled()
})
it('adopts an item a concurrent worker already created instead of ingesting twice', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: makeConversation() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: { id: 'item-winner' } }) // the winner's item
enqueue({ data: null }) // markStatus done
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(downloadMediaMock).not.toHaveBeenCalled()
expect(uploadAndExtractMock).not.toHaveBeenCalled()
const finalUpdate = lastUpdate(findCalls)
expect(finalUpdate.processing_status).toBe('done')
expect(finalUpdate.inbox_item_id).toBe('item-winner')
})
})