feat: remove AI extensions, restructure settings, and add atomic voucher commits (#157)

Remove AI-dependent extensions (ai-chat, ai-categorization, receipt-ocr,
invoice-inbox) and their infrastructure (lib/ai/*, ai-consent, LangChain/
Anthropic/OpenAI deps) to simplify core and reduce bundle size.

Restructure monolithic settings page into dedicated sub-pages (company,
bookkeeping, invoicing, tax, banking, api, account, team, templates) with
shared layout and sidebar navigation.

Add atomic commit_journal_entry RPC so voucher number increment and status
update happen in a single transaction — prevents burned numbers on constraint
failures. Add continuity check report and voucher gap explanation tracking.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-01 17:08:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e89f2c402d
commit d0b3f21bde
172 changed files with 3621 additions and 20538 deletions
-378
View File
@@ -1,378 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock server-only
vi.mock('server-only', () => ({}))
// Mock vision client
const { mockCallVision } = vi.hoisted(() => ({
mockCallVision: vi.fn(),
}))
vi.mock('../vision-client', () => ({
callVision: mockCallVision,
}))
// Mock template prompt
vi.mock('@/lib/bookkeeping/template-prompt', () => ({
buildTemplatePromptSection: () => 'TEMPLATE_SECTION',
}))
import {
analyzeDocument,
extractReceipt,
extractInvoice,
classifyDocument,
validateExtractionConsistency,
} from '../document-analyzer'
const mockReceiptResponse = {
merchant: { name: 'ICA Maxi', orgNumber: '5561234567', vatNumber: null, isForeign: false },
receipt: { date: '2024-06-15', time: '14:30', currency: 'SEK' },
lineItems: [
{ description: 'Mjölk', quantity: 1, unitPrice: 15, lineTotal: 15, vatRate: 12, suggestedCategory: 'other' },
],
totals: { subtotal: 13.39, vatAmount: 1.61, total: 15 },
flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
confidence: 0.92,
}
const mockInvoiceResponse = {
supplier: { name: 'Telenor AB', orgNumber: '5561234567', vatNumber: 'SE556123456701', address: 'Stockholm', bankgiro: '123-4567', plusgiro: null },
invoice: { invoiceNumber: 'F-2024-001', invoiceDate: '2024-06-01', dueDate: '2024-06-30', paymentReference: '1234567890', currency: 'SEK' },
lineItems: [
{ description: 'Mobilabonnemang', quantity: 1, unitPrice: 299, lineTotal: 299, vatRate: 25, accountSuggestion: '6200' },
],
totals: { subtotal: 239.2, vatAmount: 59.8, total: 299 },
vatBreakdown: [{ rate: 25, base: 239.2, amount: 59.8 }],
confidence: 0.95,
}
describe('classifyDocument', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('classifies a supplier invoice', async () => {
mockCallVision.mockResolvedValueOnce({
type: 'supplier_invoice',
confidence: 0.95,
reasoning: 'Contains invoice number and bankgiro',
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)
expect(mockCallVision).toHaveBeenCalledWith(
expect.objectContaining({ maxTokens: 1024 })
)
})
it('classifies a receipt', async () => {
mockCallVision.mockResolvedValueOnce({
type: 'receipt',
confidence: 0.92,
reasoning: 'Store receipt',
})
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('receipt')
expect(result.isReverseCharge).toBeUndefined()
})
it('falls back to unknown for invalid type', async () => {
mockCallVision.mockResolvedValueOnce({
type: 'invalid_type',
confidence: 0.8,
reasoning: 'Test',
})
const result = await classifyDocument('base64data', 'image/jpeg')
expect(result.type).toBe('unknown')
})
})
describe('extractReceipt', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('extracts receipt data', async () => {
mockCallVision.mockResolvedValueOnce(mockReceiptResponse)
const result = await extractReceipt('base64data', 'image/jpeg')
expect(result.merchant.name).toBe('ICA Maxi')
expect(result.merchant.orgNumber).toBe('556123-4567')
expect(result.receipt.date).toBe('2024-06-15')
expect(result.lineItems).toHaveLength(1)
expect(result.totals.total).toBe(15)
expect(result.confidence).toBe(0.92)
})
it('retries with correction prompt on consistency failure', async () => {
const badResponse = {
...mockReceiptResponse,
lineItems: [
{ description: 'Item', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25 },
],
totals: { subtotal: 80, vatAmount: 20, total: 200 }, // 100 != 200 → inconsistent
confidence: 0.9,
}
const goodResponse = {
...mockReceiptResponse,
lineItems: [
{ description: 'Item', quantity: 1, unitPrice: 200, lineTotal: 200, vatRate: 25 },
],
totals: { subtotal: 160, vatAmount: 40, total: 200 },
confidence: 0.85,
}
mockCallVision
.mockResolvedValueOnce(badResponse)
.mockResolvedValueOnce(goodResponse)
const result = await extractReceipt('base64data', 'image/jpeg')
expect(mockCallVision).toHaveBeenCalledTimes(2)
expect(result.totals.total).toBe(200)
expect(result.lineItems[0].lineTotal).toBe(200)
})
it('returns original with reduced confidence if retry also fails', async () => {
const badResponse = {
...mockReceiptResponse,
lineItems: [
{ description: 'Item', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25 },
],
totals: { subtotal: 80, vatAmount: 20, total: 200 },
confidence: 0.9,
}
mockCallVision
.mockResolvedValueOnce(badResponse)
.mockRejectedValueOnce(new Error('Retry failed'))
const result = await extractReceipt('base64data', 'image/jpeg')
expect(result.confidence).toBeCloseTo(0.63, 1) // 0.9 * 0.7
})
})
describe('extractInvoice', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('extracts invoice data', async () => {
mockCallVision.mockResolvedValueOnce(mockInvoiceResponse)
const result = await extractInvoice('base64data', 'application/pdf')
expect(result.supplier.name).toBe('Telenor 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].accountSuggestion).toBe('6200')
expect(result.vatBreakdown).toHaveLength(1)
expect(result.confidence).toBe(0.95)
})
})
describe('analyzeDocument', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('classifies and extracts receipt in one call', async () => {
mockCallVision.mockResolvedValueOnce({
classification: { type: 'receipt', confidence: 0.93, reasoning: 'Receipt' },
receipt: mockReceiptResponse,
invoice: null,
})
const result = await analyzeDocument('base64data', 'image/jpeg')
expect(result.classification.type).toBe('receipt')
expect(result.receipt).toBeDefined()
expect(result.receipt!.merchant.name).toBe('ICA Maxi')
expect(result.invoice).toBeUndefined()
expect(mockCallVision).toHaveBeenCalledTimes(1) // Single call
})
it('classifies and extracts invoice in one call', async () => {
mockCallVision.mockResolvedValueOnce({
classification: { type: 'supplier_invoice', confidence: 0.95, reasoning: 'Invoice', isReverseCharge: false },
receipt: null,
invoice: mockInvoiceResponse,
})
const result = await analyzeDocument('base64data', 'application/pdf')
expect(result.classification.type).toBe('supplier_invoice')
expect(result.invoice).toBeDefined()
expect(result.invoice!.supplier.name).toBe('Telenor AB')
expect(result.receipt).toBeUndefined()
})
it('falls back to extractReceipt when type=receipt but receipt data missing', async () => {
mockCallVision
.mockResolvedValueOnce({
classification: { type: 'receipt', confidence: 0.8, reasoning: 'Receipt' },
receipt: null,
invoice: null,
})
.mockResolvedValueOnce(mockReceiptResponse) // fallback extractReceipt call
const result = await analyzeDocument('base64data', 'image/jpeg')
expect(result.classification.type).toBe('receipt')
expect(result.receipt).toBeDefined()
expect(mockCallVision).toHaveBeenCalledTimes(2) // unified + fallback
})
it('falls back to extractInvoice when type=supplier_invoice but invoice data missing', async () => {
mockCallVision
.mockResolvedValueOnce({
classification: { type: 'supplier_invoice', confidence: 0.9, reasoning: 'Invoice' },
receipt: null,
invoice: null,
})
.mockResolvedValueOnce(mockInvoiceResponse)
const result = await analyzeDocument('base64data', 'application/pdf')
expect(result.classification.type).toBe('supplier_invoice')
expect(result.invoice).toBeDefined()
expect(mockCallVision).toHaveBeenCalledTimes(2)
})
it('returns classification only for government_letter', async () => {
mockCallVision.mockResolvedValueOnce({
classification: { type: 'government_letter', confidence: 0.88, reasoning: 'From Skatteverket' },
})
const result = await analyzeDocument('base64data', 'application/pdf')
expect(result.classification.type).toBe('government_letter')
expect(result.receipt).toBeUndefined()
expect(result.invoice).toBeUndefined()
expect(mockCallVision).toHaveBeenCalledTimes(1)
})
it('returns classification only for unknown', async () => {
mockCallVision.mockResolvedValueOnce({
classification: { type: 'unknown', confidence: 0.5, reasoning: 'Cannot determine' },
})
const result = await analyzeDocument('base64data', 'image/png')
expect(result.classification.type).toBe('unknown')
expect(mockCallVision).toHaveBeenCalledTimes(1)
})
})
describe('validateExtractionConsistency', () => {
it('passes for valid extraction', () => {
const extraction = {
...mockReceiptResponse,
merchant: { ...mockReceiptResponse.merchant, orgNumber: '556123-4567' },
} as unknown as import('@/types').ReceiptExtractionResult
const result = validateExtractionConsistency(extraction, 'receipt')
expect(result.valid).toBe(true)
expect(result.issues).toHaveLength(0)
})
it('detects line item sum mismatch', () => {
const extraction = {
lineItems: [
{ description: 'Item 1', lineTotal: 100, vatRate: 25 },
],
totals: { subtotal: 80, vatAmount: 20, total: 200 },
confidence: 0.9,
} as unknown as import('@/types').ReceiptExtractionResult
const result = validateExtractionConsistency(extraction, 'receipt')
expect(result.valid).toBe(false)
expect(result.issues).toContainEqual(expect.stringContaining('Line items sum'))
})
it('allows small rounding differences (±1 SEK)', () => {
const extraction = {
lineItems: [
{ description: 'Item', lineTotal: 99.5, vatRate: 25 },
],
totals: { subtotal: 80, vatAmount: 20, total: 100 },
confidence: 0.9,
} as unknown as import('@/types').ReceiptExtractionResult
const result = validateExtractionConsistency(extraction, 'receipt')
expect(result.valid).toBe(true)
})
it('detects invalid VAT rates', () => {
const extraction = {
lineItems: [
{ description: 'Item', lineTotal: 100, vatRate: 18 },
],
totals: { subtotal: 85, vatAmount: 15, total: 100 },
confidence: 0.9,
} as unknown as import('@/types').ReceiptExtractionResult
const result = validateExtractionConsistency(extraction, 'receipt')
expect(result.valid).toBe(false)
expect(result.issues).toContainEqual(expect.stringContaining('Invalid VAT rate 18'))
})
it('detects empty descriptions', () => {
const extraction = {
lineItems: [
{ description: '', lineTotal: 100, vatRate: 25 },
],
totals: { subtotal: 80, vatAmount: 20, total: 100 },
confidence: 0.9,
} as unknown as import('@/types').ReceiptExtractionResult
const result = validateExtractionConsistency(extraction, 'receipt')
expect(result.valid).toBe(false)
expect(result.issues).toContainEqual(expect.stringContaining('empty description'))
})
it('detects non-positive totals', () => {
const extraction = {
lineItems: [],
totals: { subtotal: 0, vatAmount: 0, total: -5 },
confidence: 0.9,
} as unknown as import('@/types').ReceiptExtractionResult
const result = validateExtractionConsistency(extraction, 'receipt')
expect(result.valid).toBe(false)
expect(result.issues).toContainEqual(expect.stringContaining('Total is -5'))
})
it('passes when total is null (not extracted)', () => {
const extraction = {
lineItems: [
{ description: 'Item', lineTotal: 100, vatRate: 25 },
],
totals: { subtotal: null, vatAmount: null, total: null },
confidence: 0.5,
} as unknown as import('@/types').ReceiptExtractionResult
const result = validateExtractionConsistency(extraction, 'receipt')
expect(result.valid).toBe(true)
})
})
-68
View File
@@ -1,68 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
// Mock server-only
vi.mock('server-only', () => ({}))
// Mock sharp - use vi.hoisted to avoid initialization order issues
const { mockSharp, mockSharpChain, mockToBuffer } = vi.hoisted(() => {
const mockToBuffer = vi.fn()
const mockSharpChain = {
grayscale: vi.fn().mockReturnThis(),
normalize: vi.fn().mockReturnThis(),
sharpen: vi.fn().mockReturnThis(),
jpeg: vi.fn().mockReturnThis(),
toBuffer: mockToBuffer,
}
const mockSharp = vi.fn().mockReturnValue(mockSharpChain)
return { mockSharp, mockSharpChain, mockToBuffer }
})
vi.mock('sharp', () => ({ default: mockSharp }))
import { preprocessImage } from '../preprocess-image'
describe('preprocessImage', () => {
it('returns PDFs unchanged', async () => {
const result = await preprocessImage('pdf-base64-data', 'application/pdf')
expect(result.base64).toBe('pdf-base64-data')
expect(result.mimeType).toBe('application/pdf')
expect(mockSharp).not.toHaveBeenCalled()
})
it('processes JPEG images through sharp pipeline', async () => {
const inputBase64 = Buffer.from('fake-image-data').toString('base64')
const outputBuffer = Buffer.from('processed-image')
mockToBuffer.mockResolvedValueOnce(outputBuffer)
const result = await preprocessImage(inputBase64, 'image/jpeg')
expect(mockSharp).toHaveBeenCalled()
expect(mockSharpChain.grayscale).toHaveBeenCalled()
expect(mockSharpChain.normalize).toHaveBeenCalled()
expect(mockSharpChain.sharpen).toHaveBeenCalledWith({ sigma: 1.5 })
expect(mockSharpChain.jpeg).toHaveBeenCalledWith({ quality: 90 })
expect(result.mimeType).toBe('image/jpeg')
expect(result.base64).toBe(outputBuffer.toString('base64'))
})
it('processes PNG images and outputs as JPEG', async () => {
const inputBase64 = Buffer.from('fake-png-data').toString('base64')
const outputBuffer = Buffer.from('processed-png')
mockToBuffer.mockResolvedValueOnce(outputBuffer)
const result = await preprocessImage(inputBase64, 'image/png')
expect(result.mimeType).toBe('image/jpeg')
expect(mockSharp).toHaveBeenCalled()
})
it('processes WebP images', async () => {
const inputBase64 = Buffer.from('fake-webp').toString('base64')
mockToBuffer.mockResolvedValueOnce(Buffer.from('out'))
const result = await preprocessImage(inputBase64, 'image/webp')
expect(result.mimeType).toBe('image/jpeg')
})
})
-131
View File
@@ -1,131 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
validateString,
validateNumber,
validateDate,
validateTime,
validateOrgNumber,
validateVatNumber,
isSwedishOrgNumber,
validateAccountNumber,
} from '../validation-helpers'
describe('validateString', () => {
it('returns trimmed string for valid input', () => {
expect(validateString(' hello ')).toBe('hello')
expect(validateString('test')).toBe('test')
})
it('returns null for empty or non-string', () => {
expect(validateString('')).toBeNull()
expect(validateString(' ')).toBeNull()
expect(validateString(null)).toBeNull()
expect(validateString(undefined)).toBeNull()
expect(validateString(123)).toBeNull()
})
})
describe('validateNumber', () => {
it('returns number for valid numeric input', () => {
expect(validateNumber(42)).toBe(42)
expect(validateNumber(3.14)).toBe(3.14)
expect(validateNumber(0)).toBe(0)
expect(validateNumber(-5)).toBe(-5)
})
it('parses numeric strings', () => {
expect(validateNumber('42')).toBe(42)
expect(validateNumber('3.14')).toBe(3.14)
expect(validateNumber('100 SEK')).toBe(100)
})
it('returns null for invalid values', () => {
expect(validateNumber(NaN)).toBeNull()
expect(validateNumber(null)).toBeNull()
expect(validateNumber(undefined)).toBeNull()
expect(validateNumber('abc')).toBeNull()
expect(validateNumber({})).toBeNull()
})
})
describe('validateDate', () => {
it('returns ISO date for valid date strings', () => {
expect(validateDate('2024-06-15')).toBe('2024-06-15')
expect(validateDate('2024-01-01T12:00:00Z')).toBe('2024-01-01')
})
it('returns null for invalid dates', () => {
expect(validateDate('not a date')).toBeNull()
expect(validateDate(null)).toBeNull()
expect(validateDate(42)).toBeNull()
})
})
describe('validateTime', () => {
it('returns HH:MM for valid time strings', () => {
expect(validateTime('14:30')).toBe('14:30')
expect(validateTime('09:05')).toBe('09:05')
expect(validateTime('14:30:00')).toBe('14:30')
})
it('returns null for invalid times', () => {
expect(validateTime('2pm')).toBeNull()
expect(validateTime(null)).toBeNull()
expect(validateTime('1:30')).toBeNull() // needs 2-digit hour
})
})
describe('validateOrgNumber', () => {
it('formats valid Swedish org numbers', () => {
expect(validateOrgNumber('5561234567')).toBe('556123-4567')
expect(validateOrgNumber('556123-4567')).toBe('556123-4567')
expect(validateOrgNumber('556 123 4567')).toBe('556123-4567')
})
it('returns null for invalid org numbers', () => {
expect(validateOrgNumber('12345')).toBeNull()
expect(validateOrgNumber('123456789012')).toBeNull()
expect(validateOrgNumber(null)).toBeNull()
expect(validateOrgNumber(123)).toBeNull()
})
})
describe('validateVatNumber', () => {
it('validates Swedish VAT numbers', () => {
expect(validateVatNumber('SE556123456701')).toBe('SE556123456701')
expect(validateVatNumber('se556123456701')).toBe('SE556123456701')
})
it('returns null for non-Swedish VAT numbers', () => {
expect(validateVatNumber('DE123456789')).toBeNull()
expect(validateVatNumber('SE12345')).toBeNull() // too short
expect(validateVatNumber(null)).toBeNull()
})
})
describe('isSwedishOrgNumber', () => {
it('returns true for 10-digit numbers', () => {
expect(isSwedishOrgNumber('5561234567')).toBe(true)
expect(isSwedishOrgNumber('556123-4567')).toBe(true)
})
it('returns false for non-10-digit numbers', () => {
expect(isSwedishOrgNumber('12345')).toBe(false)
expect(isSwedishOrgNumber('123456789012')).toBe(false)
})
})
describe('validateAccountNumber', () => {
it('validates 4-digit BAS account numbers', () => {
expect(validateAccountNumber('5410')).toBe('5410')
expect(validateAccountNumber('1930')).toBe('1930')
expect(validateAccountNumber('konto 6530')).toBe('6530')
})
it('returns null for invalid account numbers', () => {
expect(validateAccountNumber('999')).toBeNull() // too short
expect(validateAccountNumber('0500')).toBeNull() // below 1000
expect(validateAccountNumber(null)).toBeNull()
expect(validateAccountNumber(undefined)).toBeNull()
})
})
-256
View File
@@ -1,256 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock server-only
vi.mock('server-only', () => ({}))
// Mock preprocessing
vi.mock('../preprocess-image', () => ({
preprocessImage: vi.fn().mockResolvedValue({ base64: 'preprocessed', mimeType: 'image/jpeg' }),
}))
// Mock Anthropic SDK
const { mockCreate } = vi.hoisted(() => {
const mockCreate = vi.fn()
return { mockCreate }
})
vi.mock('@anthropic-ai/sdk', () => ({
default: class MockAnthropic {
messages = { create: mockCreate }
},
}))
import { callVision, stripJsonFences } from '../vision-client'
import { preprocessImage } from '../preprocess-image'
function makeResponse(json: Record<string, unknown>) {
return {
content: [{ type: 'text', text: JSON.stringify(json) }],
}
}
describe('callVision', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns parsed JSON from Claude response', async () => {
mockCreate.mockResolvedValueOnce(
makeResponse({ type: 'receipt', confidence: 0.95 })
)
const result = await callVision({
base64: 'base64data',
mimeType: 'image/jpeg',
systemPrompt: 'System prompt',
userPrompt: 'User prompt',
})
expect(result).toEqual({ type: 'receipt', confidence: 0.95 })
})
it('preprocesses images by default', async () => {
mockCreate.mockResolvedValueOnce(makeResponse({ ok: true }))
await callVision({
base64: 'raw-image',
mimeType: 'image/jpeg',
systemPrompt: 'S',
userPrompt: 'U',
})
expect(preprocessImage).toHaveBeenCalledWith('raw-image', 'image/jpeg')
})
it('skips preprocessing when preprocess=false', async () => {
mockCreate.mockResolvedValueOnce(makeResponse({ ok: true }))
await callVision({
base64: 'raw-image',
mimeType: 'image/jpeg',
systemPrompt: 'S',
userPrompt: 'U',
preprocess: false,
})
expect(preprocessImage).not.toHaveBeenCalled()
})
it('skips preprocessing for PDFs', async () => {
mockCreate.mockResolvedValueOnce(makeResponse({ ok: true }))
await callVision({
base64: 'pdf-data',
mimeType: 'application/pdf',
systemPrompt: 'S',
userPrompt: 'U',
})
expect(preprocessImage).not.toHaveBeenCalled()
})
it('uses document content block for PDFs', async () => {
mockCreate.mockResolvedValueOnce(makeResponse({ type: 'invoice' }))
await callVision({
base64: 'pdf-data',
mimeType: 'application/pdf',
systemPrompt: 'S',
userPrompt: 'U',
})
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'document' }),
]),
},
],
})
)
})
it('uses image content block for images', async () => {
mockCreate.mockResolvedValueOnce(makeResponse({ type: 'receipt' }))
await callVision({
base64: 'img-data',
mimeType: 'image/png',
systemPrompt: 'S',
userPrompt: 'U',
preprocess: false,
})
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
{
role: 'user',
content: expect.arrayContaining([
expect.objectContaining({ type: 'image' }),
]),
},
],
})
)
})
it('retries on API error', async () => {
mockCreate
.mockRejectedValueOnce(new Error('API timeout'))
.mockResolvedValueOnce(makeResponse({ ok: true }))
const result = await callVision({
base64: 'data',
mimeType: 'image/jpeg',
systemPrompt: 'S',
userPrompt: 'U',
})
expect(result).toEqual({ ok: true })
expect(mockCreate).toHaveBeenCalledTimes(2)
})
it('fast-fails on SyntaxError without retrying', async () => {
mockCreate.mockResolvedValueOnce({
content: [{ type: 'text', text: 'not valid json at all' }],
})
await expect(
callVision({
base64: 'data',
mimeType: 'image/jpeg',
systemPrompt: 'S',
userPrompt: 'U',
})
).rejects.toThrow('Failed to parse AI response')
expect(mockCreate).toHaveBeenCalledTimes(1)
})
it('throws after max retries', async () => {
mockCreate
.mockRejectedValueOnce(new Error('err1'))
.mockRejectedValueOnce(new Error('err2'))
.mockRejectedValueOnce(new Error('err3'))
await expect(
callVision({
base64: 'data',
mimeType: 'image/jpeg',
systemPrompt: 'S',
userPrompt: 'U',
})
).rejects.toThrow('Vision API call failed after 3 attempts')
expect(mockCreate).toHaveBeenCalledTimes(3)
})
it('throws on unsupported file type', async () => {
await expect(
callVision({
base64: 'data',
mimeType: 'text/plain',
systemPrompt: 'S',
userPrompt: 'U',
})
).rejects.toThrow('Unsupported file type: text/plain')
})
it('uses custom maxTokens', async () => {
mockCreate.mockResolvedValueOnce(makeResponse({ ok: true }))
await callVision({
base64: 'data',
mimeType: 'application/pdf',
systemPrompt: 'S',
userPrompt: 'U',
maxTokens: 1024,
})
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ max_tokens: 1024 })
)
})
it('strips JSON fences from response', async () => {
mockCreate.mockResolvedValueOnce({
content: [
{
type: 'text',
text: '```json\n{"type":"receipt","confidence":0.9}\n```',
},
],
})
const result = await callVision({
base64: 'data',
mimeType: 'image/jpeg',
systemPrompt: 'S',
userPrompt: 'U',
})
expect(result).toEqual({ type: 'receipt', confidence: 0.9 })
})
})
describe('stripJsonFences', () => {
it('strips ```json ... ``` fences', () => {
expect(stripJsonFences('```json\n{"a":1}\n```')).toBe('{"a":1}')
})
it('strips plain ``` fences', () => {
expect(stripJsonFences('```\n{"a":1}\n```')).toBe('{"a":1}')
})
it('returns plain JSON unchanged', () => {
expect(stripJsonFences('{"a":1}')).toBe('{"a":1}')
})
it('trims whitespace', () => {
expect(stripJsonFences(' {"a":1} ')).toBe('{"a":1}')
})
})