3f255ea201
Extract shared vision/document analysis logic into lib/ai (vision-client, document-analyzer, image preprocessing, validation helpers), simplifying invoice-analyzer, receipt-analyzer, and document classifier. Add migration 046 for journal entry reversal/correction link columns (reversed_by_id, reverses_id, correction_of_id) required by storno service. Update extension components and shared UI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
159 lines
4.9 KiB
TypeScript
159 lines
4.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
|
|
|
// Mock server-only
|
|
vi.mock('server-only', () => ({}))
|
|
|
|
// Hoisted mocks to avoid reference-before-initialization
|
|
const { mockVerify, mockServiceClientFn } = vi.hoisted(() => {
|
|
return {
|
|
mockVerify: vi.fn(),
|
|
mockServiceClientFn: vi.fn(),
|
|
}
|
|
})
|
|
|
|
// Mock svix
|
|
vi.mock('svix', () => ({
|
|
Webhook: class MockWebhook {
|
|
verify = mockVerify
|
|
},
|
|
}))
|
|
|
|
// Mock Supabase SSR
|
|
vi.mock('@supabase/ssr', () => ({
|
|
createServerClient: (...args: unknown[]) => mockServiceClientFn(...args),
|
|
}))
|
|
|
|
// Mock email handler
|
|
vi.mock('@/extensions/general/invoice-inbox/lib/email-handler', () => ({
|
|
parseInboundPayload: vi.fn(),
|
|
extractAttachments: vi.fn(),
|
|
resolveUserFromEmail: vi.fn(),
|
|
}))
|
|
|
|
// Mock unified document analyzer
|
|
vi.mock('@/lib/ai/document-analyzer', () => ({
|
|
analyzeDocument: vi.fn(),
|
|
}))
|
|
|
|
// Mock supplier matcher
|
|
vi.mock('@/extensions/general/invoice-inbox/lib/supplier-matcher', () => ({
|
|
matchSupplier: vi.fn(),
|
|
}))
|
|
|
|
// Mock receipt pipeline
|
|
vi.mock('@/extensions/general/receipt-ocr/lib/receipt-pipeline', () => ({
|
|
processReceiptFromDocument: vi.fn(),
|
|
}))
|
|
|
|
import { POST } from '../route'
|
|
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
|
|
|
|
const mockParseInboundPayload = vi.mocked(parseInboundPayload)
|
|
const mockExtractAttachments = vi.mocked(extractAttachments)
|
|
const mockResolveUserFromEmail = vi.mocked(resolveUserFromEmail)
|
|
|
|
describe('Invoice Inbox Webhook Route', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.RESEND_WEBHOOK_SECRET = 'test-secret'
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key'
|
|
})
|
|
|
|
function makeWebhookRequest(body: unknown = {}) {
|
|
const bodyStr = JSON.stringify(body)
|
|
return new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'svix-id': 'msg_test123',
|
|
'svix-timestamp': String(Math.floor(Date.now() / 1000)),
|
|
'svix-signature': 'v1,test-signature',
|
|
},
|
|
body: bodyStr,
|
|
})
|
|
}
|
|
|
|
it('returns 400 when webhook headers are missing', async () => {
|
|
const request = new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: '{}',
|
|
})
|
|
|
|
const response = await POST(request)
|
|
const { status } = await parseJsonResponse(response)
|
|
expect(status).toBe(400)
|
|
})
|
|
|
|
it('returns 401 when signature verification fails', async () => {
|
|
mockVerify.mockImplementation(() => {
|
|
throw new Error('Invalid signature')
|
|
})
|
|
|
|
const response = await POST(makeWebhookRequest({ from: 'test@test.com', to: 'inbox@co.com' }))
|
|
const { status } = await parseJsonResponse(response)
|
|
expect(status).toBe(401)
|
|
})
|
|
|
|
it('returns 400 when payload is invalid', async () => {
|
|
mockVerify.mockReturnValue(undefined)
|
|
mockParseInboundPayload.mockReturnValue(null)
|
|
|
|
const response = await POST(makeWebhookRequest({}))
|
|
const { status } = await parseJsonResponse(response)
|
|
expect(status).toBe(400)
|
|
})
|
|
|
|
it('returns 404 when user not found for email', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
mockServiceClientFn.mockReturnValue(supabase)
|
|
|
|
mockVerify.mockReturnValue(undefined)
|
|
mockParseInboundPayload.mockReturnValue({
|
|
from: 'supplier@test.com',
|
|
to: 'unknown@inbox.com',
|
|
subject: 'Invoice',
|
|
html: null,
|
|
text: null,
|
|
attachments: [],
|
|
created_at: '2024-06-15T10:00:00Z',
|
|
})
|
|
mockResolveUserFromEmail.mockResolvedValue(null)
|
|
|
|
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'unknown@inbox.com' }))
|
|
const { status } = await parseJsonResponse(response)
|
|
expect(status).toBe(404)
|
|
})
|
|
|
|
it('returns success with 0 processed when no attachments', async () => {
|
|
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
|
mockServiceClientFn.mockReturnValue(supabase)
|
|
|
|
mockVerify.mockReturnValue(undefined)
|
|
mockParseInboundPayload.mockReturnValue({
|
|
from: 'supplier@test.com',
|
|
to: 'inbox@myco.com',
|
|
subject: 'No attachments',
|
|
html: null,
|
|
text: null,
|
|
attachments: [],
|
|
created_at: '2024-06-15T10:00:00Z',
|
|
})
|
|
mockExtractAttachments.mockReturnValue([])
|
|
mockResolveUserFromEmail.mockResolvedValue('user-1')
|
|
|
|
// Insert inbox item with error status
|
|
enqueueMany([
|
|
{ data: { id: 'item-1' }, error: null },
|
|
])
|
|
|
|
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'inbox@myco.com' }))
|
|
const { status, body } = await parseJsonResponse<{ data: { processed: number } }>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.data.processed).toBe(0)
|
|
})
|
|
})
|