diff --git a/backend/routes/cases/download.js b/backend/routes/cases/download.js index ab2071d..3714968 100644 --- a/backend/routes/cases/download.js +++ b/backend/routes/cases/download.js @@ -4,6 +4,7 @@ import { DataTypes } from 'sequelize'; import Papa from 'papaparse'; import defineCase from '../../models/cases.js'; import defineStep from '../../models/steps.js'; +import defineFolder from '../../models/folders.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.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) { const Case = defineCase(sequelize, DataTypes); const Step = defineStep(sequelize, DataTypes); + const Folder = defineFolder(sequelize, DataTypes); + Case.belongsTo(Folder); Case.belongsToMany(Step, { through: 'caseSteps' }); Step.belongsToMany(Case, { through: 'caseSteps' }); const { verifySignedIn } = authMiddleware(sequelize); @@ -37,6 +40,10 @@ export default function (sequelize) { order: [['stepNo', 'ASC']], attributes: { exclude: ['createdAt', 'updatedAt'] }, }, + { + model: Folder, + attributes: ['name'], + }, ], where: { folderId }, raw: true, @@ -90,6 +97,7 @@ const _formatRawCasesToJson = (cases) => { casesObject[c.id] = { id: c.id, folderId: c.folderId, + folder: c['Folder.name'], title: c.title, state: c.state, priority: c.priority, @@ -119,6 +127,7 @@ const _formatRawCasesToCsv = (cases) => { return cases.map((c) => ({ id: c.id, folderId: c.folderId, + folder: c['Folder.name'], title: c.title, state: c.state, priority: c.priority, diff --git a/backend/routes/cases/download.test.js b/backend/routes/cases/download.test.js index 06e60c2..e8a1b56 100644 --- a/backend/routes/cases/download.test.js +++ b/backend/routes/cases/download.test.js @@ -38,6 +38,7 @@ vi.mock('../../middleware/verifyVisible.js', () => ({ const mockCase = { findAll: vi.fn(), belongsToMany: vi.fn(), + belongsTo: vi.fn(), }; vi.mock('../../models/cases.js', () => ({ default: () => mockCase, diff --git a/backend/routes/cases/import.js b/backend/routes/cases/import.js index 9b07cfd..894b859 100644 --- a/backend/routes/cases/import.js +++ b/backend/routes/cases/import.js @@ -5,6 +5,8 @@ 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'; @@ -34,6 +36,10 @@ const upload = multer({ 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); @@ -60,86 +66,135 @@ export default function (sequelize) { return res.status(400).json({ error: 'folderId is required' }); } + const t = await sequelize.transaction(); try { const workbook = XLSX.read(req.file.buffer, { type: 'buffer' }); const sheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; const jsonData = XLSX.utils.sheet_to_json(worksheet); - let errorMessage = null; + let currentTitle = null; + let previousTitle = null; + let stepNo = 1; const casesToCreate = []; - const requiredFields = ['title', 'priority', 'type', 'template']; + const stepsToCreate = []; for (const [index, row] of jsonData.entries()) { - const rowNumber = index + 2; - 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']}`; - } - } - + const errorMessage = _getRowValidationError(row, index); if (errorMessage) { return res.status(400).json({ error: errorMessage }); } - casesToCreate.push({ - folderId: folderId, - title: row['title'], - description: row['description'] || '', - state: 0, // default state - priority: priorityIndex, - type: typeIndex, - preConditions: row['preConditions'], - expectedResults: row['expectedResults'], - automationStatus: automationStatusIndex, - template: templateIndex, - }); + // 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'] || '', + }); + } } - 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); } catch (error) { + await t.rollback(); console.error(error); - res.status(500).send('Internal Server Error'); } } ); 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; +} diff --git a/frontend/public/template/unittcms-import-template-v1.1.xlsx b/frontend/public/template/unittcms-import-template-v1.1.xlsx new file mode 100644 index 0000000..ff6c70e Binary files /dev/null and b/frontend/public/template/unittcms-import-template-v1.1.xlsx differ diff --git a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx index 3a1167e..d771130 100644 --- a/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx +++ b/frontend/src/app/[locale]/projects/[projectId]/folders/[folderId]/cases/CaseImportDialog.tsx @@ -70,7 +70,7 @@ export default function CaseImportDialog({ isOpen, folderId, isDisabled, onImpor
{messages.importAvailable}
- + {messages.downloadTemplate}