* 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>
169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
/**
|
|
* Route tests for the authenticated /link endpoints: unmute from the app
|
|
* (the in-chat counterpart is the literal text `start`) and the health
|
|
* counters GET /link exposes for the settings panel.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import type { ExtensionContext } from '@/lib/extensions/types'
|
|
|
|
vi.mock('@supabase/supabase-js', () => ({
|
|
createClient: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createServiceClient: vi.fn(),
|
|
}))
|
|
|
|
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', errorDetail: null }),
|
|
getDisplayPhoneNumber: vi.fn().mockResolvedValue(null),
|
|
}
|
|
})
|
|
|
|
vi.mock('@/extensions/general/whatsapp-inbox/lib/process-inbound', () => ({
|
|
kickInboundProcessing: vi.fn(),
|
|
}))
|
|
|
|
import { createServiceClient } from '@/lib/supabase/server'
|
|
import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox'
|
|
|
|
function findRoute(method: string, path: string) {
|
|
return whatsappInboxExtension.apiRoutes!.find((r) => r.method === method && r.path === path)!
|
|
}
|
|
|
|
function makeCtx(supabase: unknown): ExtensionContext {
|
|
return {
|
|
userId: 'user-1',
|
|
companyId: 'company-1',
|
|
extensionId: 'whatsapp-inbox',
|
|
supabase,
|
|
} as unknown as ExtensionContext
|
|
}
|
|
|
|
function request(path: string, method = 'POST'): Request {
|
|
return new Request(`http://localhost:3000/api/extensions/ext/whatsapp-inbox${path}`, { method })
|
|
}
|
|
|
|
describe('POST /link/unmute', () => {
|
|
const route = findRoute('POST', '/link/unmute')
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('401s without an authenticated context', async () => {
|
|
const response = await route.handler(request('/link/unmute'))
|
|
expect(response.status).toBe(401)
|
|
})
|
|
|
|
it('clears muted_at on the caller own active link', async () => {
|
|
const { supabase, enqueue, findCall, calls } = createQueuedMockSupabase()
|
|
enqueue({ data: [{ id: 'link-1' }] }) // guarded update matched the row
|
|
|
|
const response = await route.handler(request('/link/unmute'), makeCtx(supabase))
|
|
expect(response.status).toBe(200)
|
|
const body = (await response.json()) as { data: { unmuted: boolean } }
|
|
expect(body.data).toEqual({ unmuted: true })
|
|
|
|
const [patch] = findCall('whatsapp_phone_links', 'update') as [Record<string, unknown>]
|
|
expect(patch).toEqual({ muted_at: null })
|
|
// Scoped to the caller's own active link.
|
|
expect(
|
|
calls.some(
|
|
(c) =>
|
|
c.table === 'whatsapp_phone_links' &&
|
|
c.method === 'eq' &&
|
|
c.args[0] === 'user_id' &&
|
|
c.args[1] === 'user-1',
|
|
),
|
|
).toBe(true)
|
|
expect(
|
|
calls.some(
|
|
(c) =>
|
|
c.table === 'whatsapp_phone_links' && c.method === 'is' && c.args[0] === 'revoked_at',
|
|
),
|
|
).toBe(true)
|
|
})
|
|
|
|
it('404s when there is no active link', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: [] }) // no row matched
|
|
|
|
const response = await route.handler(request('/link/unmute'), makeCtx(supabase))
|
|
expect(response.status).toBe(404)
|
|
})
|
|
})
|
|
|
|
describe('GET /link', () => {
|
|
const route = findRoute('GET', '/link')
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('401s without an authenticated context', async () => {
|
|
const response = await route.handler(request('/link', 'GET'))
|
|
expect(response.status).toBe(401)
|
|
})
|
|
|
|
it('includes the 7-day health counters for the caller link only', async () => {
|
|
const userMock = createQueuedMockSupabase()
|
|
userMock.enqueue({
|
|
data: {
|
|
id: 'link-1',
|
|
phone_masked: '+46 70 *** ** 67',
|
|
default_company_id: null,
|
|
muted_at: null,
|
|
verified_at: '2026-08-01T09:00:00Z',
|
|
},
|
|
})
|
|
const serviceMock = createQueuedMockSupabase()
|
|
serviceMock.enqueue({
|
|
data: {
|
|
created_at: '2026-08-12T10:00:00Z',
|
|
processing_status: 'done',
|
|
error_message: null,
|
|
inbox_item_id: 'item-1',
|
|
},
|
|
}) // last inbound
|
|
serviceMock.enqueue({ data: { delivery_status: 'sent' } }) // last outbound
|
|
serviceMock.enqueue({ count: 2 }) // outboundFailed7d
|
|
serviceMock.enqueue({ count: 1 }) // parkedInbound7d
|
|
vi.mocked(createServiceClient).mockReturnValue(serviceMock.supabase as never)
|
|
|
|
const response = await route.handler(request('/link', 'GET'), makeCtx(userMock.supabase))
|
|
expect(response.status).toBe(200)
|
|
const { data } = (await response.json()) as {
|
|
data: { linked: boolean; health: { outboundFailed7d: number; parkedInbound7d: number } }
|
|
}
|
|
expect(data.linked).toBe(true)
|
|
expect(data.health).toEqual({ outboundFailed7d: 2, parkedInbound7d: 1 })
|
|
// Both head counts are keyed strictly by the link row RLS just proved
|
|
// the caller owns.
|
|
const linkScoped = serviceMock.calls.filter(
|
|
(c) =>
|
|
c.table === 'whatsapp_messages' &&
|
|
c.method === 'eq' &&
|
|
c.args[0] === 'phone_link_id' &&
|
|
c.args[1] === 'link-1',
|
|
)
|
|
expect(linkScoped.length).toBeGreaterThanOrEqual(4)
|
|
})
|
|
|
|
it('reports linked: false without touching the service client', async () => {
|
|
const userMock = createQueuedMockSupabase()
|
|
userMock.enqueue({ data: null }) // no active link row
|
|
|
|
const response = await route.handler(request('/link', 'GET'), makeCtx(userMock.supabase))
|
|
const { data } = (await response.json()) as { data: { linked: boolean } }
|
|
expect(data).toEqual({ linked: false })
|
|
expect(vi.mocked(createServiceClient)).not.toHaveBeenCalled()
|
|
})
|
|
})
|