Files
accounted/lib/documents/__tests__/classifier.test.ts
T
Jakob WennbergandClaude Opus 4.6 39e407644d feat: unified document inbox, full BAS 2026, and document-transaction matching
- Expand BAS reference from ~180 to ~1,276 accounts (full BAS Kontoplan 2026)
  with K2 exclusion flags, per-class data files, and computed SRU codes
- Evolve invoice inbox into unified document inbox handling invoices, receipts,
  and government letters with AI-powered classification (Claude Haiku Vision)
- Add multi-pass document-to-transaction matching engine with greedy assignment
  for both supplier invoices (reference/amount/date/name) and receipts
  (weighted amount/merchant/date scoring)
- Add supplier invoice matching in transaction ingest pipeline
- Inject booking template suggestions into AI extraction prompts
- Surface matched documents in swipe categorization UI with one-tap booking
- Auto-activate missing BAS accounts during SIE import against full reference
- Add K2 filter toggle in Chart of Accounts manager
- Add receipt confirmation route with BFNAR representation fields
- Add database migrations for K2 support and document matching columns
- Remove obsolete extension migration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:59:02 +01:00

239 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 { classifyDocument } from '../classifier'
function makeResponse(json: Record<string, unknown>) {
return {
content: [{ type: 'text', text: JSON.stringify(json) }],
}
}
describe('classifyDocument', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('classifies a supplier invoice', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'supplier_invoice',
confidence: 0.95,
reasoning: 'Contains invoice number, bankgiro, and supplier details',
isReverseCharge: false,
})
)
const result = await classifyDocument('base64data', 'application/pdf')
expect(result.type).toBe('supplier_invoice')
expect(result.confidence).toBe(0.95)
expect(result.isReverseCharge).toBe(false)
})
it('classifies a receipt', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'receipt',
confidence: 0.92,
reasoning: 'Store receipt with line items and total',
})
)
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('receipt')
expect(result.confidence).toBe(0.92)
expect(result.isReverseCharge).toBeUndefined()
})
it('classifies a government letter', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'government_letter',
confidence: 0.88,
reasoning: 'Letter from Skatteverket',
})
)
const result = await classifyDocument('base64data', 'application/pdf')
expect(result.type).toBe('government_letter')
expect(result.confidence).toBe(0.88)
expect(result.isReverseCharge).toBeUndefined()
})
it('classifies unknown documents', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'unknown',
confidence: 0.5,
reasoning: 'Cannot determine document type',
})
)
const result = await classifyDocument('base64data', 'image/png')
expect(result.type).toBe('unknown')
expect(result.confidence).toBe(0.5)
})
it('detects reverse charge on EU invoices', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'supplier_invoice',
confidence: 0.93,
reasoning: 'EU invoice with reverse charge',
isReverseCharge: true,
})
)
const result = await classifyDocument('base64data', 'application/pdf')
expect(result.type).toBe('supplier_invoice')
expect(result.isReverseCharge).toBe(true)
})
it('retries on API error', async () => {
mockCreate
.mockRejectedValueOnce(new Error('API timeout'))
.mockResolvedValueOnce(
makeResponse({
type: 'receipt',
confidence: 0.9,
reasoning: 'Receipt',
})
)
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('receipt')
expect(mockCreate).toHaveBeenCalledTimes(2)
})
it('throws on JSON parse error without retry', async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: 'text', text: 'not valid json' }],
})
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
'Failed to parse AI response'
)
expect(mockCreate).toHaveBeenCalledTimes(1)
})
it('throws on unsupported MIME type', async () => {
await expect(classifyDocument('base64data', 'text/plain')).rejects.toThrow(
'Unsupported file type: text/plain'
)
expect(mockCreate).not.toHaveBeenCalled()
})
it('falls back to unknown for invalid type values', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'invalid_type',
confidence: 0.8,
reasoning: 'Test',
})
)
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('unknown')
})
it('handles PDF content blocks correctly', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'supplier_invoice',
confidence: 0.95,
reasoning: 'PDF invoice',
isReverseCharge: false,
})
)
await classifyDocument('base64data', 'application/pdf')
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'document' }),
]),
},
],
})
)
})
it('handles image content blocks correctly', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({
type: 'receipt',
confidence: 0.9,
reasoning: 'Image receipt',
})
)
await classifyDocument('base64data', 'image/png')
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'image' }),
]),
},
],
})
)
})
it('strips markdown code blocks from response', async () => {
mockCreate.mockResolvedValueOnce({
content: [
{
type: 'text',
text: '```json\n{"type":"receipt","confidence":0.9,"reasoning":"Test"}\n```',
},
],
})
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('receipt')
})
it('throws after max retries', async () => {
mockCreate
.mockRejectedValueOnce(new Error('API error 1'))
.mockRejectedValueOnce(new Error('API error 2'))
.mockRejectedValueOnce(new Error('API error 3'))
await expect(classifyDocument('base64data', 'image/jpeg')).rejects.toThrow(
'Document classification failed after 3 attempts'
)
expect(mockCreate).toHaveBeenCalledTimes(3)
})
})