import path from 'path'; import express from 'express'; const router = express.Router(); import multer from 'multer'; import XLSX from 'xlsx'; import { DataTypes } from 'sequelize'; import defineCase from '../../models/cases.js'; import defineStep from '../../models/steps.js'; import defineCaseStep from '../../models/caseSteps.js'; import authMiddleware from '../../middleware/auth.js'; import editableMiddleware from '../../middleware/verifyEditable.js'; import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js'; const fileFilter = (req, file, cb) => { // Accept Excel and JSON by extension. JSON mime types vary across browsers // (application/json, text/plain, application/octet-stream), so rely on the extension. const allowedFileTypes = ['.xlsx', '.xls', '.json']; const extname = allowedFileTypes.includes(path.extname(file.originalname).toLowerCase()); if (extname) { return cb(null, true); } else { cb(new Error('Only Excel (.xlsx, .xls) or JSON (.json) files are allowed!')); } }; const storage = multer.memoryStorage(); const upload = multer({ storage: storage, fileFilter, limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB limit }); export default function (sequelize) { const Case = defineCase(sequelize, DataTypes); const Step = defineStep(sequelize, DataTypes); const CaseStep = defineCaseStep(sequelize, DataTypes); Case.belongsToMany(Step, { through: CaseStep }); Step.belongsToMany(Case, { through: CaseStep }); const { verifySignedIn } = authMiddleware(sequelize); const { verifyProjectDeveloperFromFolderId } = editableMiddleware(sequelize); router.post( '/import', (req, res, next) => { upload.single('file')(req, res, function (err) { if (err) { return res.status(400).json({ error: err.message }); } next(); }); }, verifySignedIn, verifyProjectDeveloperFromFolderId, async (req, res) => { const { folderId } = req.query; if (!req.file || !req.file.buffer) { return res.status(400).json({ error: 'No file uploaded' }); } if (!folderId) { return res.status(400).json({ error: 'folderId is required' }); } // Parse and validate the file before opening a transaction. // Both parsers return the same { casesToCreate, stepsToCreate } shape, // or throw an Error with a user-facing validation message. let casesToCreate; let stepsToCreate; try { const ext = path.extname(req.file.originalname).toLowerCase(); const parsed = ext === '.json' ? _parseJsonBuffer(req.file.buffer, folderId) : _parseExcelBuffer(req.file.buffer, folderId); casesToCreate = parsed.casesToCreate; stepsToCreate = parsed.stepsToCreate; } catch (error) { return res.status(400).json({ error: error.message }); } const t = await sequelize.transaction(); try { // 'Manually' create cases, steps and caseStep association. const createdCases = await Case.bulkCreate(casesToCreate, { transaction: t }); for (const stepData of stepsToCreate) { const createdCase = createdCases[stepData.caseIndex]; const createdStep = await Step.create( { step: stepData.step, result: stepData.result, }, { transaction: t } ); await CaseStep.create( { caseId: createdCase.id, stepId: createdStep.id, stepNo: stepData.stepNo, }, { transaction: t } ); } await t.commit(); res.json(createdCases); } catch (error) { await t.rollback(); console.error(error); if (!res.headersSent) { res.status(500).json({ error: 'Internal Server Error' }); } } } ); return router; } // Parse an Excel (.xlsx, .xls) buffer into { casesToCreate, stepsToCreate }. // Rows sharing the same title are grouped into a single case with multiple steps. function _parseExcelBuffer(buffer, folderId) { const workbook = XLSX.read(buffer, { type: 'buffer' }); const sheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; const jsonData = XLSX.utils.sheet_to_json(worksheet); let currentTitle = null; let previousTitle = null; let stepNo = 1; const casesToCreate = []; const stepsToCreate = []; for (const [index, row] of jsonData.entries()) { const errorMessage = _getRowValidationError(row, index); if (errorMessage) { throw new Error(errorMessage); } // Add step to the same case if the current row title is equal to previous row title // This handle cases with multiple steps :) currentTitle = row['title'].trim(); previousTitle = casesToCreate[casesToCreate.length - 1]?.title.trim(); if (casesToCreate.length > 0 && previousTitle === currentTitle) { stepNo += 1; stepsToCreate.push({ caseIndex: casesToCreate.length - 1, stepNo: stepNo, step: row['step'] || '', result: row['expectedStepResult'] || '', }); } else { stepNo = 1; casesToCreate.push({ folderId: folderId, title: currentTitle, description: row['description'] || '', state: 0, // default state priority: row['priority'] ? priorities.indexOf(row['priority']) : priorities.indexOf('medium'), type: row['type'] ? testTypes.indexOf(row['type']) : testTypes.indexOf('other'), preConditions: row['preConditions'], expectedResults: row['expectedResults'], automationStatus: row['automationStatus'] ? automationStatus.indexOf(row['automationStatus']) : automationStatus.indexOf('automation-not-required'), template: row['template'] ? templates.indexOf(row['template']) : templates.indexOf('text'), }); stepsToCreate.push({ caseIndex: casesToCreate.length - 1, stepNo: stepNo, step: row['step'] || '', result: row['expectedStepResult'] || '', }); } } return { casesToCreate, stepsToCreate }; } // Parse a JSON buffer into { casesToCreate, stepsToCreate }. // Accepts the same shape produced by the JSON export (`/cases/download?type=json`): // either an array of case objects, or an object map keyed by id. // `id`, `folderId`, `folder` and `state` are ignored; cases are created in the // target folder with the default `new` state. function _parseJsonBuffer(buffer, folderId) { let parsed; try { parsed = JSON.parse(buffer.toString('utf-8')); } catch { throw new Error('Invalid JSON file'); } const rawCases = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object' ? Object.values(parsed) : null; if (!rawCases || rawCases.length === 0) { throw new Error('No cases found in JSON file'); } const casesToCreate = []; const stepsToCreate = []; for (const [index, c] of rawCases.entries()) { const errorMessage = _getJsonCaseValidationError(c, index); if (errorMessage) { throw new Error(errorMessage); } casesToCreate.push({ folderId: folderId, title: String(c.title).trim(), description: c.description || '', state: 0, // default state priority: priorities.indexOf(c.priority), type: testTypes.indexOf(c.type), preConditions: c.preConditions || '', expectedResults: c.expectedResults || '', automationStatus: c.automationStatus ? automationStatus.indexOf(c.automationStatus) : automationStatus.indexOf('automation-not-required'), template: templates.indexOf(c.template), }); const steps = Array.isArray(c.Steps) ? c.Steps : []; steps.forEach((s, stepIndex) => { stepsToCreate.push({ caseIndex: casesToCreate.length - 1, stepNo: stepIndex + 1, step: s?.step || '', result: s?.expectedStepResult || s?.result || '', }); }); } return { casesToCreate, stepsToCreate }; } function _getRowValidationError(row, index) { const requiredFields = ['title', 'priority', 'type', 'template']; const rowNumber = index + 2; for (const field of requiredFields) { if (!row[field]) { return `Row ${rowNumber} is missing required field: ${field}`; } } // Validate priority if provided if (row['priority']) { const priorityIndex = priorities.indexOf(row['priority']?.toLowerCase()); if (priorityIndex === -1) { return `Row ${rowNumber} has invalid priority: ${row['priority']}`; } } // Validate type if provided if (row['type']) { const typeIndex = testTypes.indexOf(row['type']?.toLowerCase()); if (typeIndex === -1) { return `Row ${rowNumber} has invalid type: ${row['type']}`; } } // Validate automationStatus if provided if (row['automationStatus']) { const automationStatusIndex = automationStatus.indexOf(row['automationStatus']?.toLowerCase()); if (automationStatusIndex === -1) { return `Row ${rowNumber} has invalid automationStatus: ${row['automationStatus']}`; } } // Validate template if provided if (row['template']) { const templateIndex = templates.indexOf(row['template']?.toLowerCase()); if (templateIndex === -1) { return `Row ${rowNumber} has invalid template: ${row['template']}`; } } return null; } function _getJsonCaseValidationError(c, index) { const caseNumber = index + 1; if (!c || typeof c !== 'object') { return `Case ${caseNumber} is not a valid object`; } const requiredFields = ['title', 'priority', 'type', 'template']; for (const field of requiredFields) { if (!c[field]) { return `Case ${caseNumber} is missing required field: ${field}`; } } // Values are human-readable labels, matching the JSON export format. if (priorities.indexOf(c.priority) === -1) { return `Case ${caseNumber} has invalid priority: ${c.priority}`; } if (testTypes.indexOf(c.type) === -1) { return `Case ${caseNumber} has invalid type: ${c.type}`; } if (templates.indexOf(c.template) === -1) { return `Case ${caseNumber} has invalid template: ${c.template}`; } if (c.automationStatus && automationStatus.indexOf(c.automationStatus) === -1) { return `Case ${caseNumber} has invalid automationStatus: ${c.automationStatus}`; } return null; }