* feat(invoice-inbox): make Bedrock model + max_tokens env-overridable Read BEDROCK_MODEL_ID and BEDROCK_MAX_TOKENS from process.env so ops can swap models or raise token caps without a code deploy. Defaults align with production: eu.anthropic.claude-sonnet-4-6 in eu-north-1, 8192 tokens (up from 4096 — enough headroom for 20+ line-item invoices that were still occasionally truncating mid-JSON). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(invoice-inbox): guard onFieldsUpdated, tighten env parsing, hoist afterAll Three fixes from Greptile review on #416: 1. Stale-closure bug in onFieldsUpdated. setSelected used an unconditional spread, so a PATCH that completed after the user had navigated to a different item would silently overwrite the new selection's extracted_data with the old item's payload. Guard with prev?.id === selected.id (capturing selected.id outside the updater so it's stable through both setSelected and setItems). 2. BEDROCK_MAX_TOKENS env parser used `Number(...) || 8192`, which silently treats a deliberate `0` the same as unset. Use Number.isFinite(n) && n > 0 so an invalid configuration is not masked by the fallback. 3. Hoist `afterAll` import in the test file to the top alongside other vitest imports. Was working via ES-module hoisting but reads weird to humans and linters. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
193 lines
6.1 KiB
TypeScript
193 lines
6.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'
|
|
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
|
|
|
|
// Mock the Bedrock SDK so tests drive the JSON parser without
|
|
// network/credential needs.
|
|
const mockCreate = vi.fn()
|
|
|
|
vi.mock('@anthropic-ai/bedrock-sdk', () => {
|
|
class FakeBedrock {
|
|
messages = { create: mockCreate }
|
|
}
|
|
return { default: FakeBedrock }
|
|
})
|
|
|
|
const ORIG_AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID
|
|
const ORIG_AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY
|
|
|
|
function aiResponse(json: string | object) {
|
|
const text = typeof json === 'string' ? json : JSON.stringify(json)
|
|
return Promise.resolve({
|
|
content: [{ type: 'text', text }],
|
|
})
|
|
}
|
|
|
|
const VALID_RESULT = {
|
|
supplier: {
|
|
name: 'Anthropic, PBC',
|
|
orgNumber: null,
|
|
vatNumber: null,
|
|
address: '548 Market Street, San Francisco, CA 94104',
|
|
bankgiro: null,
|
|
plusgiro: null,
|
|
},
|
|
invoice: {
|
|
invoiceNumber: '06655767-0007',
|
|
invoiceDate: '2026-02-13',
|
|
dueDate: null,
|
|
paymentReference: null,
|
|
currency: 'USD',
|
|
},
|
|
lineItems: [
|
|
{
|
|
description: 'One-time credit purchase',
|
|
quantity: 1,
|
|
unitPrice: 5,
|
|
lineTotal: 5,
|
|
vatRate: 25,
|
|
accountSuggestion: null,
|
|
},
|
|
],
|
|
totals: { subtotal: 5, vatAmount: 1.25, total: 6.25 },
|
|
vatBreakdown: [{ rate: 25, base: 5, amount: 1.25 }],
|
|
}
|
|
|
|
describe('extractInvoiceFields', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.AWS_ACCESS_KEY_ID = 'test-key'
|
|
process.env.AWS_SECRET_ACCESS_KEY = 'test-secret'
|
|
})
|
|
|
|
it('returns empty result for unsupported mime type (HEIC)', async () => {
|
|
const { data, rawText } = await extractInvoiceFields({
|
|
buffer: Buffer.from(''),
|
|
mimeType: 'image/heic',
|
|
fileName: 'photo.heic',
|
|
})
|
|
expect(rawText).toBeNull()
|
|
expect(data.totals.total).toBeNull()
|
|
expect(data.supplier.name).toBeNull()
|
|
expect(mockCreate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns empty result and skips API when AWS creds are missing', async () => {
|
|
delete process.env.AWS_ACCESS_KEY_ID
|
|
delete process.env.AWS_SECRET_ACCESS_KEY
|
|
const { data } = await extractInvoiceFields({
|
|
buffer: Buffer.from('%PDF'),
|
|
mimeType: 'application/pdf',
|
|
fileName: 'f.pdf',
|
|
})
|
|
expect(data.totals.total).toBeNull()
|
|
expect(mockCreate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('parses a valid AI response into InvoiceExtractionResult', async () => {
|
|
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
|
|
const { data, rawText } = await extractInvoiceFields({
|
|
buffer: Buffer.from('%PDF'),
|
|
mimeType: 'application/pdf',
|
|
fileName: 'anthropic-receipt.pdf',
|
|
})
|
|
expect(rawText).toContain('Anthropic')
|
|
expect(data.supplier.name).toBe('Anthropic, PBC')
|
|
expect(data.invoice.currency).toBe('USD')
|
|
expect(data.invoice.invoiceNumber).toBe('06655767-0007')
|
|
expect(data.totals.total).toBe(6.25)
|
|
expect(data.vatBreakdown).toHaveLength(1)
|
|
expect(data.lineItems).toHaveLength(1)
|
|
expect(data.confidence).toBe(1)
|
|
})
|
|
|
|
it('sends image content for an image upload', async () => {
|
|
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
|
|
await extractInvoiceFields({
|
|
buffer: Buffer.from('JPEG'),
|
|
mimeType: 'image/jpeg',
|
|
fileName: 'photo.jpg',
|
|
})
|
|
const call = mockCreate.mock.calls[0][0]
|
|
const content = call.messages[0].content
|
|
expect(content[0].type).toBe('image')
|
|
expect(content[0].source.media_type).toBe('image/jpeg')
|
|
})
|
|
|
|
it('sends document content for a PDF upload', async () => {
|
|
mockCreate.mockReturnValueOnce(aiResponse(VALID_RESULT))
|
|
await extractInvoiceFields({
|
|
buffer: Buffer.from('%PDF'),
|
|
mimeType: 'application/pdf',
|
|
fileName: 'invoice.pdf',
|
|
})
|
|
const call = mockCreate.mock.calls[0][0]
|
|
const content = call.messages[0].content
|
|
expect(content[0].type).toBe('document')
|
|
expect(content[0].source.media_type).toBe('application/pdf')
|
|
})
|
|
|
|
it('returns empty result when AI response is not valid JSON', async () => {
|
|
mockCreate.mockReturnValueOnce(aiResponse('Sorry, I cannot read this PDF.'))
|
|
const { data, rawText } = await extractInvoiceFields({
|
|
buffer: Buffer.from('%PDF'),
|
|
mimeType: 'application/pdf',
|
|
fileName: 'f.pdf',
|
|
})
|
|
expect(rawText).toBe('Sorry, I cannot read this PDF.')
|
|
expect(data.totals.total).toBeNull()
|
|
expect(data.supplier.name).toBeNull()
|
|
})
|
|
|
|
it('returns empty result when AI response fails schema validation', async () => {
|
|
mockCreate.mockReturnValueOnce(
|
|
aiResponse({ supplier: { name: 'X' } /* missing required keys */ })
|
|
)
|
|
const { data } = await extractInvoiceFields({
|
|
buffer: Buffer.from('%PDF'),
|
|
mimeType: 'application/pdf',
|
|
fileName: 'f.pdf',
|
|
})
|
|
expect(data.totals.total).toBeNull()
|
|
expect(data.supplier.name).toBeNull()
|
|
})
|
|
|
|
it('returns empty result when Bedrock throws', async () => {
|
|
mockCreate.mockRejectedValueOnce(new Error('throttled'))
|
|
const { data, rawText } = await extractInvoiceFields({
|
|
buffer: Buffer.from('%PDF'),
|
|
mimeType: 'application/pdf',
|
|
fileName: 'f.pdf',
|
|
})
|
|
expect(rawText).toBeNull()
|
|
expect(data.totals.total).toBeNull()
|
|
})
|
|
|
|
it('forces accountSuggestion to null even if the model returns a value', async () => {
|
|
mockCreate.mockReturnValueOnce(
|
|
aiResponse({
|
|
...VALID_RESULT,
|
|
lineItems: [
|
|
{
|
|
...VALID_RESULT.lineItems[0],
|
|
accountSuggestion: '5410', // model attempting BAS suggestion
|
|
},
|
|
],
|
|
})
|
|
)
|
|
const { data } = await extractInvoiceFields({
|
|
buffer: Buffer.from('%PDF'),
|
|
mimeType: 'application/pdf',
|
|
fileName: 'f.pdf',
|
|
})
|
|
expect(data.lineItems[0].accountSuggestion).toBeNull()
|
|
})
|
|
|
|
// Restore env vars so other test files aren't affected.
|
|
afterAll(() => {
|
|
if (ORIG_AWS_ACCESS_KEY_ID) process.env.AWS_ACCESS_KEY_ID = ORIG_AWS_ACCESS_KEY_ID
|
|
else delete process.env.AWS_ACCESS_KEY_ID
|
|
if (ORIG_AWS_SECRET_ACCESS_KEY) process.env.AWS_SECRET_ACCESS_KEY = ORIG_AWS_SECRET_ACCESS_KEY
|
|
else delete process.env.AWS_SECRET_ACCESS_KEY
|
|
})
|
|
})
|