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' }) ]) ); }); }); });});