feat: add intelligent import for CSV, Excel, and PDF files
Add IntelligentImportAugmentation with support for: - CSV: auto-detection of encoding, delimiters, and field types - Excel: multi-sheet extraction with metadata preservation - PDF: text extraction, table detection, and metadata extraction Features: - Automatic format detection from file extension or content - Intelligent type inference (string, number, boolean, date) - Seamless integration with neural entity extraction - Production-ready with 69 comprehensive tests Dependencies added: - xlsx@^0.18.5 for Excel parsing - pdfjs-dist@^4.0.379 for PDF parsing - csv-parse@^6.1.0 for CSV parsing - chardet@^2.0.0 for encoding detection Documentation: - Updated README with import examples - Updated API_REFERENCE with comprehensive import() docs - Updated import-anything.md guide - Added working example: import-excel-pdf-csv.ts - Updated augmentations README
This commit is contained in:
parent
aaf8e0f411
commit
814cbb48ee
33 changed files with 4664 additions and 28 deletions
281
tests/augmentations/intelligentImport/csvHandler.unit.test.ts
Normal file
281
tests/augmentations/intelligentImport/csvHandler.unit.test.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
/**
|
||||
* CSV Handler Tests
|
||||
* Comprehensive tests for CSV parsing, encoding detection, and type inference
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { CSVHandler } from '../../../src/augmentations/intelligentImport/handlers/csvHandler.js'
|
||||
import { promises as fs } from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
describe('CSVHandler', () => {
|
||||
let handler: CSVHandler
|
||||
const fixturesPath = path.join(process.cwd(), 'tests/fixtures/import')
|
||||
|
||||
beforeAll(() => {
|
||||
handler = new CSVHandler()
|
||||
})
|
||||
|
||||
describe('canHandle', () => {
|
||||
it('should handle .csv extension', () => {
|
||||
expect(handler.canHandle({ filename: 'data.csv' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle .tsv extension', () => {
|
||||
expect(handler.canHandle({ filename: 'data.tsv' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle .txt extension', () => {
|
||||
expect(handler.canHandle({ filename: 'data.txt' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle CSV content (comma)', () => {
|
||||
const content = 'name,age,email\nJohn,30,john@example.com'
|
||||
expect(handler.canHandle(content)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle CSV content (semicolon)', () => {
|
||||
const content = 'name;age;email\nJohn;30;john@example.com'
|
||||
expect(handler.canHandle(content)).toBe(true)
|
||||
})
|
||||
|
||||
it('should not handle non-CSV content', () => {
|
||||
const content = 'This is just plain text without any structure'
|
||||
expect(handler.canHandle(content)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not handle .xlsx extension', () => {
|
||||
expect(handler.canHandle({ filename: 'data.xlsx' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - simple CSV', () => {
|
||||
it('should parse simple comma-delimited CSV', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.csv')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.csv' })
|
||||
|
||||
expect(result.format).toBe('csv')
|
||||
expect(result.data).toHaveLength(4)
|
||||
expect(result.data[0]).toEqual({
|
||||
name: 'John Doe',
|
||||
age: 30,
|
||||
email: 'john@example.com',
|
||||
active: true
|
||||
})
|
||||
expect(result.metadata.rowCount).toBe(4)
|
||||
expect(result.metadata.fields).toEqual(['name', 'age', 'email', 'active'])
|
||||
expect(result.metadata.delimiter).toBe(',')
|
||||
expect(result.metadata.hasHeaders).toBe(true)
|
||||
})
|
||||
|
||||
it('should infer correct types', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.csv')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.csv' })
|
||||
|
||||
expect(typeof result.data[0].name).toBe('string')
|
||||
expect(typeof result.data[0].age).toBe('number')
|
||||
expect(typeof result.data[0].active).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - delimiter detection', () => {
|
||||
it('should detect semicolon delimiter', async () => {
|
||||
const filePath = path.join(fixturesPath, 'semicolon.csv')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'semicolon.csv' })
|
||||
|
||||
expect(result.metadata.delimiter).toBe(';')
|
||||
expect(result.data).toHaveLength(4)
|
||||
expect(result.data[0]).toEqual({
|
||||
product: 'Laptop',
|
||||
price: 999.99,
|
||||
quantity: 10,
|
||||
inStock: true
|
||||
})
|
||||
})
|
||||
|
||||
it('should detect tab delimiter', async () => {
|
||||
const filePath = path.join(fixturesPath, 'tab-delimited.csv')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'tab-delimited.csv' })
|
||||
|
||||
expect(result.metadata.delimiter).toBe('\t')
|
||||
expect(result.data).toHaveLength(4)
|
||||
expect(result.data[0].city).toBe('Tokyo')
|
||||
expect(result.data[0].population).toBe(13960000)
|
||||
})
|
||||
|
||||
it('should use specified delimiter when provided', async () => {
|
||||
const content = 'name|age|email\nJohn|30|john@example.com'
|
||||
|
||||
const result = await handler.process(content, {
|
||||
csvDelimiter: '|',
|
||||
filename: 'pipe.csv'
|
||||
})
|
||||
|
||||
expect(result.data).toHaveLength(1)
|
||||
expect(result.data[0].name).toBe('John')
|
||||
expect(result.data[0].age).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - type inference', () => {
|
||||
it('should infer integer, float, date, and boolean types', async () => {
|
||||
const filePath = path.join(fixturesPath, 'types.csv')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'types.csv' })
|
||||
|
||||
expect(result.metadata.types).toBeDefined()
|
||||
expect(result.metadata.types.id).toBe('integer')
|
||||
expect(result.metadata.types.name).toBe('string')
|
||||
// Score is 95 (integer), not 95.0, so it's detected as integer
|
||||
expect(['integer', 'float']).toContain(result.metadata.types.score)
|
||||
expect(result.metadata.types.active).toBe('boolean')
|
||||
|
||||
// Check actual values
|
||||
expect(result.data[0].id).toBe(1)
|
||||
expect([95, 95.0]).toContain(result.data[0].score) // Can be integer or float
|
||||
expect(result.data[0].active).toBe(true)
|
||||
expect(result.data[1].score).toBe(87.5) // This one is definitely a float
|
||||
})
|
||||
|
||||
it('should handle null values', async () => {
|
||||
const content = 'name,age,email\nJohn,30,john@example.com\nJane,,jane@example.com\nBob,45,'
|
||||
|
||||
const result = await handler.process(content, { filename: 'nulls.csv' })
|
||||
|
||||
expect(result.data[1].age).toBeNull()
|
||||
expect(result.data[2].email).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - encoding detection', () => {
|
||||
it('should detect UTF-8 encoding', async () => {
|
||||
const content = 'name,description\nProduct 1,Description with émojis 🎉'
|
||||
|
||||
const result = await handler.process(content, { filename: 'utf8.csv' })
|
||||
|
||||
// Encoding can be 'UTF-8', 'utf-8', or 'utf8' depending on detection
|
||||
expect(result.metadata.encoding.toLowerCase()).toContain('utf')
|
||||
expect(result.data[0].description).toContain('émojis')
|
||||
expect(result.data[0].description).toContain('🎉')
|
||||
})
|
||||
|
||||
it('should use specified encoding when provided', async () => {
|
||||
const content = 'name,age\nJohn,30'
|
||||
|
||||
const result = await handler.process(content, {
|
||||
encoding: 'ascii',
|
||||
filename: 'ascii.csv'
|
||||
})
|
||||
|
||||
expect(result.metadata.encoding).toBe('ascii')
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - options', () => {
|
||||
it('should respect csvHeaders=false option', async () => {
|
||||
const content = 'John,30,john@example.com\nJane,25,jane@example.com'
|
||||
|
||||
const result = await handler.process(content, {
|
||||
csvHeaders: false,
|
||||
filename: 'no-headers.csv'
|
||||
})
|
||||
|
||||
expect(result.metadata.hasHeaders).toBe(false)
|
||||
// csv-parse will generate column names like '0', '1', '2'
|
||||
expect(Object.keys(result.data[0])).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should respect maxRows option', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.csv')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, {
|
||||
maxRows: 2,
|
||||
filename: 'simple.csv'
|
||||
})
|
||||
|
||||
expect(result.data).toHaveLength(2)
|
||||
expect(result.metadata.rowCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - edge cases', () => {
|
||||
it('should handle empty CSV', async () => {
|
||||
const content = ''
|
||||
|
||||
const result = await handler.process(content, { filename: 'empty.csv' })
|
||||
|
||||
expect(result.data).toHaveLength(0)
|
||||
expect(result.metadata.rowCount).toBe(0)
|
||||
expect(result.metadata.fields).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle CSV with only headers', async () => {
|
||||
const content = 'name,age,email'
|
||||
|
||||
const result = await handler.process(content, { filename: 'headers-only.csv' })
|
||||
|
||||
expect(result.data).toHaveLength(0)
|
||||
expect(result.metadata.rowCount).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle CSV with single row', async () => {
|
||||
const content = 'name,age,email\nJohn,30,john@example.com'
|
||||
|
||||
const result = await handler.process(content, { filename: 'single-row.csv' })
|
||||
|
||||
expect(result.data).toHaveLength(1)
|
||||
expect(result.data[0].name).toBe('John')
|
||||
})
|
||||
|
||||
it('should handle CSV with varying column counts', async () => {
|
||||
const content = 'name,age,email\nJohn,30,john@example.com\nJane,25\nBob,45,bob@example.com,extra'
|
||||
|
||||
const result = await handler.process(content, { filename: 'varying.csv' })
|
||||
|
||||
// csv-parse with relax_column_count should handle this
|
||||
expect(result.data).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should trim whitespace', async () => {
|
||||
const content = 'name, age , email \n John , 30 , john@example.com '
|
||||
|
||||
const result = await handler.process(content, { filename: 'whitespace.csv' })
|
||||
|
||||
expect(result.data[0].name).toBe('John')
|
||||
expect(result.data[0].age).toBe(30)
|
||||
expect(result.data[0].email).toBe('john@example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - performance', () => {
|
||||
it('should measure processing time', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.csv')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.csv' })
|
||||
|
||||
expect(result.metadata.processingTime).toBeGreaterThanOrEqual(0)
|
||||
expect(typeof result.metadata.processingTime).toBe('number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - error handling', () => {
|
||||
it('should throw error for malformed CSV', async () => {
|
||||
const content = 'name,age,email\nJohn,30,"unclosed quote'
|
||||
|
||||
await expect(
|
||||
handler.process(content, { filename: 'malformed.csv' })
|
||||
).rejects.toThrow('CSV parsing failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
269
tests/augmentations/intelligentImport/excelHandler.unit.test.ts
Normal file
269
tests/augmentations/intelligentImport/excelHandler.unit.test.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* Excel Handler Tests
|
||||
* Comprehensive tests for Excel parsing, multi-sheet extraction, and type inference
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { ExcelHandler } from '../../../src/augmentations/intelligentImport/handlers/excelHandler.js'
|
||||
import { promises as fs } from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
describe('ExcelHandler', () => {
|
||||
let handler: ExcelHandler
|
||||
const fixturesPath = path.join(process.cwd(), 'tests/fixtures/import')
|
||||
|
||||
beforeAll(() => {
|
||||
handler = new ExcelHandler()
|
||||
})
|
||||
|
||||
describe('canHandle', () => {
|
||||
it('should handle .xlsx extension', () => {
|
||||
expect(handler.canHandle({ filename: 'data.xlsx' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle .xls extension', () => {
|
||||
expect(handler.canHandle({ filename: 'data.xls' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle .xlsb extension', () => {
|
||||
expect(handler.canHandle({ filename: 'data.xlsb' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle .xlsm extension', () => {
|
||||
expect(handler.canHandle({ filename: 'data.xlsm' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should not handle .csv extension', () => {
|
||||
expect(handler.canHandle({ filename: 'data.csv' })).toBe(false)
|
||||
})
|
||||
|
||||
it('should not handle .pdf extension', () => {
|
||||
expect(handler.canHandle({ filename: 'data.pdf' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - simple Excel', () => {
|
||||
it('should parse simple single-sheet Excel file', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.xlsx')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.xlsx' })
|
||||
|
||||
expect(result.format).toBe('excel')
|
||||
expect(result.data).toHaveLength(4)
|
||||
expect(result.data[0]).toMatchObject({
|
||||
Name: 'Alice Johnson',
|
||||
Age: 28,
|
||||
Department: 'Engineering',
|
||||
Salary: 95000,
|
||||
Active: true
|
||||
})
|
||||
expect(result.metadata.rowCount).toBe(4)
|
||||
expect(result.metadata.sheetCount).toBe(1)
|
||||
expect(result.metadata.sheets).toEqual(['Employees'])
|
||||
})
|
||||
|
||||
it('should infer correct types from Excel data', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.xlsx')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.xlsx' })
|
||||
|
||||
expect(result.metadata.types).toBeDefined()
|
||||
expect(result.metadata.types.Name).toBe('string')
|
||||
expect(result.metadata.types.Age).toBe('integer')
|
||||
expect(['integer', 'float']).toContain(result.metadata.types.Salary)
|
||||
expect(result.metadata.types.Active).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - multi-sheet Excel', () => {
|
||||
it('should extract data from all sheets by default', async () => {
|
||||
const filePath = path.join(fixturesPath, 'multi-sheet.xlsx')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'multi-sheet.xlsx' })
|
||||
|
||||
expect(result.metadata.sheetCount).toBe(3)
|
||||
expect(result.metadata.sheets).toEqual(['Products', 'Orders', 'Customers'])
|
||||
|
||||
// Total rows from all sheets
|
||||
const totalRows = 4 + 3 + 3 // Products + Orders + Customers
|
||||
expect(result.data).toHaveLength(totalRows)
|
||||
|
||||
// Check _sheet field is added
|
||||
expect(result.data[0]._sheet).toBe('Products')
|
||||
})
|
||||
|
||||
it('should filter data by sheet name', async () => {
|
||||
const filePath = path.join(fixturesPath, 'multi-sheet.xlsx')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, {
|
||||
filename: 'multi-sheet.xlsx',
|
||||
excelSheets: ['Products']
|
||||
})
|
||||
|
||||
expect(result.metadata.sheetCount).toBe(1)
|
||||
expect(result.metadata.sheets).toEqual(['Products'])
|
||||
expect(result.data).toHaveLength(4)
|
||||
expect(result.data.every(row => row._sheet === 'Products')).toBe(true)
|
||||
})
|
||||
|
||||
it('should extract data from multiple specified sheets', async () => {
|
||||
const filePath = path.join(fixturesPath, 'multi-sheet.xlsx')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, {
|
||||
filename: 'multi-sheet.xlsx',
|
||||
excelSheets: ['Products', 'Customers']
|
||||
})
|
||||
|
||||
expect(result.metadata.sheetCount).toBe(2)
|
||||
expect(result.metadata.sheets).toEqual(['Products', 'Customers'])
|
||||
expect(result.data).toHaveLength(7) // 4 + 3
|
||||
|
||||
const sheets = new Set(result.data.map(row => row._sheet))
|
||||
expect(sheets.has('Products')).toBe(true)
|
||||
expect(sheets.has('Customers')).toBe(true)
|
||||
expect(sheets.has('Orders')).toBe(false)
|
||||
})
|
||||
|
||||
it('should include sheet metadata for each sheet', async () => {
|
||||
const filePath = path.join(fixturesPath, 'multi-sheet.xlsx')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'multi-sheet.xlsx' })
|
||||
|
||||
expect(result.metadata.sheetMetadata).toBeDefined()
|
||||
expect(result.metadata.sheetMetadata.Products).toMatchObject({
|
||||
rowCount: 4,
|
||||
columnCount: 5
|
||||
})
|
||||
expect(result.metadata.sheetMetadata.Products.headers).toEqual([
|
||||
'ID', 'Product', 'Price', 'Stock', 'Category'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - type inference', () => {
|
||||
it('should infer integer, float, date, and boolean types', async () => {
|
||||
const filePath = path.join(fixturesPath, 'types.xlsx')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'types.xlsx' })
|
||||
|
||||
expect(result.metadata.types).toBeDefined()
|
||||
expect(result.metadata.types.ID).toBe('integer')
|
||||
expect(result.metadata.types.Name).toBe('string')
|
||||
expect(['integer', 'float']).toContain(result.metadata.types.Score)
|
||||
expect(['integer', 'float']).toContain(result.metadata.types.Percentage)
|
||||
expect(result.metadata.types.Active).toBe('boolean')
|
||||
|
||||
// Check actual values
|
||||
expect(result.data[0].ID).toBe(1)
|
||||
expect(result.data[0].Active).toBe(true)
|
||||
expect(typeof result.data[0].Score).toBe('number')
|
||||
})
|
||||
|
||||
it('should handle null/empty values', async () => {
|
||||
const filePath = path.join(fixturesPath, 'empty.xlsx')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'empty.xlsx' })
|
||||
|
||||
expect(result.data).toHaveLength(0)
|
||||
expect(result.metadata.rowCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - workbook metadata', () => {
|
||||
it('should extract workbook information', async () => {
|
||||
const filePath = path.join(fixturesPath, 'multi-sheet.xlsx')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'multi-sheet.xlsx' })
|
||||
|
||||
expect(result.metadata.workbookInfo).toBeDefined()
|
||||
expect(result.metadata.workbookInfo.sheetNames).toEqual([
|
||||
'Products', 'Orders', 'Customers'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - edge cases', () => {
|
||||
it('should handle Excel file with only headers', async () => {
|
||||
const filePath = path.join(fixturesPath, 'empty.xlsx')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'empty.xlsx' })
|
||||
|
||||
expect(result.data).toHaveLength(0)
|
||||
expect(result.metadata.sheetCount).toBe(1)
|
||||
})
|
||||
|
||||
it('should skip non-existent sheets when filtering', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.xlsx')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, {
|
||||
filename: 'simple.xlsx',
|
||||
excelSheets: ['Employees', 'NonExistent']
|
||||
})
|
||||
|
||||
expect(result.metadata.sheets).toEqual(['Employees'])
|
||||
expect(result.data).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - performance', () => {
|
||||
it('should measure processing time', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.xlsx')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.xlsx' })
|
||||
|
||||
expect(result.metadata.processingTime).toBeGreaterThanOrEqual(0)
|
||||
expect(typeof result.metadata.processingTime).toBe('number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - field sanitization', () => {
|
||||
it('should sanitize column names', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.xlsx')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.xlsx' })
|
||||
|
||||
// All field names should be valid identifiers
|
||||
const fields = Object.keys(result.data[0]).filter(k => k !== '_sheet')
|
||||
for (const field of fields) {
|
||||
expect(field).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - error handling', () => {
|
||||
it('should handle corrupted Excel file gracefully', async () => {
|
||||
// XLSX library is very forgiving, so we test that it returns empty data
|
||||
// rather than crashing
|
||||
const invalidData = Buffer.from('This is not an Excel file')
|
||||
|
||||
const result = await handler.process(invalidData, { filename: 'invalid.xlsx' })
|
||||
|
||||
// Should return empty data instead of crashing
|
||||
expect(result.data).toHaveLength(0)
|
||||
expect(result.metadata.rowCount).toBe(0)
|
||||
})
|
||||
|
||||
it('should handle binary garbage', async () => {
|
||||
const garbage = Buffer.from(new Uint8Array(256).fill(255))
|
||||
|
||||
const result = await handler.process(garbage, { filename: 'garbage.xlsx' })
|
||||
|
||||
// Should not crash
|
||||
expect(result).toBeDefined()
|
||||
expect(result.format).toBe('excel')
|
||||
})
|
||||
})
|
||||
})
|
||||
292
tests/augmentations/intelligentImport/pdfHandler.unit.test.ts
Normal file
292
tests/augmentations/intelligentImport/pdfHandler.unit.test.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
/**
|
||||
* PDF Handler Tests
|
||||
* Comprehensive tests for PDF text extraction, table detection, and metadata
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { PDFHandler } from '../../../src/augmentations/intelligentImport/handlers/pdfHandler.js'
|
||||
import { promises as fs } from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
describe('PDFHandler', () => {
|
||||
let handler: PDFHandler
|
||||
const fixturesPath = path.join(process.cwd(), 'tests/fixtures/import')
|
||||
|
||||
beforeAll(() => {
|
||||
handler = new PDFHandler()
|
||||
})
|
||||
|
||||
describe('canHandle', () => {
|
||||
it('should handle .pdf extension', () => {
|
||||
expect(handler.canHandle({ filename: 'document.pdf' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle PDF magic bytes', () => {
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n')
|
||||
expect(handler.canHandle(pdfBuffer)).toBe(true)
|
||||
})
|
||||
|
||||
it('should not handle .xlsx extension', () => {
|
||||
expect(handler.canHandle({ filename: 'data.xlsx' })).toBe(false)
|
||||
})
|
||||
|
||||
it('should not handle .csv extension', () => {
|
||||
expect(handler.canHandle({ filename: 'data.csv' })).toBe(false)
|
||||
})
|
||||
|
||||
it('should not handle non-PDF buffer', () => {
|
||||
const buffer = Buffer.from('This is not a PDF')
|
||||
expect(handler.canHandle(buffer)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - simple text PDF', () => {
|
||||
it('should extract text from simple PDF', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.pdf' })
|
||||
|
||||
expect(result.format).toBe('pdf')
|
||||
expect(result.data.length).toBeGreaterThan(0)
|
||||
|
||||
// Should have extracted paragraphs
|
||||
const paragraphs = result.data.filter(item => item._type === 'paragraph')
|
||||
expect(paragraphs.length).toBeGreaterThan(0)
|
||||
|
||||
// Check that text was extracted
|
||||
const hasText = paragraphs.some(p => p.text && p.text.length > 0)
|
||||
expect(hasText).toBe(true)
|
||||
})
|
||||
|
||||
it('should include page numbers', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.pdf' })
|
||||
|
||||
// All items should have _page field
|
||||
expect(result.data.every(item => typeof item._page === 'number')).toBe(true)
|
||||
expect(result.data.every(item => item._page >= 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('should count pages correctly', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.pdf' })
|
||||
|
||||
expect(result.metadata.pageCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - multi-page PDF', () => {
|
||||
it('should extract text from all pages', async () => {
|
||||
const filePath = path.join(fixturesPath, 'multi-page.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'multi-page.pdf' })
|
||||
|
||||
expect(result.metadata.pageCount).toBe(3)
|
||||
|
||||
// Should have content from multiple pages
|
||||
const pages = new Set(result.data.map(item => item._page))
|
||||
expect(pages.size).toBeGreaterThan(1)
|
||||
expect(pages.has(1)).toBe(true)
|
||||
expect(pages.has(2)).toBe(true)
|
||||
expect(pages.has(3)).toBe(true)
|
||||
})
|
||||
|
||||
it('should preserve page order', async () => {
|
||||
const filePath = path.join(fixturesPath, 'multi-page.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'multi-page.pdf' })
|
||||
|
||||
// Page numbers should be in order
|
||||
let lastPage = 0
|
||||
let pageChanged = false
|
||||
|
||||
for (const item of result.data) {
|
||||
if (item._page !== lastPage && lastPage > 0) {
|
||||
pageChanged = true
|
||||
}
|
||||
expect(item._page).toBeGreaterThanOrEqual(lastPage)
|
||||
lastPage = item._page
|
||||
}
|
||||
|
||||
expect(pageChanged).toBe(true) // Should have moved through pages
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - table detection', () => {
|
||||
it('should detect tables in PDF', async () => {
|
||||
const filePath = path.join(fixturesPath, 'table.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'table.pdf' })
|
||||
|
||||
// Should have detected at least one table
|
||||
expect(result.metadata.tableCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should extract table rows when tables detected', async () => {
|
||||
const filePath = path.join(fixturesPath, 'table.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, {
|
||||
filename: 'table.pdf',
|
||||
pdfExtractTables: true
|
||||
})
|
||||
|
||||
// Should have table_row items
|
||||
const tableRows = result.data.filter(item => item._type === 'table_row')
|
||||
expect(tableRows.length).toBeGreaterThan(0)
|
||||
|
||||
// Table rows should have structured data
|
||||
if (tableRows.length > 0) {
|
||||
const firstRow = tableRows[0]
|
||||
const fields = Object.keys(firstRow).filter(k => !k.startsWith('_'))
|
||||
expect(fields.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('should skip table extraction when disabled', async () => {
|
||||
const filePath = path.join(fixturesPath, 'table.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, {
|
||||
filename: 'table.pdf',
|
||||
pdfExtractTables: false
|
||||
})
|
||||
|
||||
// Should not have detected tables
|
||||
expect(result.metadata.tableCount).toBe(0)
|
||||
|
||||
// Should not have table_row items
|
||||
const tableRows = result.data.filter(item => item._type === 'table_row')
|
||||
expect(tableRows.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - metadata extraction', () => {
|
||||
it('should extract PDF metadata', async () => {
|
||||
const filePath = path.join(fixturesPath, 'metadata.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'metadata.pdf' })
|
||||
|
||||
expect(result.metadata.pdfMetadata).toBeDefined()
|
||||
expect(result.metadata.pdfMetadata.title).toBe('Test Document')
|
||||
expect(result.metadata.pdfMetadata.author).toBe('Test Author')
|
||||
expect(result.metadata.pdfMetadata.creator).toBe('Brainy Test Suite')
|
||||
})
|
||||
|
||||
it('should handle PDFs without metadata gracefully', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.pdf' })
|
||||
|
||||
expect(result.metadata.pdfMetadata).toBeDefined()
|
||||
// Some fields may be null
|
||||
expect(result.metadata.pdfMetadata).toHaveProperty('title')
|
||||
expect(result.metadata.pdfMetadata).toHaveProperty('author')
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - text statistics', () => {
|
||||
it('should track total text length', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.pdf' })
|
||||
|
||||
expect(result.metadata.textLength).toBeGreaterThan(0)
|
||||
expect(typeof result.metadata.textLength).toBe('number')
|
||||
})
|
||||
|
||||
it('should count extracted items', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.pdf' })
|
||||
|
||||
expect(result.metadata.rowCount).toBe(result.data.length)
|
||||
expect(result.metadata.rowCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - edge cases', () => {
|
||||
it('should handle empty PDF', async () => {
|
||||
const filePath = path.join(fixturesPath, 'empty.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'empty.pdf' })
|
||||
|
||||
expect(result.format).toBe('pdf')
|
||||
expect(result.metadata.pageCount).toBe(1)
|
||||
// Empty PDF may have 0 or minimal content
|
||||
expect(result.data).toBeDefined()
|
||||
})
|
||||
|
||||
it('should measure processing time', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.pdf' })
|
||||
|
||||
expect(result.metadata.processingTime).toBeGreaterThan(0)
|
||||
expect(typeof result.metadata.processingTime).toBe('number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - data structure', () => {
|
||||
it('should include type indicators', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.pdf' })
|
||||
|
||||
// All items should have _type field
|
||||
expect(result.data.every(item => '_type' in item)).toBe(true)
|
||||
|
||||
// Types should be valid
|
||||
const validTypes = ['paragraph', 'table_row']
|
||||
expect(result.data.every(item => validTypes.includes(item._type))).toBe(true)
|
||||
})
|
||||
|
||||
it('should include index for paragraphs', async () => {
|
||||
const filePath = path.join(fixturesPath, 'simple.pdf')
|
||||
const buffer = await fs.readFile(filePath)
|
||||
|
||||
const result = await handler.process(buffer, { filename: 'simple.pdf' })
|
||||
|
||||
const paragraphs = result.data.filter(item => item._type === 'paragraph')
|
||||
|
||||
// Paragraphs should have _index field
|
||||
expect(paragraphs.every(p => typeof p._index === 'number')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('process - error handling', () => {
|
||||
it('should throw error for invalid PDF', async () => {
|
||||
const invalidData = Buffer.from('This is not a PDF file')
|
||||
|
||||
await expect(
|
||||
handler.process(invalidData, { filename: 'invalid.pdf' })
|
||||
).rejects.toThrow('PDF parsing failed')
|
||||
})
|
||||
|
||||
it('should throw error for corrupted PDF', async () => {
|
||||
const corruptedPDF = Buffer.concat([
|
||||
Buffer.from('%PDF-1.4\n'),
|
||||
Buffer.from('corrupted data that is not valid PDF structure')
|
||||
])
|
||||
|
||||
await expect(
|
||||
handler.process(corruptedPDF, { filename: 'corrupted.pdf' })
|
||||
).rejects.toThrow('PDF parsing failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue