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 = {}) { 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 = {}) { 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 = {}) { 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 { const updates = findCalls('whatsapp_messages', 'update') return updates[updates.length - 1][0] as Record } 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 }) .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 } 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 } 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') }) })