Files
accounted/extensions/general/invoice-inbox/__tests__/extract-invoice-fields.test.ts
T
Jakob WennbergandClaude Opus 4.7 8676f10fdd fix(invoice-inbox): switch from pdfjs-dist to unpdf (#409)
* fix(invoice-inbox): switch from pdfjs-dist to unpdf for PDF text extraction

After three rounds of fighting pdfjs on Vercel (#407 stubbed DOM globals,
#408 tried to ship the worker file via outputFileTracingIncludes), text
extraction still failed in prod with "Setting up fake worker failed" —
Next's tracer can't reliably include pdfjs-dist's worker file when the
package is marked as a server external.

unpdf is a serverless-first wrapper around pdfjs (by unjs) that ships its
own bundled pdfjs build with no canvas/worker dependencies. Drop-in
replacement: extractText returns merged page text directly.

- Remove DOM stubs, serverExternalPackages, outputFileTracingIncludes
- Replace pdfjs-dist with unpdf (no transitive deps)
- Update test mock from getDocument → extractText

All 45 invoice-inbox unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(invoice-inbox): make unpdf import static, rename pdfjs test labels

Per Greptile review on #409:

1. CRITICAL: tryExtractPdfText was still using await import('unpdf'), a
   dynamic import. CLAUDE.md forbids dynamic imports in extensions
   precisely because Next.js bundling can't reliably trace them — which
   is the same class of failure that caused the pdfjs prod bug. unpdf
   bundles statically (no canvas/worker), so a top-level static import
   is safe and correct.

2. NIT: two test descriptions still said "pdfjs" after the mock rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:32:50 +02:00

140 lines
4.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
// Mock unpdf so we can drive the regex extractors with canned text
// without building actual PDF binaries.
const mockExtractText = vi.fn()
vi.mock('unpdf', () => ({
extractText: (...args: unknown[]) => mockExtractText(...args),
}))
function fakePdf(text: string) {
return Promise.resolve({ totalPages: 1, text })
}
describe('extractInvoiceFields', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns empty result for non-PDF mime type', async () => {
const { data, rawText } = await extractInvoiceFields({
buffer: Buffer.from(''),
mimeType: 'image/png',
fileName: 'foo.png',
})
expect(rawText).toBeNull()
expect(data.totals.total).toBeNull()
expect(data.supplier.orgNumber).toBeNull()
})
it('returns empty result when unpdf extracts no text (image-only PDF)', async () => {
mockExtractText.mockReturnValueOnce(fakePdf(''))
const { data, rawText } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'scan.pdf',
})
expect(rawText).toBe('')
expect(data.totals.total).toBeNull()
})
it('extracts a Luhn-valid org number', async () => {
// 5560125790 is a valid Swedish AB org-nr (Luhn-checked)
mockExtractText.mockReturnValueOnce(fakePdf('Lev: Acme AB Org.nr 556012-5790 Faktura'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.supplier.orgNumber).toBe('5560125790')
})
it('rejects org-nrs with bad Luhn digit', async () => {
mockExtractText.mockReturnValueOnce(fakePdf('Org.nr 556012-5791'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.supplier.orgNumber).toBeNull()
})
it('extracts a Luhn-valid OCR reference', async () => {
mockExtractText.mockReturnValueOnce(fakePdf('OCR-nummer: 12345674'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.invoice.paymentReference).toBe('12345674')
})
it('extracts a Luhn-valid bankgiro', async () => {
// 991-2346 is the canonical test bankgiro (Luhn-valid) used in lib/bankgiro/__tests__
mockExtractText.mockReturnValueOnce(fakePdf('Bankgiro 991-2346 Plusgiro'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.supplier.bankgiro).toBe('991-2346')
})
it('parses Swedish-formatted totals', async () => {
mockExtractText.mockReturnValueOnce(
fakePdf('Att betala 12 345,67 kr')
)
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.totals.total).toBe(12345.67)
})
it('parses Förfallodatum and normalizes to ISO', async () => {
mockExtractText.mockReturnValueOnce(fakePdf('Förfallodatum 2026-06-15'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.invoice.dueDate).toBe('2026-06-15')
})
it('extracts an invoice number after Fakturanr', async () => {
mockExtractText.mockReturnValueOnce(fakePdf('Fakturanr F-2024-001 Datum'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.invoice.invoiceNumber).toBe('F-2024-001')
})
it('keeps SEK as default currency when no foreign code is present', async () => {
mockExtractText.mockReturnValueOnce(fakePdf('Total 100 kr'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.invoice.currency).toBe('SEK')
})
it('returns empty result when unpdf throws', async () => {
mockExtractText.mockImplementationOnce(() => {
throw new Error('boom')
})
const { data, rawText } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(rawText).toBeNull()
expect(data.totals.total).toBeNull()
})
})