* 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>
136 lines
4.5 KiB
TypeScript
136 lines
4.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { runInboxSweep, PROCESSING_STUCK_MS } from '../lib/sweep'
|
|
import { emptyResult } from '../lib/extract-invoice-fields'
|
|
|
|
/**
|
|
* The staged-upload crash recovery: only rows that are (a) still
|
|
* 'processing', (b) older than the stuck threshold, and (c) still carrying a
|
|
* NULL extracted_data are flipped, and the flip writes the same empty
|
|
* skeleton the synchronous swallow-failure path persists. No re-extraction.
|
|
*/
|
|
|
|
interface Captured {
|
|
selectFilters: Array<{ method: string; args: unknown[] }>
|
|
updatePayload?: Record<string, unknown>
|
|
updateFilters: Array<{ method: string; args: unknown[] }>
|
|
}
|
|
|
|
function makeSupabase(opts: {
|
|
staleIds: string[]
|
|
claimedIds?: string[]
|
|
selectError?: { message: string }
|
|
updateError?: { message: string }
|
|
}) {
|
|
const captured: Captured = { selectFilters: [], updateFilters: [] }
|
|
|
|
const selectChain = {
|
|
eq: vi.fn((...args: unknown[]) => {
|
|
captured.selectFilters.push({ method: 'eq', args })
|
|
return selectChain
|
|
}),
|
|
lt: vi.fn((...args: unknown[]) => {
|
|
captured.selectFilters.push({ method: 'lt', args })
|
|
return selectChain
|
|
}),
|
|
limit: vi.fn().mockResolvedValue(
|
|
opts.selectError
|
|
? { data: null, error: opts.selectError }
|
|
: { data: opts.staleIds.map((id) => ({ id })), error: null },
|
|
),
|
|
}
|
|
|
|
const updateChain = {
|
|
in: vi.fn((...args: unknown[]) => {
|
|
captured.updateFilters.push({ method: 'in', args })
|
|
return updateChain
|
|
}),
|
|
eq: vi.fn((...args: unknown[]) => {
|
|
captured.updateFilters.push({ method: 'eq', args })
|
|
return updateChain
|
|
}),
|
|
is: vi.fn((...args: unknown[]) => {
|
|
captured.updateFilters.push({ method: 'is', args })
|
|
return updateChain
|
|
}),
|
|
select: vi.fn().mockResolvedValue(
|
|
opts.updateError
|
|
? { data: null, error: opts.updateError }
|
|
: { data: (opts.claimedIds ?? opts.staleIds).map((id) => ({ id })), error: null },
|
|
),
|
|
}
|
|
|
|
const supabase = {
|
|
from: vi.fn(() => ({
|
|
select: vi.fn(() => selectChain),
|
|
update: vi.fn((payload: Record<string, unknown>) => {
|
|
captured.updatePayload = payload
|
|
return updateChain
|
|
}),
|
|
})),
|
|
}
|
|
|
|
return { supabase: supabase as never, captured }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('runInboxSweep', () => {
|
|
it('flips stale processing rows to received with the empty skeleton', async () => {
|
|
const { supabase, captured } = makeSupabase({ staleIds: ['i1', 'i2'] })
|
|
|
|
const summary = await runInboxSweep(supabase)
|
|
|
|
expect(summary).toEqual({ flipped: 2 })
|
|
// The stale scan targets processing rows older than the threshold.
|
|
expect(captured.selectFilters).toEqual([
|
|
{ method: 'eq', args: ['status', 'processing'] },
|
|
{ method: 'lt', args: ['created_at', expect.any(String)] },
|
|
])
|
|
const cutoff = new Date(captured.selectFilters[1].args[1] as string).getTime()
|
|
expect(Date.now() - cutoff).toBeGreaterThanOrEqual(PROCESSING_STUCK_MS - 1000)
|
|
// The flip is a guarded claim: status still processing, data still NULL.
|
|
expect(captured.updateFilters).toEqual([
|
|
{ method: 'in', args: ['id', ['i1', 'i2']] },
|
|
{ method: 'eq', args: ['status', 'processing'] },
|
|
{ method: 'is', args: ['extracted_data', null] },
|
|
])
|
|
expect(captured.updatePayload).toEqual({
|
|
status: 'received',
|
|
extracted_data: emptyResult(),
|
|
extraction_skipped: false,
|
|
})
|
|
})
|
|
|
|
it('counts only the rows the guarded update actually claimed', async () => {
|
|
// A worker finished i2 between the scan and the flip: its result wins.
|
|
const { supabase } = makeSupabase({ staleIds: ['i1', 'i2'], claimedIds: ['i1'] })
|
|
|
|
const summary = await runInboxSweep(supabase)
|
|
|
|
expect(summary).toEqual({ flipped: 1 })
|
|
})
|
|
|
|
it('does nothing when no processing row is stale', async () => {
|
|
const { supabase, captured } = makeSupabase({ staleIds: [] })
|
|
|
|
const summary = await runInboxSweep(supabase)
|
|
|
|
expect(summary).toEqual({ flipped: 0 })
|
|
expect(captured.updatePayload).toBeUndefined()
|
|
})
|
|
|
|
it('never throws: a failed select reports zero flips', async () => {
|
|
const { supabase } = makeSupabase({ staleIds: [], selectError: { message: 'boom' } })
|
|
|
|
await expect(runInboxSweep(supabase)).resolves.toEqual({ flipped: 0 })
|
|
})
|
|
|
|
it('never throws: a failed update reports zero flips', async () => {
|
|
const { supabase } = makeSupabase({ staleIds: ['i1'], updateError: { message: 'boom' } })
|
|
|
|
await expect(runInboxSweep(supabase)).resolves.toEqual({ flipped: 0 })
|
|
})
|
|
})
|