Files
inventory-barcode-system/__tests__/ExcelImportService.test.js

595 lines
24 KiB
JavaScript

const ExcelImportService = require('../services/ExcelImportService');
const XLSX = require('xlsx');
describe('ExcelImportService', () => {
let service;
beforeEach(() => {
service = new ExcelImportService();
});
describe('Column Detection', () => {
test('should detect standard column names', () => {
const headers = ['Product Code', 'Description', 'Quantity', 'Category'];
const mapping = service.detectColumns(headers);
expect(mapping.productCode).toBe(0);
expect(mapping.description).toBe(1);
expect(mapping.quantity).toBe(2);
expect(mapping.category).toBe(3);
});
test('should detect case-insensitive column names', () => {
const headers = ['PRODUCT_CODE', 'desc', 'QTY', 'cat'];
const mapping = service.detectColumns(headers);
expect(mapping.productCode).toBe(0);
expect(mapping.description).toBe(1);
expect(mapping.quantity).toBe(2);
expect(mapping.category).toBe(3);
});
test('should detect alternative column names', () => {
const headers = ['SKU', 'Item Name', 'Stock Level', 'Type'];
const mapping = service.detectColumns(headers);
expect(mapping.productCode).toBe(0);
expect(mapping.description).toBe(1);
expect(mapping.quantity).toBe(2);
expect(mapping.category).toBe(3);
});
test('should handle missing columns', () => {
const headers = ['Product Code', 'Description'];
const mapping = service.detectColumns(headers);
expect(mapping.productCode).toBe(0);
expect(mapping.description).toBe(1);
expect(mapping.quantity).toBe(null);
expect(mapping.category).toBe(null);
});
test('should handle partial matches', () => {
const headers = ['Item_Code_Number', 'Product_Description_Text', 'Current_Quantity_Level'];
const mapping = service.detectColumns(headers);
expect(mapping.productCode).toBe(0);
expect(mapping.description).toBe(1);
expect(mapping.quantity).toBe(2);
});
});
describe('Quantity Parsing', () => {
test('should parse valid numbers', () => {
expect(service.parseQuantity('100')).toBe(100);
expect(service.parseQuantity('0')).toBe(0);
expect(service.parseQuantity('50.7')).toBe(50); // Should floor to integer
});
test('should handle formatted numbers', () => {
expect(service.parseQuantity('1,000')).toBe(1000);
expect(service.parseQuantity(' 250 ')).toBe(250);
expect(service.parseQuantity('1 500')).toBe(1500);
});
test('should handle empty or invalid values', () => {
expect(service.parseQuantity('')).toBe(0);
expect(service.parseQuantity(null)).toBe(0);
expect(service.parseQuantity('abc')).toBe(null);
expect(service.parseQuantity('N/A')).toBe(null);
});
test('should ensure non-negative values', () => {
expect(service.parseQuantity('-50')).toBe(0);
expect(service.parseQuantity('-10.5')).toBe(0);
});
});
describe('Cell Value Extraction', () => {
test('should extract valid cell values', () => {
const row = ['ABC123', 'Test Product', '50', 'Electronics'];
expect(service.getCellValue(row, 0)).toBe('ABC123');
expect(service.getCellValue(row, 1)).toBe('Test Product');
expect(service.getCellValue(row, 2)).toBe('50');
expect(service.getCellValue(row, 3)).toBe('Electronics');
});
test('should handle missing columns', () => {
const row = ['ABC123', 'Test Product'];
expect(service.getCellValue(row, 5)).toBe('');
expect(service.getCellValue(row, null)).toBe('');
});
test('should trim whitespace', () => {
const row = [' ABC123 ', ' Test Product '];
expect(service.getCellValue(row, 0)).toBe('ABC123');
expect(service.getCellValue(row, 1)).toBe('Test Product');
});
test('should handle null and undefined values', () => {
const row = [null, undefined, '', 0];
expect(service.getCellValue(row, 0)).toBe('');
expect(service.getCellValue(row, 1)).toBe('');
expect(service.getCellValue(row, 2)).toBe('');
expect(service.getCellValue(row, 3)).toBe('0');
});
});
describe('Data Row Parsing', () => {
test('should parse valid data rows', () => {
const dataRows = [
['ABC123', 'Test Product 1', '100', 'Electronics'],
['DEF456', 'Test Product 2', '50', 'Books']
];
const columnMapping = { productCode: 0, description: 1, quantity: 2, category: 3 };
const headerRow = ['Code', 'Name', 'Qty', 'Cat'];
const products = service.parseDataRows(dataRows, columnMapping, headerRow);
expect(products).toHaveLength(2);
expect(products[0]).toMatchObject({
rowNumber: 2,
productCode: 'ABC123',
description: 'Test Product 1',
quantity: 100,
category: 'Electronics',
errors: []
});
});
test('should skip empty rows', () => {
const dataRows = [
['ABC123', 'Test Product 1', '100', 'Electronics'],
['', '', '', ''],
[null, null, null, null],
['DEF456', 'Test Product 2', '50', 'Books']
];
const columnMapping = { productCode: 0, description: 1, quantity: 2, category: 3 };
const headerRow = ['Code', 'Name', 'Qty', 'Cat'];
const products = service.parseDataRows(dataRows, columnMapping, headerRow);
expect(products).toHaveLength(2);
expect(products[0].productCode).toBe('ABC123');
expect(products[1].productCode).toBe('DEF456');
});
test('should add errors for missing product codes', () => {
const dataRows = [
['', 'Test Product 1', '100', 'Electronics'],
['DEF456', 'Test Product 2', '50', 'Books']
];
const columnMapping = { productCode: 0, description: 1, quantity: 2, category: 3 };
const headerRow = ['Code', 'Name', 'Qty', 'Cat'];
const products = service.parseDataRows(dataRows, columnMapping, headerRow);
expect(products[0].errors).toHaveLength(1);
expect(products[0].errors[0].type).toBe('MISSING_PRODUCT_CODE');
expect(products[1].errors).toHaveLength(0);
});
test('should add errors for invalid quantities', () => {
const dataRows = [
['ABC123', 'Test Product 1', 'invalid', 'Electronics'],
['DEF456', 'Test Product 2', '50', 'Books']
];
const columnMapping = { productCode: 0, description: 1, quantity: 2, category: 3 };
const headerRow = ['Code', 'Name', 'Qty', 'Cat'];
const products = service.parseDataRows(dataRows, columnMapping, headerRow);
expect(products[0].errors).toHaveLength(1);
expect(products[0].errors[0].type).toBe('INVALID_QUANTITY');
expect(products[1].errors).toHaveLength(0);
});
});
describe('Excel File Parsing', () => {
test('should parse a simple Excel file', async () => {
// Create a simple workbook for testing
const testData = [
['Product Code', 'Description', 'Quantity', 'Category'],
['ABC123', 'Test Product 1', 100, 'Electronics'],
['DEF456', 'Test Product 2', 50, 'Books']
];
const worksheet = XLSX.utils.aoa_to_sheet(testData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
const result = await service.parseExcelFile(buffer);
expect(result.success).toBe(true);
expect(result.data.products).toHaveLength(2);
expect(result.data.totalRows).toBe(2);
expect(result.data.columnMapping.productCode).toBe(0);
expect(result.errors).toHaveLength(0);
});
test('should handle empty Excel files', async () => {
const worksheet = XLSX.utils.aoa_to_sheet([]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
const result = await service.parseExcelFile(buffer);
expect(result.success).toBe(false);
expect(result.errors[0].type).toBe('PARSE_ERROR');
expect(result.errors[0].message).toContain('empty');
});
test('should handle invalid file buffers', async () => {
const invalidBuffer = Buffer.from('not an excel file');
const result = await service.parseExcelFile(invalidBuffer);
// xlsx library is quite forgiving, so this might actually succeed with empty data
// Let's check that it either fails or returns empty data
if (!result.success) {
expect(result.errors[0].type).toBe('PARSE_ERROR');
} else {
// If it succeeds, it should have empty or minimal data
expect(result.data.products.length).toBeLessThanOrEqual(1);
}
});
test('should handle missing sheet names', async () => {
const testData = [['Header'], ['Data']];
const worksheet = XLSX.utils.aoa_to_sheet(testData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
const result = await service.parseExcelFile(buffer, { sheetName: 'NonExistent' });
expect(result.success).toBe(false);
expect(result.errors[0].message).toContain('Sheet "NonExistent" not found');
});
});
describe('Column Suggestions', () => {
test('should provide column suggestions', () => {
const headers = ['Item_ID', 'Product_Name', 'Stock_Count', 'Product_Type'];
const suggestions = service.getColumnSuggestions(headers);
expect(suggestions.productCode).toHaveLength(1);
expect(suggestions.productCode[0].header).toBe('Item_ID');
expect(suggestions.description).toHaveLength(1);
expect(suggestions.description[0].header).toBe('Product_Name');
expect(suggestions.quantity).toHaveLength(1);
expect(suggestions.quantity[0].header).toBe('Stock_Count');
expect(suggestions.category).toHaveLength(1);
expect(suggestions.category[0].header).toBe('Product_Type');
});
test('should rank suggestions by relevance', () => {
const headers = ['Code', 'Product_Code', 'Item_Code_Number'];
const suggestions = service.getColumnSuggestions(headers);
expect(suggestions.productCode).toHaveLength(3);
// Should have suggestions sorted by score
expect(suggestions.productCode[0].score).toBeGreaterThanOrEqual(suggestions.productCode[1].score);
expect(suggestions.productCode[1].score).toBeGreaterThanOrEqual(suggestions.productCode[2].score);
// Both 'Code' and 'Product_Code' should be high-scoring matches
const topSuggestions = suggestions.productCode.slice(0, 2).map(s => s.header);
expect(topSuggestions).toContain('Code');
expect(topSuggestions).toContain('Product_Code');
});
});
describe('Data Validation', () => {
test('should validate clean data successfully', () => {
const products = [
{ productCode: 'ABC123', description: 'Product 1', quantity: 100, errors: [] },
{ productCode: 'DEF456', description: 'Product 2', quantity: 50, errors: [] }
];
const validation = service.validateParsedData(products);
expect(validation.isValid).toBe(true);
expect(validation.statistics.validProducts).toBe(2);
expect(validation.statistics.invalidProducts).toBe(0);
expect(validation.duplicates).toHaveLength(0);
});
test('should detect duplicate product codes', () => {
const products = [
{ productCode: 'ABC123', description: 'Product 1', quantity: 100, errors: [], rowNumber: 2 },
{ productCode: 'ABC123', description: 'Product 2', quantity: 50, errors: [], rowNumber: 3 },
{ productCode: 'DEF456', description: 'Product 3', quantity: 25, errors: [], rowNumber: 4 }
];
const validation = service.validateParsedData(products);
expect(validation.statistics.duplicateProducts).toBe(1);
expect(validation.warnings).toHaveLength(1);
expect(validation.warnings[0].type).toBe('DUPLICATE_PRODUCT_CODES');
expect(validation.duplicates).toHaveLength(1);
});
test('should count invalid products', () => {
const products = [
{ productCode: 'ABC123', description: 'Product 1', quantity: 100, errors: [] },
{ productCode: '', description: 'Product 2', quantity: 50, errors: [{ type: 'MISSING_PRODUCT_CODE' }] }
];
const validation = service.validateParsedData(products);
expect(validation.isValid).toBe(false);
expect(validation.statistics.validProducts).toBe(1);
expect(validation.statistics.invalidProducts).toBe(1);
});
});
describe('Edge Cases', () => {
test('should handle mixed data types in cells', async () => {
const testData = [
['Product Code', 'Description', 'Quantity'],
[123, 'Product with numeric code', '50'],
['ABC456', 789, 100], // Numeric description
['DEF789', 'Normal Product', 'N/A'] // Invalid quantity
];
const worksheet = XLSX.utils.aoa_to_sheet(testData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
const result = await service.parseExcelFile(buffer);
expect(result.success).toBe(true);
expect(result.data.products).toHaveLength(3);
expect(result.data.products[0].productCode).toBe('123');
expect(result.data.products[1].description).toBe('789');
expect(result.data.products[2].quantity).toBe(null);
expect(result.data.products[2].errors).toHaveLength(1);
});
test('should handle Excel files with multiple sheets', async () => {
const testData1 = [['Code', 'Name'], ['ABC123', 'Product 1']];
const testData2 = [['Product_Code', 'Description'], ['DEF456', 'Product 2']];
const worksheet1 = XLSX.utils.aoa_to_sheet(testData1);
const worksheet2 = XLSX.utils.aoa_to_sheet(testData2);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet1, 'Inventory');
XLSX.utils.book_append_sheet(workbook, worksheet2, 'Products');
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
const result = await service.parseExcelFile(buffer, { sheetName: 'Products' });
expect(result.success).toBe(true);
expect(result.data.sheetName).toBe('Products');
expect(result.data.availableSheets).toEqual(['Inventory', 'Products']);
expect(result.data.products[0].productCode).toBe('DEF456');
});
});
describe('Data Validation and Error Handling', () => {
describe('Product Code Validation', () => {
test('should validate correct product codes', () => {
expect(service.validateProductCode('ABC123')).toMatchObject({ isValid: true });
expect(service.validateProductCode('ITEM-001')).toMatchObject({ isValid: true });
expect(service.validateProductCode('SKU_456')).toMatchObject({ isValid: true });
});
test('should reject invalid product codes', () => {
expect(service.validateProductCode('')).toMatchObject({ isValid: false });
expect(service.validateProductCode('A'.repeat(51))).toMatchObject({ isValid: false });
expect(service.validateProductCode('ABC@123')).toMatchObject({ isValid: false });
expect(service.validateProductCode('ABC 123')).toMatchObject({ isValid: false });
});
});
describe('Comprehensive Data Validation', () => {
test('should validate clean data successfully', async () => {
const products = [
{ productCode: 'ABC123', description: 'Product 1', quantity: 100, category: 'Electronics', errors: [], rowNumber: 2 },
{ productCode: 'DEF456', description: 'Product 2', quantity: 50, category: 'Books', errors: [], rowNumber: 3 }
];
const validation = await service.validateImportData(products);
expect(validation.isValid).toBe(true);
expect(validation.statistics.validProducts).toBe(2);
expect(validation.statistics.invalidProducts).toBe(0);
expect(validation.errors).toHaveLength(0);
});
test('should detect various validation errors', async () => {
const products = [
{ productCode: '', description: 'Product 1', quantity: 100, errors: [], rowNumber: 2 },
{ productCode: 'ABC123', description: 'A'.repeat(501), quantity: -10, errors: [], rowNumber: 3 },
{ productCode: 'DEF@456', description: 'Product 3', quantity: null, errors: [], rowNumber: 4 }
];
const validation = await service.validateImportData(products);
expect(validation.isValid).toBe(false);
expect(validation.statistics.validProducts).toBe(0);
expect(validation.statistics.invalidProducts).toBe(3);
// Check specific error types
const product1Errors = validation.validatedProducts[0].errors;
expect(product1Errors.some(e => e.type === 'MISSING_PRODUCT_CODE')).toBe(true);
const product2Errors = validation.validatedProducts[1].errors;
expect(product2Errors.some(e => e.type === 'DESCRIPTION_TOO_LONG')).toBe(true);
expect(product2Errors.some(e => e.type === 'NEGATIVE_QUANTITY')).toBe(true);
const product3Errors = validation.validatedProducts[2].errors;
expect(product3Errors.some(e => e.type === 'INVALID_PRODUCT_CODE_FORMAT')).toBe(true);
expect(product3Errors.some(e => e.type === 'INVALID_QUANTITY')).toBe(true);
});
test('should detect duplicate product codes', async () => {
const products = [
{ productCode: 'ABC123', description: 'Product 1', quantity: 100, errors: [], rowNumber: 2 },
{ productCode: 'ABC123', description: 'Product 2', quantity: 50, errors: [], rowNumber: 3 },
{ productCode: 'DEF456', description: 'Product 3', quantity: 25, errors: [], rowNumber: 4 }
];
const validation = await service.validateImportData(products);
expect(validation.statistics.duplicateProducts).toBe(1);
expect(validation.duplicates).toHaveLength(1);
expect(validation.duplicates[0].productCode).toBe('ABC123');
expect(validation.duplicates[0].rows).toEqual([2, 3]);
expect(validation.warnings.some(w => w.type === 'DUPLICATE_PRODUCT_CODES')).toBe(true);
});
test('should add warnings for large quantities', async () => {
const products = [
{ productCode: 'ABC123', description: 'Product 1', quantity: 1500000, errors: [], rowNumber: 2 }
];
const validation = await service.validateImportData(products);
expect(validation.isValid).toBe(true);
expect(validation.validatedProducts[0].warnings.some(w => w.type === 'LARGE_QUANTITY')).toBe(true);
});
});
describe('Duplicate Handling', () => {
test('should skip duplicates when strategy is skip', () => {
const products = [
{ productCode: 'ABC123', description: 'Product 1', quantity: 100 },
{ productCode: 'ABC123', description: 'Product 2', quantity: 50 },
{ productCode: 'DEF456', description: 'Product 3', quantity: 25 }
];
const processed = service.handleDuplicates(products, 'skip');
expect(processed).toHaveLength(3);
expect(processed[0].skipped).toBeUndefined();
expect(processed[1].skipped).toBe(true);
expect(processed[1].warnings.some(w => w.type === 'DUPLICATE_SKIPPED')).toBe(true);
expect(processed[2].skipped).toBeUndefined();
});
test('should mark for update when strategy is update', () => {
const products = [
{ productCode: 'ABC123', description: 'Product 1', quantity: 100 },
{ productCode: 'ABC123', description: 'Product 2', quantity: 50 },
{ productCode: 'DEF456', description: 'Product 3', quantity: 25 }
];
const processed = service.handleDuplicates(products, 'update');
expect(processed).toHaveLength(3);
expect(processed[0].updateExisting).toBeUndefined();
expect(processed[1].updateExisting).toBe(true);
expect(processed[1].warnings.some(w => w.type === 'DUPLICATE_WILL_UPDATE')).toBe(true);
expect(processed[2].updateExisting).toBeUndefined();
});
test('should rename duplicates when strategy is rename', () => {
const products = [
{ productCode: 'ABC123', description: 'Product 1', quantity: 100 },
{ productCode: 'ABC123', description: 'Product 2', quantity: 50 },
{ productCode: 'ABC123', description: 'Product 3', quantity: 25 }
];
const processed = service.handleDuplicates(products, 'rename');
expect(processed).toHaveLength(3);
expect(processed[0].productCode).toBe('ABC123');
expect(processed[1].productCode).toBe('ABC123_2');
expect(processed[1].originalProductCode).toBe('ABC123');
expect(processed[2].productCode).toBe('ABC123_3');
expect(processed[2].originalProductCode).toBe('ABC123');
});
});
describe('Complete Import Process', () => {
test('should process complete import workflow', async () => {
// Create test Excel file
const testData = [
['Product Code', 'Description', 'Quantity', 'Category'],
['ABC123', 'Test Product 1', 100, 'Electronics'],
['DEF456', 'Test Product 2', 50, 'Books']
];
const worksheet = XLSX.utils.aoa_to_sheet(testData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
const results = await service.processImport(buffer, {
filename: 'test.xlsx',
duplicateStrategy: 'skip',
importToDatabase: false // Don't actually import to database in test
});
expect(results.success).toBe(true);
expect(results.parseResults.success).toBe(true);
expect(results.validationResults.isValid).toBe(true);
expect(results.parseResults.data.products).toHaveLength(2);
expect(results.validationResults.statistics.validProducts).toBe(2);
});
test('should handle import process errors gracefully', async () => {
const invalidBuffer = Buffer.from('invalid data');
const results = await service.processImport(invalidBuffer, {
filename: 'invalid.xlsx'
});
// Should handle the error gracefully
expect(results.success).toBeDefined();
expect(results.parseResults).toBeDefined();
});
});
describe('Error Reporting', () => {
test('should provide detailed error information', async () => {
const products = [
{ productCode: '', description: 'Product 1', quantity: null, errors: [], rowNumber: 2 },
{ productCode: 'ABC@123', description: 'A'.repeat(501), quantity: -10, errors: [], rowNumber: 3 }
];
const validation = await service.validateImportData(products);
expect(validation.validatedProducts[0].errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'MISSING_PRODUCT_CODE',
field: 'productCode',
message: expect.any(String)
}),
expect.objectContaining({
type: 'INVALID_QUANTITY',
field: 'quantity',
message: expect.any(String)
})
])
);
expect(validation.validatedProducts[1].errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: 'INVALID_PRODUCT_CODE_FORMAT',
field: 'productCode'
}),
expect.objectContaining({
type: 'DESCRIPTION_TOO_LONG',
field: 'description'
}),
expect.objectContaining({
type: 'NEGATIVE_QUANTITY',
field: 'quantity'
})
])
);
});
});
});});