Some checks failed
UnitTCMS CI / unittcms-test (push) Has been cancelled
JSON import lets users round-trip cases exported via the JSON download endpoint back into a folder, alongside the existing Excel import path.
311 lines
10 KiB
JavaScript
311 lines
10 KiB
JavaScript
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;
|
|
}
|