* fix(whatsapp): four #1991/#1992 hardening residuals in the receipt channel Sends now say HOW they failed (failure: http_rejected | transport_error), and the company question falls back to numbered text only on an HTTP rejection. A timeout means Meta may already have delivered the interactive question, so that case rolls the question back instead of putting a second copy on the phone; the next receipt re-asks. Both drains (the company answer and the single-live-company path) share one helper that stamps rows older than Meta's ~30-day media retention as company_choice_expired instead of re-opening them into the MAX_ATTEMPTS error path, and tell the sender once (M20) how many receipts could not be recovered. STAGED_MEDIA_MAX_AGE_MS moved to conversation.ts so the sweep and the drains read one definition. POST /link/default-company checks LIVE membership with the same companies!inner(archived_at) filter intake uses, so a default pointing at an archived company is refused (403) instead of saved and then silently ignored; a failed membership read is a 500, not a 403. consumeLinkCode is tri-state: a failed lookup or claim returns 'transient_error' and the webhook answers with a neutral retry (M21) instead of M2 "the code is wrong" to a user holding a valid code. In degraded mode (quota RPC down too) M21 sits behind the same fail-closed throttle as M2. Refs #2062 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv * fix(whatsapp): drain failures are reported and swept, M21 shares the M2 throttle Review pass on #2349 (CodeRabbit, four findings, one commit): - drainParkedRows reads the error of both UPDATEs, logs each, and returns failed: true. The answer stays applied (pin set, options claimed) and the single-company path still clears the dead question: both turn the rows that are still parked into orphans, and a new sweep pass re-opens parked rows whose conversation has no open company question (older than two minutes, so a row parked just before its question is armed is left alone) through the same drain and processes them. - badCodeThrottled counts M21 alongside M2, so a code-shaped flood during a lookup outage cannot earn one M21 per message in degraded mode. - The M20 expiry notice logs a failed send. It stays best-effort like M17, M18 and M19: the extension has no durable outbound retry, and the rows the notice describes are already terminal. Refs #2062 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv * fix(whatsapp): type the orphan scan rows and the cron summary fixture reopenedOrphans joined SweepSummary in the previous commit; the cron route test's fixture and the PostgREST embed cast in the orphan pass had not followed (check:types caught both). Refs #2062 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
321 lines
12 KiB
TypeScript
321 lines
12 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()
|
|
})
|
|
})
|
|
|
|
describe('runSweep: orphaned parked rows (#2062)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
processMock.mockResolvedValue({ kind: 'media_processed', conversationId: 'conv-1' })
|
|
finalizeMock.mockResolvedValue(undefined)
|
|
})
|
|
|
|
function enqueuePassesOneToFive(enqueue: (r: { data?: unknown; count?: number | null }) => void) {
|
|
enqueue({ data: [] }) // stuck received
|
|
enqueue({ data: [] }) // stuck processing
|
|
enqueue({ data: [] }) // stale pending_ack
|
|
enqueue({ data: [] }) // unacked re-arm
|
|
enqueue({ data: [] }) // TTL scan
|
|
enqueue({ data: [] }) // pin scan
|
|
enqueue({ count: 0 }) // outbound failures
|
|
}
|
|
|
|
it('re-opens parked rows whose conversation has no open company question and processes them', async () => {
|
|
const { supabase, enqueue, findCalls, calls } = createQueuedMockSupabase()
|
|
enqueuePassesOneToFive(enqueue)
|
|
enqueue({
|
|
data: [
|
|
{ conversation_id: 'conv-orphan', conversation: { context: {} } }, // options gone: orphan
|
|
{ conversation_id: 'conv-orphan', conversation: { context: {} } },
|
|
{
|
|
conversation_id: 'conv-open',
|
|
conversation: { context: { company_options: [{ id: 'company-1', name: 'A AB' }] } },
|
|
}, // question still open: leave it
|
|
],
|
|
}) // orphan scan
|
|
enqueue({ data: [] }) // drain conv-orphan: expiry stamp
|
|
enqueue({ data: [{ id: 'stg-1' }, { id: 'stg-2' }] }) // drain conv-orphan: reopen
|
|
|
|
const summary = await runSweep(supabase as unknown as SupabaseClient)
|
|
|
|
expect(summary.reopenedOrphans).toBe(2)
|
|
expect(processMock).toHaveBeenCalledWith(supabase, 'stg-1')
|
|
expect(processMock).toHaveBeenCalledWith(supabase, 'stg-2')
|
|
// Only the orphaned conversation was drained.
|
|
const drainedConversations = findCalls('whatsapp_messages', 'eq')
|
|
.filter((args) => args[0] === 'conversation_id')
|
|
.map((args) => args[1])
|
|
expect(drainedConversations).toEqual(['conv-orphan', 'conv-orphan'])
|
|
// The scan keys on the staged marker and an age guard.
|
|
expect(
|
|
calls.some(
|
|
(c) =>
|
|
c.table === 'whatsapp_messages' &&
|
|
c.method === 'eq' &&
|
|
c.args[0] === 'error_message' &&
|
|
c.args[1] === STAGED_AWAITING_COMPANY,
|
|
),
|
|
).toBe(true)
|
|
expect(calls.some((c) => c.method === 'lt' && c.args[0] === 'created_at')).toBe(true)
|
|
})
|
|
|
|
it('is a no-op when every parked row still has its question open', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueuePassesOneToFive(enqueue)
|
|
enqueue({
|
|
data: [
|
|
{
|
|
conversation_id: 'conv-open',
|
|
conversation: { context: { company_options: [{ id: 'company-1', name: 'A AB' }] } },
|
|
},
|
|
],
|
|
})
|
|
|
|
const summary = await runSweep(supabase as unknown as SupabaseClient)
|
|
expect(summary.reopenedOrphans).toBe(0)
|
|
expect(processMock).not.toHaveBeenCalled()
|
|
expect(findCalls('whatsapp_messages', 'update')).toHaveLength(0)
|
|
})
|
|
})
|