606 lines
19 KiB
JavaScript
606 lines
19 KiB
JavaScript
const XLSX = require('xlsx');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
|
|
// Mock dependencies before importing the service
|
|
jest.mock('../models/database', () => ({
|
|
getDatabase: jest.fn()
|
|
}));
|
|
|
|
jest.mock('fs', () => ({
|
|
promises: {
|
|
mkdir: jest.fn(),
|
|
writeFile: jest.fn(),
|
|
readdir: jest.fn(),
|
|
stat: jest.fn(),
|
|
unlink: jest.fn()
|
|
}
|
|
}));
|
|
|
|
const ExcelExportService = require('../services/ExcelExportService');
|
|
const database = require('../models/database');
|
|
|
|
describe('ExcelExportService', () => {
|
|
let exportService;
|
|
let mockDb;
|
|
|
|
beforeEach(() => {
|
|
exportService = new ExcelExportService();
|
|
|
|
// Mock database instance
|
|
mockDb = {
|
|
prepare: jest.fn(),
|
|
exec: jest.fn()
|
|
};
|
|
|
|
database.getDatabase = jest.fn().mockReturnValue(mockDb);
|
|
|
|
// Clear all mocks
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('constructor', () => {
|
|
it('should initialize with correct export directory', () => {
|
|
expect(exportService.exportDirectory).toContain(path.join('data', 'exports'));
|
|
});
|
|
|
|
it('should ensure export directory exists', async () => {
|
|
await exportService.ensureExportDirectory();
|
|
expect(fs.mkdir).toHaveBeenCalledWith(
|
|
expect.stringContaining(path.join('data', 'exports')),
|
|
{ recursive: true }
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('getInventoryData', () => {
|
|
beforeEach(() => {
|
|
const mockStmt = {
|
|
all: jest.fn().mockReturnValue([
|
|
{
|
|
product_code: 'TEST001',
|
|
description: 'Test Product 1',
|
|
category: 'Electronics',
|
|
unit_of_measure: 'pcs',
|
|
current_level: 100,
|
|
minimum_level: 10,
|
|
maximum_level: 500,
|
|
last_updated: '2024-01-15T10:30:00Z',
|
|
updated_by: 'user1',
|
|
stock_status: 'Normal'
|
|
},
|
|
{
|
|
product_code: 'TEST002',
|
|
description: 'Test Product 2',
|
|
category: 'Tools',
|
|
unit_of_measure: 'pcs',
|
|
current_level: 5,
|
|
minimum_level: 10,
|
|
maximum_level: 100,
|
|
last_updated: '2024-01-14T15:45:00Z',
|
|
updated_by: 'user2',
|
|
stock_status: 'Low Stock'
|
|
}
|
|
])
|
|
};
|
|
mockDb.prepare.mockReturnValue(mockStmt);
|
|
});
|
|
|
|
it('should retrieve inventory data without filters', async () => {
|
|
const data = await exportService.getInventoryData();
|
|
|
|
expect(data).toHaveLength(2);
|
|
expect(data[0].product_code).toBe('TEST001');
|
|
expect(data[1].stock_status).toBe('Low Stock');
|
|
});
|
|
|
|
it('should apply category filter', async () => {
|
|
await exportService.getInventoryData({ category: 'Electronics' });
|
|
|
|
const query = mockDb.prepare.mock.calls[0][0];
|
|
expect(query).toContain('WHERE p.category = ?');
|
|
});
|
|
|
|
it('should apply stock status filter', async () => {
|
|
await exportService.getInventoryData({ stockStatus: 'low' });
|
|
|
|
const query = mockDb.prepare.mock.calls[0][0];
|
|
expect(query).toContain('i.current_level <= i.minimum_level');
|
|
});
|
|
|
|
it('should apply updated since filter', async () => {
|
|
const since = '2024-01-01T00:00:00Z';
|
|
await exportService.getInventoryData({ updatedSince: since });
|
|
|
|
const query = mockDb.prepare.mock.calls[0][0];
|
|
expect(query).toContain('i.last_updated >= ?');
|
|
});
|
|
|
|
it('should apply product codes filter', async () => {
|
|
await exportService.getInventoryData({ productCodes: ['TEST001', 'TEST002'] });
|
|
|
|
const query = mockDb.prepare.mock.calls[0][0];
|
|
expect(query).toContain('p.product_code IN (?,?)');
|
|
});
|
|
});
|
|
|
|
describe('createNewExcelFile', () => {
|
|
const mockInventoryData = [
|
|
{
|
|
product_code: 'TEST001',
|
|
description: 'Test Product 1',
|
|
category: 'Electronics',
|
|
unit_of_measure: 'pcs',
|
|
current_level: 100,
|
|
minimum_level: 10,
|
|
maximum_level: 500,
|
|
last_updated: '2024-01-15T10:30:00Z',
|
|
updated_by: 'user1',
|
|
stock_status: 'Normal'
|
|
}
|
|
];
|
|
|
|
it('should create new Excel file with inventory sheet', async () => {
|
|
const result = await exportService.createNewExcelFile(mockInventoryData);
|
|
|
|
expect(result.workbook).toBeDefined();
|
|
expect(result.metadata.sheets).toContain('Inventory');
|
|
expect(result.metadata.sheets).toContain('Summary');
|
|
expect(result.metadata.recordCount).toBe(1);
|
|
});
|
|
|
|
it('should include history sheet when requested', async () => {
|
|
// Mock history data
|
|
const mockHistoryStmt = {
|
|
all: jest.fn().mockReturnValue([
|
|
{
|
|
product_code: 'TEST001',
|
|
description: 'Test Product 1',
|
|
old_level: 90,
|
|
new_level: 100,
|
|
change_reason: 'Stock adjustment',
|
|
updated_by: 'user1',
|
|
updated_at: '2024-01-15T10:30:00Z'
|
|
}
|
|
])
|
|
};
|
|
mockDb.prepare.mockReturnValue(mockHistoryStmt);
|
|
|
|
const result = await exportService.createNewExcelFile(mockInventoryData, { includeHistory: true });
|
|
|
|
expect(result.metadata.sheets).toContain('History');
|
|
expect(result.metadata.includeHistory).toBe(true);
|
|
});
|
|
|
|
it('should exclude audit info when requested', async () => {
|
|
const result = await exportService.createNewExcelFile(mockInventoryData, { includeAuditInfo: false });
|
|
|
|
expect(result.metadata.includeAuditInfo).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('createInventorySheet', () => {
|
|
const mockInventoryData = [
|
|
{
|
|
product_code: 'TEST001',
|
|
description: 'Test Product 1',
|
|
category: 'Electronics',
|
|
unit_of_measure: 'pcs',
|
|
current_level: 100,
|
|
minimum_level: 10,
|
|
maximum_level: 500,
|
|
last_updated: '2024-01-15T10:30:00Z',
|
|
updated_by: 'user1',
|
|
stock_status: 'Normal'
|
|
}
|
|
];
|
|
|
|
it('should create worksheet with correct headers', () => {
|
|
const worksheet = exportService.createInventorySheet(mockInventoryData, true);
|
|
|
|
// Convert worksheet to array to check content
|
|
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
|
const headers = data[0];
|
|
|
|
expect(headers).toContain('Product Code');
|
|
expect(headers).toContain('Description');
|
|
expect(headers).toContain('Current Level');
|
|
expect(headers).toContain('Last Updated');
|
|
expect(headers).toContain('Updated By');
|
|
});
|
|
|
|
it('should exclude audit info when requested', () => {
|
|
const worksheet = exportService.createInventorySheet(mockInventoryData, false);
|
|
|
|
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
|
const headers = data[0];
|
|
|
|
expect(headers).not.toContain('Last Updated');
|
|
expect(headers).not.toContain('Updated By');
|
|
});
|
|
|
|
it('should include data rows with correct values', () => {
|
|
const worksheet = exportService.createInventorySheet(mockInventoryData, true);
|
|
|
|
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
|
const dataRow = data[1];
|
|
|
|
expect(dataRow[0]).toBe('TEST001'); // Product Code
|
|
expect(dataRow[1]).toBe('Test Product 1'); // Description
|
|
expect(dataRow[4]).toBe(100); // Current Level
|
|
});
|
|
|
|
it('should set column widths', () => {
|
|
const worksheet = exportService.createInventorySheet(mockInventoryData, true);
|
|
|
|
expect(worksheet['!cols']).toBeDefined();
|
|
expect(worksheet['!cols']).toHaveLength(10); // 8 base + 2 audit columns
|
|
});
|
|
});
|
|
|
|
describe('updateOriginalExcelFile', () => {
|
|
let originalFileBuffer;
|
|
|
|
beforeEach(() => {
|
|
// Create a mock Excel file buffer
|
|
const mockWorkbook = XLSX.utils.book_new();
|
|
const mockData = [
|
|
['Product Code', 'Description', 'Quantity'],
|
|
['TEST001', 'Old Description', 50],
|
|
['TEST002', 'Another Product', 25]
|
|
];
|
|
const mockWorksheet = XLSX.utils.aoa_to_sheet(mockData);
|
|
XLSX.utils.book_append_sheet(mockWorkbook, mockWorksheet, 'Sheet1');
|
|
originalFileBuffer = XLSX.write(mockWorkbook, { type: 'buffer', bookType: 'xlsx' });
|
|
});
|
|
|
|
it('should update existing products in original file', async () => {
|
|
const inventoryData = [
|
|
{
|
|
product_code: 'TEST001',
|
|
description: 'Updated Description',
|
|
current_level: 75,
|
|
category: 'Electronics',
|
|
last_updated: '2024-01-15T10:30:00Z'
|
|
}
|
|
];
|
|
|
|
const result = await exportService.updateOriginalExcelFile(originalFileBuffer, inventoryData);
|
|
|
|
expect(result.workbook).toBeDefined();
|
|
expect(result.metadata.updatedRows).toBe(1);
|
|
expect(result.metadata.preservedFormatting).toBe(true);
|
|
});
|
|
|
|
it('should add new products when includeNewProducts is true', async () => {
|
|
const inventoryData = [
|
|
{
|
|
product_code: 'TEST003',
|
|
description: 'New Product',
|
|
current_level: 30,
|
|
category: 'Tools'
|
|
}
|
|
];
|
|
|
|
const result = await exportService.updateOriginalExcelFile(
|
|
originalFileBuffer,
|
|
inventoryData,
|
|
{ includeNewProducts: true }
|
|
);
|
|
|
|
expect(result.metadata.addedRows).toBe(1);
|
|
});
|
|
|
|
it('should handle empty original file', async () => {
|
|
const emptyWorkbook = XLSX.utils.book_new();
|
|
const emptyWorksheet = XLSX.utils.aoa_to_sheet([]);
|
|
XLSX.utils.book_append_sheet(emptyWorkbook, emptyWorksheet, 'Sheet1');
|
|
const emptyBuffer = XLSX.write(emptyWorkbook, { type: 'buffer', bookType: 'xlsx' });
|
|
|
|
await expect(
|
|
exportService.updateOriginalExcelFile(emptyBuffer, [])
|
|
).rejects.toThrow('Original Excel file appears to be empty');
|
|
});
|
|
|
|
it('should handle missing sheet', async () => {
|
|
const inventoryData = [];
|
|
|
|
await expect(
|
|
exportService.updateOriginalExcelFile(originalFileBuffer, inventoryData, { sheetName: 'NonExistent' })
|
|
).rejects.toThrow('Sheet "NonExistent" not found');
|
|
});
|
|
});
|
|
|
|
describe('detectColumnMappings', () => {
|
|
it('should detect standard column mappings', () => {
|
|
const headerRow = ['Product Code', 'Description', 'Quantity', 'Category'];
|
|
const mapping = exportService.detectColumnMappings(headerRow);
|
|
|
|
expect(mapping.productCode).toBe(0);
|
|
expect(mapping.description).toBe(1);
|
|
expect(mapping.quantity).toBe(2);
|
|
expect(mapping.category).toBe(3);
|
|
});
|
|
|
|
it('should handle case-insensitive matching', () => {
|
|
const headerRow = ['PRODUCT_CODE', 'desc', 'QTY'];
|
|
const mapping = exportService.detectColumnMappings(headerRow);
|
|
|
|
expect(mapping.productCode).toBe(0);
|
|
expect(mapping.description).toBe(1);
|
|
expect(mapping.quantity).toBe(2);
|
|
});
|
|
|
|
it('should handle partial matches', () => {
|
|
const headerRow = ['Item Code', 'Product Name', 'Stock Level'];
|
|
const mapping = exportService.detectColumnMappings(headerRow);
|
|
|
|
expect(mapping.productCode).toBe(0);
|
|
expect(mapping.description).toBe(1);
|
|
expect(mapping.quantity).toBe(2);
|
|
});
|
|
|
|
it('should return null for unmatched columns', () => {
|
|
const headerRow = ['Unknown1', 'Unknown2'];
|
|
const mapping = exportService.detectColumnMappings(headerRow);
|
|
|
|
expect(mapping.productCode).toBeNull();
|
|
expect(mapping.description).toBeNull();
|
|
expect(mapping.quantity).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('exportInventoryToExcel', () => {
|
|
beforeEach(() => {
|
|
// Mock getInventoryData
|
|
jest.spyOn(exportService, 'getInventoryData').mockResolvedValue([
|
|
{
|
|
product_code: 'TEST001',
|
|
description: 'Test Product',
|
|
current_level: 100,
|
|
stock_status: 'Normal'
|
|
}
|
|
]);
|
|
|
|
// Mock createNewExcelFile
|
|
jest.spyOn(exportService, 'createNewExcelFile').mockResolvedValue({
|
|
workbook: XLSX.utils.book_new(),
|
|
metadata: { recordCount: 1 }
|
|
});
|
|
|
|
// Mock writeExcelFile
|
|
jest.spyOn(exportService, 'writeExcelFile').mockResolvedValue();
|
|
|
|
// Mock createExportSession
|
|
jest.spyOn(exportService, 'createExportSession').mockResolvedValue(123);
|
|
});
|
|
|
|
it('should export inventory successfully', async () => {
|
|
const result = await exportService.exportInventoryToExcel();
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.filename).toMatch(/inventory_export_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.xlsx/);
|
|
expect(result.recordCount).toBe(1);
|
|
expect(result.sessionId).toBe(123);
|
|
});
|
|
|
|
it('should use custom filename when provided', async () => {
|
|
const customFilename = 'custom_export.xlsx';
|
|
const result = await exportService.exportInventoryToExcel({ filename: customFilename });
|
|
|
|
expect(result.filename).toBe(customFilename);
|
|
});
|
|
|
|
it('should handle export errors gracefully', async () => {
|
|
jest.spyOn(exportService, 'getInventoryData').mockRejectedValue(new Error('Database error'));
|
|
|
|
const result = await exportService.exportInventoryToExcel();
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Database error');
|
|
expect(result.filePath).toBeNull();
|
|
});
|
|
|
|
it('should preserve original formatting when buffer provided', async () => {
|
|
const mockBuffer = Buffer.from('mock excel data');
|
|
jest.spyOn(exportService, 'updateOriginalExcelFile').mockResolvedValue({
|
|
workbook: XLSX.utils.book_new(),
|
|
metadata: { preservedFormatting: true }
|
|
});
|
|
|
|
const result = await exportService.exportInventoryToExcel({
|
|
originalFileBuffer: mockBuffer,
|
|
preserveFormatting: true
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(exportService.updateOriginalExcelFile).toHaveBeenCalledWith(
|
|
mockBuffer,
|
|
expect.any(Array),
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('generateExportFilename', () => {
|
|
it('should generate filename with timestamp', () => {
|
|
const filename = exportService.generateExportFilename();
|
|
|
|
expect(filename).toMatch(/inventory_export_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.xlsx/);
|
|
});
|
|
|
|
it('should use specified format', () => {
|
|
const filename = exportService.generateExportFilename('csv');
|
|
|
|
expect(filename.endsWith('.csv')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('createExportSession', () => {
|
|
beforeEach(() => {
|
|
const mockStmt = {
|
|
run: jest.fn().mockReturnValue({ lastInsertRowid: 456 })
|
|
};
|
|
mockDb.prepare.mockReturnValue(mockStmt);
|
|
});
|
|
|
|
it('should create export session record', async () => {
|
|
const sessionData = {
|
|
filename: 'test_export.xlsx',
|
|
totalRecords: 100,
|
|
filters: { category: 'Electronics' },
|
|
includeHistory: true
|
|
};
|
|
|
|
const sessionId = await exportService.createExportSession(sessionData);
|
|
|
|
expect(sessionId).toBe(456);
|
|
expect(mockDb.exec).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE IF NOT EXISTS export_sessions'));
|
|
});
|
|
|
|
it('should handle database errors gracefully', async () => {
|
|
mockDb.prepare.mockImplementation(() => {
|
|
throw new Error('Database error');
|
|
});
|
|
|
|
const sessionId = await exportService.createExportSession({});
|
|
|
|
expect(sessionId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getExportHistory', () => {
|
|
beforeEach(() => {
|
|
const mockStmt = {
|
|
all: jest.fn().mockReturnValue([
|
|
{
|
|
id: 1,
|
|
filename: 'export1.xlsx',
|
|
total_records: 100,
|
|
export_date: '2024-01-15T10:30:00Z'
|
|
}
|
|
])
|
|
};
|
|
mockDb.prepare.mockReturnValue(mockStmt);
|
|
});
|
|
|
|
it('should retrieve export history', async () => {
|
|
const history = await exportService.getExportHistory();
|
|
|
|
expect(history).toHaveLength(1);
|
|
expect(history[0].filename).toBe('export1.xlsx');
|
|
});
|
|
|
|
it('should apply limit and offset', async () => {
|
|
await exportService.getExportHistory({ limit: 10, offset: 5 });
|
|
|
|
const mockStmt = mockDb.prepare.mock.results[0].value;
|
|
expect(mockStmt.all).toHaveBeenCalledWith(10, 5);
|
|
});
|
|
|
|
it('should handle database errors', async () => {
|
|
mockDb.prepare.mockImplementation(() => {
|
|
throw new Error('Database error');
|
|
});
|
|
|
|
const history = await exportService.getExportHistory();
|
|
|
|
expect(history).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('cleanupOldExports', () => {
|
|
beforeEach(() => {
|
|
const oldDate = new Date(Date.now() - 48 * 60 * 60 * 1000); // 48 hours ago
|
|
const newDate = new Date(Date.now() - 12 * 60 * 60 * 1000); // 12 hours ago
|
|
|
|
fs.readdir.mockResolvedValue(['old_export.xlsx', 'new_export.xlsx']);
|
|
fs.stat
|
|
.mockResolvedValueOnce({ mtime: oldDate })
|
|
.mockResolvedValueOnce({ mtime: newDate });
|
|
fs.unlink.mockResolvedValue();
|
|
});
|
|
|
|
it('should delete old export files', async () => {
|
|
const result = await exportService.cleanupOldExports(24);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.deletedCount).toBe(1);
|
|
expect(fs.unlink).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should handle cleanup errors', async () => {
|
|
fs.readdir.mockRejectedValue(new Error('Directory error'));
|
|
|
|
const result = await exportService.cleanupOldExports();
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Directory error');
|
|
});
|
|
});
|
|
|
|
describe('writeExcelFile', () => {
|
|
it('should write Excel file to disk', async () => {
|
|
const mockWorkbook = XLSX.utils.book_new();
|
|
// Add a worksheet to make the workbook valid
|
|
const mockWorksheet = XLSX.utils.aoa_to_sheet([['Test']]);
|
|
XLSX.utils.book_append_sheet(mockWorkbook, mockWorksheet, 'Sheet1');
|
|
|
|
const filePath = '/test/path/export.xlsx';
|
|
|
|
await exportService.writeExcelFile(mockWorkbook, filePath, 'xlsx');
|
|
|
|
expect(fs.writeFile).toHaveBeenCalledWith(filePath, expect.any(Buffer));
|
|
});
|
|
|
|
it('should handle write errors', async () => {
|
|
fs.writeFile.mockRejectedValue(new Error('Write error'));
|
|
const mockWorkbook = XLSX.utils.book_new();
|
|
// Add a worksheet to make the workbook valid
|
|
const mockWorksheet = XLSX.utils.aoa_to_sheet([['Test']]);
|
|
XLSX.utils.book_append_sheet(mockWorkbook, mockWorksheet, 'Sheet1');
|
|
|
|
await expect(
|
|
exportService.writeExcelFile(mockWorkbook, '/test/path', 'xlsx')
|
|
).rejects.toThrow('Failed to write Excel file: Write error');
|
|
});
|
|
});
|
|
|
|
describe('getCellValue', () => {
|
|
it('should return cell value as string', () => {
|
|
const row = ['A', 'B', 'C'];
|
|
const value = exportService.getCellValue(row, 1);
|
|
|
|
expect(value).toBe('B');
|
|
});
|
|
|
|
it('should handle null column index', () => {
|
|
const row = ['A', 'B', 'C'];
|
|
const value = exportService.getCellValue(row, null);
|
|
|
|
expect(value).toBe('');
|
|
});
|
|
|
|
it('should handle out of bounds index', () => {
|
|
const row = ['A', 'B', 'C'];
|
|
const value = exportService.getCellValue(row, 5);
|
|
|
|
expect(value).toBe('');
|
|
});
|
|
|
|
it('should handle null/undefined values', () => {
|
|
const row = ['A', null, undefined, 'D'];
|
|
|
|
expect(exportService.getCellValue(row, 1)).toBe('');
|
|
expect(exportService.getCellValue(row, 2)).toBe('');
|
|
expect(exportService.getCellValue(row, 3)).toBe('D');
|
|
});
|
|
|
|
it('should trim whitespace', () => {
|
|
const row = [' A ', ' B '];
|
|
|
|
expect(exportService.getCellValue(row, 0)).toBe('A');
|
|
expect(exportService.getCellValue(row, 1)).toBe('B');
|
|
});
|
|
});
|
|
}); |