From 9ba1f3f1f8e9286ee92e6e3e55be755ca75796ce Mon Sep 17 00:00:00 2001 From: Matheus Santana <83383321+matheusfsantana@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:48:36 -0300 Subject: [PATCH] feat: add routes to clone test folders and test cases (#301) --- backend/routes/cases/clone.js | 74 ++++++++++++++++++++++ backend/routes/folders/clone.js | 106 ++++++++++++++++++++++++++++++++ backend/server.js | 4 ++ 3 files changed, 184 insertions(+) create mode 100644 backend/routes/cases/clone.js create mode 100644 backend/routes/folders/clone.js diff --git a/backend/routes/cases/clone.js b/backend/routes/cases/clone.js new file mode 100644 index 0000000..1dbc890 --- /dev/null +++ b/backend/routes/cases/clone.js @@ -0,0 +1,74 @@ +import express from 'express'; +const router = express.Router(); +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'; + +export default function (sequelize) { + const { verifySignedIn } = authMiddleware(sequelize); + const { verifyProjectDeveloperFromProjectId } = editableMiddleware(sequelize); + const Case = defineCase(sequelize, DataTypes); + const Step = defineStep(sequelize, DataTypes); + const CaseStep = defineCaseStep(sequelize, DataTypes); + Case.belongsToMany(Step, { through: 'caseSteps' }); + Step.belongsToMany(Case, { through: 'caseSteps' }); + + // TODO: Implement a safer middleware to check permissions based on the actual caseId (in this case, multiples case ids) + router.post('/clone', verifySignedIn, verifyProjectDeveloperFromProjectId, async (req, res) => { + const { caseIds, targetFolderId } = req.body; + + if (!Array.isArray(caseIds) || caseIds.length === 0 || !targetFolderId) { + return res.status(400).json({ error: 'caseIds(array) and targetFolderId are required' }); + } + + try { + const caseRecords = await Case.findAll({ + where: { id: caseIds }, + include: [{ model: Step, through: { attributes: ['stepNo'] } }], + }); + + if (caseRecords.length !== caseIds.length) { + return res.status(404).json({ error: 'Some cases not found' }); + } + + const cases = caseRecords.map((c) => c.get({ plain: true })); + + const clonedCases = cases.map((c) => { + const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...clonedCase } = c; + return { ...clonedCase, folderId: targetFolderId }; + }); + + await sequelize.transaction(async (t) => { + for (const c of clonedCases) { + const newCase = await Case.create(c, { transaction: t }); + + if (c.Steps) { + const clonedSteps = c.Steps.map((s) => { + const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...clonedStep } = s; + return clonedStep; + }); + + const newStep = await Step.bulkCreate(clonedSteps, { transaction: t }); + const newCaseSteps = newStep.map((step, index) => ({ + caseId: newCase.id, + stepId: step.id, + stepNo: clonedSteps[index].caseSteps.stepNo, + })); + + await CaseStep.bulkCreate(newCaseSteps, { transaction: t }); + } + } + }); + + res.status(200).json({ message: 'Cases cloned successfully' }); + } catch (error) { + console.error('Error cloning cases:', error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/routes/folders/clone.js b/backend/routes/folders/clone.js new file mode 100644 index 0000000..b9c4e38 --- /dev/null +++ b/backend/routes/folders/clone.js @@ -0,0 +1,106 @@ +import express from 'express'; +const router = express.Router(); +import { DataTypes } from 'sequelize'; +import defineFolder from '../../models/folders.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 editableMiddleware from '../../middleware/verifyEditable.js'; + +export default function (sequelize) { + const { verifySignedIn } = authMiddleware(sequelize); + const { verifyProjectDeveloperFromFolderId } = editableMiddleware(sequelize); + + const Folder = defineFolder(sequelize, DataTypes); + const Case = defineCase(sequelize, DataTypes); + const Step = defineStep(sequelize, DataTypes); + const CaseStep = defineCaseStep(sequelize, DataTypes); + Case.belongsTo(Folder); + Case.belongsToMany(Step, { through: 'caseSteps' }); + Step.belongsToMany(Case, { through: 'caseSteps' }); + + async function _cloneFolderRecursive(sourceFolder, targetParent, transaction) { + const folderToCreate = { + name: sourceFolder.name, + detail: sourceFolder.detail, + parentFolderId: targetParent.id, + projectId: targetParent.projectId, + }; + + const clonedFolder = await Folder.create(folderToCreate, { transaction }); + + await _cloneCasesAndSteps(sourceFolder.id, clonedFolder.id, transaction); + + const childFolders = await Folder.findAll({ + where: { parentFolderId: sourceFolder.id }, + }); + + for (const child of childFolders) { + await _cloneFolderRecursive(child, clonedFolder, transaction); + } + + return clonedFolder; + } + + async function _cloneCasesAndSteps(folderId, targetFolderId, transaction) { + const folderCases = await Case.findAll({ + where: { folderId }, + include: [{ model: Step, through: { attributes: ['stepNo'] } }], + }); + + if (folderCases.length === 0) return; + + const cases = folderCases.map((c) => c.get({ plain: true })); + + const clonedCases = cases.map((c) => { + const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...clonedCase } = c; + return { ...clonedCase, folderId: targetFolderId }; + }); + + for (const c of clonedCases) { + const newCase = await Case.create(c, { transaction }); + + if (c.Steps && c.Steps.length > 0) { + const clonedSteps = c.Steps.map((s) => { + const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...clonedStep } = s; + return clonedStep; + }); + + const newSteps = await Step.bulkCreate(clonedSteps, { transaction }); + const caseSteps = newSteps.map((step, index) => ({ + caseId: newCase.id, + stepId: step.id, + stepNo: clonedSteps[index].caseSteps.stepNo, + })); + + await CaseStep.bulkCreate(caseSteps, { transaction }); + } + } + } + + router.post('/:folderId/clone', verifySignedIn, verifyProjectDeveloperFromFolderId, async (req, res) => { + const folderId = req.params.folderId; + const { targetFolderId } = req.body; + + try { + const sourceFolder = await Folder.findByPk(folderId); + const targetFolder = await Folder.findByPk(targetFolderId); + + if (!sourceFolder || !targetFolder) { + return res.status(404).send('Folder or target folder not found'); + } + + await sequelize.transaction(async (t) => { + await _cloneFolderRecursive(sourceFolder, targetFolder, t); + }); + + res.status(201).send({ message: 'Folder cloned successfully' }); + } catch (err) { + console.error(err); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/server.js b/backend/server.js index da1c355..6c83507 100644 --- a/backend/server.js +++ b/backend/server.js @@ -77,10 +77,12 @@ import foldersIndexRoute from './routes/folders/index.js'; import foldersNewRoute from './routes/folders/new.js'; import foldersEditRoute from './routes/folders/edit.js'; import foldersDeleteRoute from './routes/folders/delete.js'; +import foldersCloneRoute from './routes/folders/clone.js'; app.use('/folders', foldersIndexRoute(sequelize)); app.use('/folders', foldersNewRoute(sequelize)); app.use('/folders', foldersEditRoute(sequelize)); app.use('/folders', foldersDeleteRoute(sequelize)); +app.use('/folders', foldersCloneRoute(sequelize)); // "/cases" import casesDownloadRoute from './routes/cases/download.js'; @@ -91,6 +93,7 @@ import casesShowRoute from './routes/cases/show.js'; import casesNewRoute from './routes/cases/new.js'; import casesEditRoute from './routes/cases/edit.js'; import casesDeleteRoute from './routes/cases/delete.js'; +import casesCloneRoute from './routes/cases/clone.js'; app.use('/cases', casesDownloadRoute(sequelize)); app.use('/cases', casesMoveRoute(sequelize)); app.use('/cases', casesIndexRoute(sequelize)); @@ -99,6 +102,7 @@ app.use('/cases', casesShowRoute(sequelize)); app.use('/cases', casesNewRoute(sequelize)); app.use('/cases', casesEditRoute(sequelize)); app.use('/cases', casesDeleteRoute(sequelize)); +app.use('/cases', casesCloneRoute(sequelize)); // "/steps" import stepsEditRoute from './routes/steps/edit.js';