188 lines
6.2 KiB
TypeScript
188 lines
6.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// Mock server-only
|
|
vi.mock('server-only', () => ({}))
|
|
|
|
// Mock Anthropic SDK - vi.hoisted ensures the variable is available before vi.mock hoisting
|
|
const { mockCreate } = vi.hoisted(() => {
|
|
const mockCreate = vi.fn()
|
|
return { mockCreate }
|
|
})
|
|
|
|
vi.mock('@anthropic-ai/sdk', () => {
|
|
return {
|
|
default: class MockAnthropic {
|
|
messages = { create: mockCreate }
|
|
},
|
|
}
|
|
})
|
|
|
|
import { analyzeInvoice } from '../invoice-analyzer'
|
|
|
|
describe('Invoice Analyzer', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
const validExtractionJson = JSON.stringify({
|
|
supplier: {
|
|
name: 'Kontorsbolaget AB',
|
|
orgNumber: '556123-4567',
|
|
vatNumber: 'SE5561234567',
|
|
address: 'Storgatan 1, 111 22 Stockholm',
|
|
bankgiro: '123-4567',
|
|
plusgiro: null,
|
|
},
|
|
invoice: {
|
|
invoiceNumber: 'F-2024-001',
|
|
invoiceDate: '2024-06-15',
|
|
dueDate: '2024-07-15',
|
|
paymentReference: '1234567890',
|
|
currency: 'SEK',
|
|
},
|
|
lineItems: [
|
|
{
|
|
description: 'Kontorsmaterial',
|
|
quantity: 10,
|
|
unitPrice: 50,
|
|
lineTotal: 500,
|
|
vatRate: 25,
|
|
accountSuggestion: '6100',
|
|
},
|
|
],
|
|
totals: {
|
|
subtotal: 500,
|
|
vatAmount: 125,
|
|
total: 625,
|
|
},
|
|
vatBreakdown: [
|
|
{ rate: 25, base: 500, amount: 125 },
|
|
],
|
|
confidence: 0.92,
|
|
})
|
|
|
|
it('parses valid AI response for PDF', async () => {
|
|
mockCreate.mockResolvedValue({
|
|
content: [{ type: 'text', text: validExtractionJson }],
|
|
})
|
|
|
|
const result = await analyzeInvoice('base64data', 'application/pdf')
|
|
|
|
expect(result.supplier.name).toBe('Kontorsbolaget AB')
|
|
expect(result.supplier.orgNumber).toBe('556123-4567')
|
|
expect(result.invoice.invoiceNumber).toBe('F-2024-001')
|
|
expect(result.lineItems).toHaveLength(1)
|
|
expect(result.lineItems[0].lineTotal).toBe(500)
|
|
expect(result.totals.total).toBe(625)
|
|
expect(result.confidence).toBe(0.92)
|
|
})
|
|
|
|
it('parses valid AI response for image', async () => {
|
|
mockCreate.mockResolvedValue({
|
|
content: [{ type: 'text', text: validExtractionJson }],
|
|
})
|
|
|
|
const result = await analyzeInvoice('base64data', 'image/jpeg')
|
|
|
|
expect(result.supplier.name).toBe('Kontorsbolaget AB')
|
|
expect(mockCreate).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('strips markdown code blocks from response', async () => {
|
|
mockCreate.mockResolvedValue({
|
|
content: [{ type: 'text', text: '```json\n' + validExtractionJson + '\n```' }],
|
|
})
|
|
|
|
const result = await analyzeInvoice('base64data', 'application/pdf')
|
|
expect(result.supplier.name).toBe('Kontorsbolaget AB')
|
|
})
|
|
|
|
it('validates org number format', async () => {
|
|
mockCreate.mockResolvedValue({
|
|
content: [{ type: 'text', text: JSON.stringify({
|
|
supplier: { name: 'Test', orgNumber: '5561234567', vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
|
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
|
|
lineItems: [],
|
|
totals: { subtotal: 0, vatAmount: 0, total: 0 },
|
|
vatBreakdown: [],
|
|
confidence: 0.5,
|
|
}) }],
|
|
})
|
|
|
|
const result = await analyzeInvoice('base64data', 'application/pdf')
|
|
expect(result.supplier.orgNumber).toBe('556123-4567') // Formatted with dash
|
|
})
|
|
|
|
it('rejects invalid VAT numbers', async () => {
|
|
mockCreate.mockResolvedValue({
|
|
content: [{ type: 'text', text: JSON.stringify({
|
|
supplier: { name: 'Test', orgNumber: null, vatNumber: 'DE123', address: null, bankgiro: null, plusgiro: null },
|
|
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
|
|
lineItems: [],
|
|
totals: { subtotal: 0, vatAmount: 0, total: 0 },
|
|
vatBreakdown: [],
|
|
confidence: 0.5,
|
|
}) }],
|
|
})
|
|
|
|
const result = await analyzeInvoice('base64data', 'application/pdf')
|
|
expect(result.supplier.vatNumber).toBeNull() // Not SE-prefixed
|
|
})
|
|
|
|
it('throws on JSON parse error without retrying', async () => {
|
|
mockCreate.mockResolvedValue({
|
|
content: [{ type: 'text', text: 'not json at all' }],
|
|
})
|
|
|
|
await expect(analyzeInvoice('base64data', 'application/pdf')).rejects.toThrow('Failed to parse AI response')
|
|
expect(mockCreate).toHaveBeenCalledTimes(1) // No retry for parse errors
|
|
})
|
|
|
|
it('retries on API errors', async () => {
|
|
mockCreate
|
|
.mockRejectedValueOnce(new Error('API timeout'))
|
|
.mockResolvedValueOnce({
|
|
content: [{ type: 'text', text: validExtractionJson }],
|
|
})
|
|
|
|
const result = await analyzeInvoice('base64data', 'application/pdf')
|
|
expect(result.supplier.name).toBe('Kontorsbolaget AB')
|
|
expect(mockCreate).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('throws after max retries', async () => {
|
|
mockCreate.mockRejectedValue(new Error('API timeout'))
|
|
|
|
await expect(analyzeInvoice('base64data', 'application/pdf')).rejects.toThrow(
|
|
'Invoice analysis failed after 3 attempts'
|
|
)
|
|
expect(mockCreate).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('rejects unsupported file types', async () => {
|
|
await expect(analyzeInvoice('base64data', 'text/plain')).rejects.toThrow(
|
|
'Unsupported file type'
|
|
)
|
|
})
|
|
|
|
it('validates account number suggestions', async () => {
|
|
mockCreate.mockResolvedValue({
|
|
content: [{ type: 'text', text: JSON.stringify({
|
|
supplier: { name: 'Test', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
|
|
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
|
|
lineItems: [
|
|
{ description: 'Item', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25, accountSuggestion: '6100' },
|
|
{ description: 'Bad', quantity: 1, unitPrice: 50, lineTotal: 50, vatRate: 25, accountSuggestion: 'abc' },
|
|
],
|
|
totals: { subtotal: 150, vatAmount: 37.5, total: 187.5 },
|
|
vatBreakdown: [],
|
|
confidence: 0.8,
|
|
}) }],
|
|
})
|
|
|
|
const result = await analyzeInvoice('base64data', 'application/pdf')
|
|
expect(result.lineItems[0].accountSuggestion).toBe('6100')
|
|
expect(result.lineItems[1].accountSuggestion).toBeNull()
|
|
})
|
|
})
|