Files
accounted/extensions/general/whatsapp-inbox/__tests__/sweep.test.ts
T
MattssonandClaude Fable 5 4a9fa5e6c5 feat(inbox): staged upload ack, HEIC/HEIF validation, WhatsApp silence fixes (#1605)
* fix(whatsapp): app-side unmute, close silent intake paths, health visibility

- add POST /link/unmute and a Reactivate control on the Pausad state
- company resolution: transient query errors release the row for sweep
  retry; genuine zero-options sends M19 instead of parking silently
- media from unlinked senders bypasses the hourly greeting throttle
  (10 min burst window, daily cap kept)
- GET /link returns 7-day failed-delivery and parked-inbound counts;
  sweep summary logs outboundFailed24h

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(documents): real HEIC/HEIF magic-byte validation, bilingual upload errors

- detect ISO-BMFF ftyp brands (heic/heix/heim/heis/hevc/hevx/hevm/hevs,
  mif1/msf1) instead of exempting image/heic from validation; declared
  heic/heif accepts either family member (iOS labels vary)
- new INBOX_UPLOAD_* structured error codes replace raw English strings
  on the inbox upload and attach-document routes
- registry doc corrected to the real 10 MB cap

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(inbox): staged upload with instant ack and deferred AI extraction

- web uploads insert the inbox item as status processing and respond
  immediately; Bedrock extraction and supplier match run via after()
  with a CAS flip to received (email and WhatsApp channels keep the
  synchronous path)
- widen invoice_inbox_items.status CHECK to include processing
  (migration 20260813180000, pg-real test included)
- crash-recovery sweep cron (*/2) flips stale processing rows;
  bulk-book skips extraction_in_progress items
- workspace: processing chip, in-flight rows disable actions, realtime
  flip, retry-extraction button for empty extractions
- picker accept list drops HEIC/HEIF so iOS transcodes library photos
  to JPEG; server allowlists unchanged (supersedes 2026-08-01 HEIC
  decision, see DECISIONS.md)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrations): bump inbox processing-status migration past main's latest

Main merged 20260813210000 while this PR was in flight; an inserted
version older than the latest applied aborts the prod db push at merge.
Renamed 20260813180000 to 20260813213000 and updated references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(decisions): log preview-tracker orphan repair after migration rename

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:57:53 +02:00

