feat: add folder on export and handle steps on import (#352)

This commit is contained in:
Matheus Santana
2025-11-29 03:00:43 -03:00
committed by GitHub
parent 92d6340ddc
commit a2fea7a127
5 changed files with 126 additions and 61 deletions

View File

@@ -4,6 +4,7 @@ import { DataTypes } from 'sequelize';
import Papa from 'papaparse'; import Papa from 'papaparse';
import defineCase from '../../models/cases.js'; import defineCase from '../../models/cases.js';
import defineStep from '../../models/steps.js'; import defineStep from '../../models/steps.js';
import defineFolder from '../../models/folders.js';
import authMiddleware from '../../middleware/auth.js'; import authMiddleware from '../../middleware/auth.js';
import visibilityMiddleware from '../../middleware/verifyVisible.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js';
import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js'; import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
@@ -11,6 +12,8 @@ import { testRunStatus, priorities, testTypes, automationStatus, templates } fro
export default function (sequelize) { export default function (sequelize) {
const Case = defineCase(sequelize, DataTypes); const Case = defineCase(sequelize, DataTypes);
const Step = defineStep(sequelize, DataTypes); const Step = defineStep(sequelize, DataTypes);
const Folder = defineFolder(sequelize, DataTypes);
Case.belongsTo(Folder);
Case.belongsToMany(Step, { through: 'caseSteps' }); Case.belongsToMany(Step, { through: 'caseSteps' });
Step.belongsToMany(Case, { through: 'caseSteps' }); Step.belongsToMany(Case, { through: 'caseSteps' });
const { verifySignedIn } = authMiddleware(sequelize); const { verifySignedIn } = authMiddleware(sequelize);
@@ -37,6 +40,10 @@ export default function (sequelize) {
order: [['stepNo', 'ASC']], order: [['stepNo', 'ASC']],
attributes: { exclude: ['createdAt', 'updatedAt'] }, attributes: { exclude: ['createdAt', 'updatedAt'] },
}, },
{
model: Folder,
attributes: ['name'],
},
], ],
where: { folderId }, where: { folderId },
raw: true, raw: true,
@@ -90,6 +97,7 @@ const _formatRawCasesToJson = (cases) => {
casesObject[c.id] = { casesObject[c.id] = {
id: c.id, id: c.id,
folderId: c.folderId, folderId: c.folderId,
folder: c['Folder.name'],
title: c.title, title: c.title,
state: c.state, state: c.state,
priority: c.priority, priority: c.priority,
@@ -119,6 +127,7 @@ const _formatRawCasesToCsv = (cases) => {
return cases.map((c) => ({ return cases.map((c) => ({
id: c.id, id: c.id,
folderId: c.folderId, folderId: c.folderId,
folder: c['Folder.name'],
title: c.title, title: c.title,
state: c.state, state: c.state,
priority: c.priority, priority: c.priority,

View File

@@ -38,6 +38,7 @@ vi.mock('../../middleware/verifyVisible.js', () => ({
const mockCase = { const mockCase = {
findAll: vi.fn(), findAll: vi.fn(),
belongsToMany: vi.fn(), belongsToMany: vi.fn(),
belongsTo: vi.fn(),
}; };
vi.mock('../../models/cases.js', () => ({ vi.mock('../../models/cases.js', () => ({
default: () => mockCase, default: () => mockCase,

View File

@@ -5,6 +5,8 @@ import multer from 'multer';
import XLSX from 'xlsx'; import XLSX from 'xlsx';
import { DataTypes } from 'sequelize'; import { DataTypes } from 'sequelize';
import defineCase from '../../models/cases.js'; 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 authMiddleware from '../../middleware/auth.js';
import editableMiddleware from '../../middleware/verifyEditable.js'; import editableMiddleware from '../../middleware/verifyEditable.js';
import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js'; import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
@@ -34,6 +36,10 @@ const upload = multer({
export default function (sequelize) { export default function (sequelize) {
const Case = defineCase(sequelize, DataTypes); 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 { verifySignedIn } = authMiddleware(sequelize);
const { verifyProjectDeveloperFromFolderId } = editableMiddleware(sequelize); const { verifyProjectDeveloperFromFolderId } = editableMiddleware(sequelize);
@@ -60,86 +66,135 @@ export default function (sequelize) {
return res.status(400).json({ error: 'folderId is required' }); return res.status(400).json({ error: 'folderId is required' });
} }
const t = await sequelize.transaction();
try { try {
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' }); const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
const sheetName = workbook.SheetNames[0]; const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName]; const worksheet = workbook.Sheets[sheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet); const jsonData = XLSX.utils.sheet_to_json(worksheet);
let errorMessage = null; let currentTitle = null;
let previousTitle = null;
let stepNo = 1;
const casesToCreate = []; const casesToCreate = [];
const requiredFields = ['title', 'priority', 'type', 'template']; const stepsToCreate = [];
for (const [index, row] of jsonData.entries()) { for (const [index, row] of jsonData.entries()) {
const rowNumber = index + 2; const errorMessage = _getRowValidationError(row, index);
for (const field of requiredFields) {
if (!row[field]) {
errorMessage = `Row ${rowNumber} is missing required field: ${field}`;
console.log(`Error found for field: ${errorMessage}`);
}
}
// Validate priority if provided
let priorityIndex = priorities.indexOf('medium'); // default to 'medium'
if (row['priority']) {
priorityIndex = priorities.indexOf(row['priority'].toLowerCase());
if (priorityIndex === -1) {
errorMessage = `Row ${rowNumber} has invalid priority: ${row['priority']}`;
}
}
// Validate type if provided
let typeIndex = testTypes.indexOf('other'); // default to 'other'
if (row['type']) {
typeIndex = testTypes.indexOf(row['type'].toLowerCase());
if (typeIndex === -1) {
errorMessage = `Row ${rowNumber} has invalid type: ${row['type']}`;
}
}
// Validate automationStatus if provided
let automationStatusIndex = automationStatus.indexOf('automation-not-required'); // default to 'automation-not-required'
if (row['automationStatus']) {
automationStatusIndex = automationStatus.indexOf(row['automationStatus'].toLowerCase());
if (automationStatusIndex === -1) {
errorMessage = `Row ${rowNumber} has invalid automationStatus: ${row['automationStatus']}`;
}
}
// Validate template if provided
let templateIndex = templates.indexOf('text'); // default to 'text'
if (row['template']) {
templateIndex = templates.indexOf(row['template'].toLowerCase());
if (templateIndex === -1) {
errorMessage = `Row ${rowNumber} has invalid template: ${row['template']}`;
}
}
if (errorMessage) { if (errorMessage) {
return res.status(400).json({ error: errorMessage }); return res.status(400).json({ 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({ casesToCreate.push({
folderId: folderId, folderId: folderId,
title: row['title'], title: currentTitle,
description: row['description'] || '', description: row['description'] || '',
state: 0, // default state state: 0, // default state
priority: priorityIndex, priority: row['priority'] ? priorities.indexOf(row['priority']) : priorities.indexOf('medium'),
type: typeIndex, type: row['type'] ? testTypes.indexOf(row['type']) : testTypes.indexOf('other'),
preConditions: row['preConditions'], preConditions: row['preConditions'],
expectedResults: row['expectedResults'], expectedResults: row['expectedResults'],
automationStatus: automationStatusIndex, automationStatus: row['automationStatus']
template: templateIndex, ? 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'] || '',
}); });
} }
}
const createdCases = await Case.bulkCreate(casesToCreate); // '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); res.json(createdCases);
} catch (error) { } catch (error) {
await t.rollback();
console.error(error); console.error(error);
res.status(500).send('Internal Server Error');
} }
} }
); );
return router; return router;
} }
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;
}

View File

@@ -70,7 +70,7 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor
<div className="flex items-center justify-center w-full"> <div className="flex items-center justify-center w-full">
<div className={`mt-1 text-neutral-500 dark:text-neutral-400 text-sm rounded`}> <div className={`mt-1 text-neutral-500 dark:text-neutral-400 text-sm rounded`}>
<div>{messages.importAvailable}</div> <div>{messages.importAvailable}</div>
<a href="/template/unittcms-import-template-v1.xlsx" download className="text-tiny underline"> <a href="/template/unittcms-import-template-v1.1.xlsx" download className="text-tiny underline">
{messages.downloadTemplate} {messages.downloadTemplate}
</a> </a>
</div> </div>