PR4 of the WhatsApp intake track: turns the per-message PR3 pipeline into a
conversation. Media replies are burst-debounced into ONE combined ack (M4
single / M5 numbered list) sent by the single winner of the atomic
pending_ack claim; losers stay silent. Multi-company senders get the company
question (reply buttons <=3, list 4-10, numbered text >10) with an 8h
sliding pin ('byt' clears it); their receipts park as staged message rows
until the answer and then run through the normal intake path.
Clarifying questions are evaluated per receipt after extraction, max one per
receipt, priority unreadable > representation > partial, keyed on the
Phase-0 classification (legibility/documentKind/merchantCategory) with
heuristic fallbacks (compressed-chat-photo signal, extended meal regex).
Budgets: <=2 content questions per burst, <=6 per sender per Stockholm day;
over budget acks only and flags the item moved_to_app. Questions expire
after 48h (sweep, silent hand-off) and are asked exactly once.
Free-text answers route through the ONE new LLM call
(lib/interpret-answer.ts): Sonnet via Bedrock, max_tokens 600, no thinking,
forced tool call validated by Zod with hard caps, gated by
checkAgentRateLimit, reply framed as untrusted data. Any failure degrades to
storing the raw text as a note; exact 'nej' short-circuits without the LLM.
Answers land in invoice_inbox_items.channel_context
(representation/user_note/quality) with ChannelQuestionAsked/Answered
processing-history events. Late answers match by quoted wamid or the most
recent open question within 7 days.
New per-minute sweep cron (registry-gated physical route, 503
EXTENSION_DISABLED when off) re-claims stuck rows (max 3 attempts), rescues
crashed burst acks, expires questions and pins. One new migration
(20260802210000) adds whatsapp_messages.acked_at, the relational burst-
membership marker, with pg-real coverage for the single-winner claim.
Verified: full vitest suite (12270), pg-real against a migrated
supabase/postgres 15 (977), lint 0 errors, tsc at the 405 baseline,
check:guards green, crontabs regenerated. Mutation-checked the debounce
claim and the daily budget gate.
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
522 lines
20 KiB
TypeScript
522 lines
20 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: { 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 }) // markStatus error
|
|
|
|
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('suppresses M18 on re-claims (attempts > 1)', async () => {
|
|
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 }) // markStatus error
|
|
|
|
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
|
|
|
|
expect(lastUpdate(findCalls).processing_status).toBe('error')
|
|
expect(sendTextMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|