246 lines
8.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
vi.mock('@/extensions/general/whatsapp-inbox/lib/process-inbound', () => ({
processInboundMessage: vi.fn(),
finalizeBurst: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: vi.fn().mockResolvedValue('event-1'),
}))
import {
processInboundMessage,
finalizeBurst,
} from '@/extensions/general/whatsapp-inbox/lib/process-inbound'
import { runSweep } from '@/extensions/general/whatsapp-inbox/lib/sweep'
import {
COMPANY_CHOICE_EXPIRED,
STAGED_AWAITING_COMPANY,
} from '@/extensions/general/whatsapp-inbox/lib/conversation'
const processMock = vi.mocked(processInboundMessage)
const finalizeMock = vi.mocked(finalizeBurst)
const HOURS = 60 * 60 * 1000
function expiredConversation(overrides: Record<string, unknown> = {}) {
return {
id: 'conv-1',
phone_link_id: 'link-1',
state: 'awaiting_representation',
context: {
pending_question: {
type: 'representation',
inbox_item_id: 'item-9',
asked_at: new Date(Date.now() - 49 * HOURS).toISOString(),
},
recent_questions: [
{
type: 'representation',
inbox_item_id: 'item-9',
asked_at: new Date(Date.now() - 49 * HOURS).toISOString(),
status: 'open',
},
],
},
company_id: null,
service_window_expires_at: new Date(Date.now() - 25 * HOURS).toISOString(),
debounce_until: null,
pending_ack: false,
...overrides,
}
}
describe('runSweep', () => {
beforeEach(() => {
vi.clearAllMocks()
processMock.mockResolvedValue({ kind: 'media_processed', conversationId: 'conv-1' })
finalizeMock.mockResolvedValue(undefined)
})
it('re-claims stuck received rows, errors out max-attempts rows, finalizes touched bursts', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({
data: [
{ id: 'm-fresh', attempts: 1, conversation_id: 'conv-1' },
{ id: 'm-dead', attempts: 3, conversation_id: 'conv-1' },
],
}) // stuck received
enqueue({ data: null }) // m-dead -> error update
enqueue({ data: [] }) // stuck processing
enqueue({ data: [] }) // stale pending_ack
enqueue({ data: [] }) // unacked re-arm
enqueue({ data: [] }) // TTL scan
enqueue({ data: [] }) // pin scan
enqueue({ count: 2 }) // outbound failures, last 24h
const summary = await runSweep(supabase as unknown as SupabaseClient)
expect(processMock).toHaveBeenCalledTimes(1)
expect(processMock).toHaveBeenCalledWith(supabase, 'm-fresh')
expect(finalizeMock).toHaveBeenCalledWith(supabase, 'conv-1')
expect(summary.reclaimedReceived).toBe(1)
expect(summary.erroredMaxAttempts).toBe(1)
expect(summary.finalizedAcks).toBe(1)
expect(summary.outboundFailed24h).toBe(2)
const errorPatch = findCalls('whatsapp_messages', 'update')[0][0] as Record<string, unknown>
expect(errorPatch.processing_status).toBe('error')
expect(errorPatch.error_message).toBe('Max attempts exceeded')
})
it('resets stuck processing rows back to received before re-running them', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [] }) // stuck received
enqueue({ data: [{ id: 'm-stuck', attempts: 1, conversation_id: 'conv-2' }] })
enqueue({ data: { id: 'm-stuck' } }) // guarded reset won
enqueue({ data: [] }) // stale pending_ack
enqueue({ data: [] }) // unacked re-arm
enqueue({ data: [] }) // TTL scan
enqueue({ data: [] }) // pin scan
const summary = await runSweep(supabase as unknown as SupabaseClient)
const resetPatch = findCalls('whatsapp_messages', 'update')[0][0] as Record<string, unknown>
expect(resetPatch.processing_status).toBe('received')
expect(processMock).toHaveBeenCalledWith(supabase, 'm-stuck')
expect(summary.reclaimedProcessing).toBe(1)
})
it('claims stale pending_ack conversations through finalizeBurst', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [] }) // stuck received
enqueue({ data: [] }) // stuck processing
enqueue({ data: [{ id: 'conv-7' }] }) // stale pending_ack
enqueue({ data: [] }) // unacked re-arm
enqueue({ data: [] }) // TTL scan
enqueue({ data: [] }) // pin scan
await runSweep(supabase as unknown as SupabaseClient)
expect(finalizeMock).toHaveBeenCalledWith(supabase, 'conv-7')
})
it('expires a 48h-old question: item moved_to_app, conversation back to idle, NOTHING sent', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [] }) // stuck received
enqueue({ data: [] }) // stuck processing
enqueue({ data: [] }) // stale pending_ack
enqueue({ data: [] }) // unacked re-arm
enqueue({ data: [expiredConversation()] }) // TTL scan
enqueue({
data: {
channel_context: {
channel: 'whatsapp',
pending_question: {
type: 'representation',
asked_at: new Date(Date.now() - 49 * HOURS).toISOString(),
status: 'open',
},
},
},
}) // item context load
enqueue({ data: null }) // item context update
enqueue({ data: { company_id: 'company-1', correlation_id: null } }) // history lookup
enqueue({ data: null }) // conversation -> idle
enqueue({ data: [] }) // pin scan
const summary = await runSweep(supabase as unknown as SupabaseClient)
expect(summary.expiredQuestions).toBe(1)
const itemPatch = findCalls('invoice_inbox_items', 'update')[0][0] as {
channel_context: { pending_question: { status: string } }
}
expect(itemPatch.channel_context.pending_question.status).toBe('moved_to_app')
const conversationPatch = findCalls('whatsapp_conversations', 'update')[0][0] as {
state: string
context: { pending_question?: unknown; recent_questions: { status: string }[] }
}
expect(conversationPatch.state).toBe('idle')
expect(conversationPatch.context.pending_question).toBeUndefined()
expect(conversationPatch.context.recent_questions[0].status).toBe('moved_to_app')
// Expiry is a silent hand-off: the service window is long gone.
expect(finalizeMock).not.toHaveBeenCalled()
})
it('expires an abandoned company question: parked rows get the expired marker', async () => {
const { supabase, enqueue, findCalls, calls } = createQueuedMockSupabase()
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({
data: [
expiredConversation({
state: 'awaiting_company',
context: {
company_options: [{ id: 'company-1', name: 'A AB' }],
pending_question: {
type: 'company',
inbox_item_id: null,
asked_at: new Date(Date.now() - 49 * HOURS).toISOString(),
},
},
}),
],
})
enqueue({ data: null }) // staged rows marker update
enqueue({ data: null }) // conversation -> idle
enqueue({ data: [] }) // pin scan
await runSweep(supabase as unknown as SupabaseClient)
const markerPatch = findCalls('whatsapp_messages', 'update')[0][0] as Record<string, unknown>
expect(markerPatch.error_message).toBe(COMPANY_CHOICE_EXPIRED)
expect(
calls.some(
(c) =>
c.table === 'whatsapp_messages' &&
c.method === 'eq' &&
c.args[0] === 'error_message' &&
c.args[1] === STAGED_AWAITING_COMPANY,
),
).toBe(true)
const conversationPatch = findCalls('whatsapp_conversations', 'update')[0][0] as {
state: string
context: { company_options?: unknown }
}
expect(conversationPatch.state).toBe('idle')
expect(conversationPatch.context.company_options).toBeUndefined()
})
it('clears expired 8h company pins', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] }) // TTL scan
enqueue({
data: [
expiredConversation({
state: 'idle',
company_id: 'company-1',
context: { pin_expires_at: new Date(Date.now() - 1000).toISOString(), pin_source: 'button' },
}),
],
}) // pin scan
const summary = await runSweep(supabase as unknown as SupabaseClient)
expect(summary.clearedPins).toBe(1)
const patch = findCalls('whatsapp_conversations', 'update')[0][0] as {
company_id: string | null
context: { pin_expires_at?: string; pin_source?: string }
}
expect(patch.company_id).toBeNull()
expect(patch.context.pin_expires_at).toBeUndefined()
expect(patch.context.pin_source).toBeUndefined()
})
})