diff --git a/backend/config/contentDisposition.js b/backend/config/contentDisposition.js new file mode 100644 index 0000000..a1c59f8 --- /dev/null +++ b/backend/config/contentDisposition.js @@ -0,0 +1,16 @@ +export function toSafeFileName(fileName) { + const s = String(fileName ?? '') + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001F\u007F]/g, '') + .replace(/[<>:"/\\|?*]/g, '_') + .replace(/\s+/g, ' ') + .trim(); + + return s; +} + +export function contentDisposition(filename) { + const fallback = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, "'"); + const encoded = encodeURIComponent(filename); + return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`; +} diff --git a/backend/middleware/verifyEditable.js b/backend/middleware/verifyEditable.js index 7be9aa2..cebd3eb 100644 --- a/backend/middleware/verifyEditable.js +++ b/backend/middleware/verifyEditable.js @@ -5,6 +5,7 @@ import defineProject from '../models/projects.js'; import defineFolder from '../models/folders.js'; import defineCase from '../models/cases.js'; import defineRun from '../models/runs.js'; +import defineRunCase from '../models/runCases.js'; export default function verifyEditableMiddleware(sequelize) { /** @@ -243,6 +244,55 @@ export default function verifyEditableMiddleware(sequelize) { return res.status(403).json({ error: 'Forbidden' }); } + /** + * Verify user is reporter of the project by CommentableId + * (have to be called after verifySignedIn() middleware) + */ + async function verifyProjectReporterFromCommentableId(req, res, next) { + const commentableType = req.params.commentableType || req.query.commentableType; + const commentableId = req.params.commentableId || req.query.commentableId; + if (!commentableType || !commentableId) { + return res.status(400).json({ error: 'commentableType and commentableId are required' }); + } + + if (commentableType === 'Run') { + // not implemented yet + next(); + return; + } else if (commentableType === 'Case') { + // not implemented yet + next(); + return; + } else if (commentableType === 'RunCase') { + const RunCase = defineRunCase(sequelize, DataTypes); + const runCaseId = req.params.commentableId || req.query.commentableId; + if (!runCaseId) { + return res.status(400).json({ error: 'runCaseId is required' }); + } + + const runCase = await RunCase.findByPk(runCaseId); + const runId = runCase && runCase.runId; + if (!runId) { + return res.status(404).send('failed to find runId'); + } + + const Run = defineRun(sequelize, DataTypes); + const run = await Run.findByPk(runId); + const projectId = run && run.projectId; + if (!projectId) { + return res.status(404).send('failed to find projectId'); + } + + const isReporterRet = await isReporter(projectId, req.userId); + if (isReporterRet) { + next(); + return; + } + } else { + return res.status(400).json({ error: 'unsupported commentableType' }); + } + } + async function isReporter(projectId, userId) { const Project = defineProject(sequelize, DataTypes); const Member = defineMember(sequelize, DataTypes); @@ -289,5 +339,6 @@ export default function verifyEditableMiddleware(sequelize) { verifyProjectDeveloperFromCaseId, verifyProjectReporterFromProjectId, verifyProjectReporterFromRunId, + verifyProjectReporterFromCommentableId, }; } diff --git a/backend/middleware/verifyVisible.js b/backend/middleware/verifyVisible.js index 640e2ce..85b6c03 100644 --- a/backend/middleware/verifyVisible.js +++ b/backend/middleware/verifyVisible.js @@ -4,6 +4,7 @@ import defineProject from '../models/projects.js'; import defineFolder from '../models/folders.js'; import defineCase from '../models/cases.js'; import defineRun from '../models/runs.js'; +import defineRunCase from '../models/runCases.js'; export default function verifyVisibleMiddleware(sequelize) { /** @@ -16,8 +17,8 @@ export default function verifyVisibleMiddleware(sequelize) { return res.status(400).json({ error: 'projectId is required' }); } - const isVisble = await isVisible(projectId, req.userId); - if (isVisble) { + const visible = await isVisible(projectId, req.userId); + if (visible) { next(); return; } @@ -44,8 +45,8 @@ export default function verifyVisibleMiddleware(sequelize) { return res.status(404).send('failed to find projectId'); } - const isVisble = await isVisible(projectId, req.userId); - if (isVisble) { + const visible = await isVisible(projectId, req.userId); + if (visible) { next(); return; } @@ -80,8 +81,8 @@ export default function verifyVisibleMiddleware(sequelize) { return res.status(404).send('failed to find projectId'); } - const isVisble = await isVisible(projectId, req.userId); - if (isVisble) { + const visible = await isVisible(projectId, req.userId); + if (visible) { next(); return; } @@ -108,8 +109,8 @@ export default function verifyVisibleMiddleware(sequelize) { return res.status(404).send('failed to find projectId'); } - const isVisble = await isVisible(projectId, req.userId); - if (isVisble) { + const visible = await isVisible(projectId, req.userId); + if (visible) { next(); return; } @@ -117,6 +118,51 @@ export default function verifyVisibleMiddleware(sequelize) { return res.status(403).json({ error: 'Forbidden' }); } + async function verifyProjectVisibleFromCommentableId(req, res, next) { + const commentableType = req.params.commentableType || req.query.commentableType; + const commentableId = req.params.commentableId || req.query.commentableId; + if (!commentableType || !commentableId) { + return res.status(400).json({ error: 'commentableType and commentableId are required' }); + } + + if (commentableType === 'Run') { + // not implemented yet + next(); + return; + } else if (commentableType === 'Case') { + // not implemented yet + next(); + return; + } else if (commentableType === 'RunCase') { + const RunCase = defineRunCase(sequelize, DataTypes); + const runCaseId = req.params.commentableId || req.query.commentableId; + if (!runCaseId) { + return res.status(400).json({ error: 'runCaseId is required' }); + } + + const runCase = await RunCase.findByPk(runCaseId); + const runId = runCase && runCase.runId; + if (!runId) { + return res.status(404).send('failed to find runId'); + } + + const Run = defineRun(sequelize, DataTypes); + const run = await Run.findByPk(runId); + const projectId = run && run.projectId; + if (!projectId) { + return res.status(404).send('failed to find projectId'); + } + + const visible = await isVisible(projectId, req.userId); + if (visible) { + next(); + return; + } + } else { + return res.status(400).json({ error: 'unsupported commentableType' }); + } + } + async function isVisible(projectId, userId) { const Project = defineProject(sequelize, DataTypes); const Member = defineMember(sequelize, DataTypes); @@ -158,5 +204,6 @@ export default function verifyVisibleMiddleware(sequelize) { verifyProjectVisibleFromFolderId, verifyProjectVisibleFromCaseId, verifyProjectVisibleFromRunId, + verifyProjectVisibleFromCommentableId, }; } diff --git a/backend/migrations/20260131000000-create-comments.js b/backend/migrations/20260131000000-create-comments.js new file mode 100644 index 0000000..5ebfbcb --- /dev/null +++ b/backend/migrations/20260131000000-create-comments.js @@ -0,0 +1,47 @@ +export async function up(queryInterface, Sequelize) { + await queryInterface.createTable('comments', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + commentableType: { + type: Sequelize.STRING, + allowNull: false, + }, + commentableId: { + type: Sequelize.INTEGER, + allowNull: false, + }, + userId: { + type: Sequelize.INTEGER, + references: { + model: 'users', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL', + }, + content: { + type: Sequelize.TEXT, + allowNull: false, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + + // Add composite index for efficient polymorphic queries + await queryInterface.addIndex('comments', ['commentableType', 'commentableId'], { + name: 'comments_commentable_index', + }); +} + +export async function down(queryInterface) { + await queryInterface.dropTable('comments'); +} diff --git a/backend/models/comments.js b/backend/models/comments.js new file mode 100644 index 0000000..9ff35c6 --- /dev/null +++ b/backend/models/comments.js @@ -0,0 +1,47 @@ +function defineComment(sequelize, DataTypes) { + const Comment = sequelize.define('Comment', { + commentableType: { + type: DataTypes.STRING, + allowNull: false, + }, + commentableId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: true, + }, + content: { + type: DataTypes.TEXT, + allowNull: false, + }, + }); + + Comment.associate = (models) => { + // Polymorphic associations + Comment.belongsTo(models.RunCase, { + foreignKey: 'commentableId', + constraints: false, + as: 'runCase', + }); + Comment.belongsTo(models.Run, { + foreignKey: 'commentableId', + constraints: false, + as: 'run', + }); + Comment.belongsTo(models.Case, { + foreignKey: 'commentableId', + constraints: false, + as: 'case', + }); + Comment.belongsTo(models.User, { + foreignKey: 'userId', + onDelete: 'SET NULL', + }); + }; + + return Comment; +} + +export default defineComment; diff --git a/backend/models/runCases.js b/backend/models/runCases.js index 668ba00..700a4bf 100644 --- a/backend/models/runCases.js +++ b/backend/models/runCases.js @@ -23,6 +23,10 @@ function defineRunCase(sequelize, DataTypes) { foreignKey: 'caseId', onDelete: 'CASCADE', }); + RunCase.hasMany(models.Comment, { + foreignKey: 'commentableId', + onDelete: 'CASCADE', + }); }; return RunCase; diff --git a/backend/routes/cases/download.js b/backend/routes/cases/download.js index 3714968..53f6c6a 100644 --- a/backend/routes/cases/download.js +++ b/backend/routes/cases/download.js @@ -7,6 +7,7 @@ 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 { contentDisposition, toSafeFileName } from '../../config/contentDisposition.js'; import { testRunStatus, priorities, testTypes, automationStatus, templates } from '../../config/enums.js'; export default function (sequelize) { @@ -31,6 +32,16 @@ export default function (sequelize) { } try { + const folder = await Folder.findByPk(folderId); + if (!folder) { + return res.status(404).send('Folder not found'); + } + + const folderName = toSafeFileName(folder.name); + const filename = `${folderName}.${type}`; + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.setHeader('Content-Disposition', contentDisposition(filename)); + const cases = await Case.findAll({ attributes: { exclude: ['createdAt', 'updatedAt', 'caseSteps'] }, include: [ @@ -74,7 +85,6 @@ export default function (sequelize) { }); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename=cases_folder_${folderId}.csv`); return res.send(csv); } diff --git a/backend/routes/cases/download.test.js b/backend/routes/cases/download.test.js index e8a1b56..06f638e 100644 --- a/backend/routes/cases/download.test.js +++ b/backend/routes/cases/download.test.js @@ -34,7 +34,13 @@ vi.mock('../../middleware/verifyVisible.js', () => ({ }), })); -// mock defineCase +const mockFolder = { + findByPk: vi.fn(), +}; +vi.mock('../../models/folders.js', () => ({ + default: () => mockFolder, +})); + const mockCase = { findAll: vi.fn(), belongsToMany: vi.fn(), @@ -66,6 +72,8 @@ describe('GET /download with type=csv', () => { }); it('should return CSV with human-readable labels for state, priority, type, automationStatus, and template', async () => { + mockFolder.findByPk.mockResolvedValue({ id: 1, name: 'Test Folder' }); + mockCase.findAll.mockResolvedValue([ { id: 1, @@ -112,7 +120,7 @@ describe('GET /download with type=csv', () => { expect(response.status).toBe(200); expect(response.headers['content-type']).toBe('text/csv; charset=utf-8'); - expect(response.headers['content-disposition']).toBe('attachment; filename=cases_folder_1.csv'); + expect(response.headers['content-disposition']).toContain('attachment; filename="Test Folder.csv"'); const csvContent = response.text; diff --git a/backend/routes/cases/indexByProjectId.js b/backend/routes/cases/indexByProjectId.js index 216c027..c2c0f2b 100644 --- a/backend/routes/cases/indexByProjectId.js +++ b/backend/routes/cases/indexByProjectId.js @@ -109,7 +109,19 @@ export default function (sequelize) { }, { model: RunCase, - attributes: ['id', 'runId', 'status'], + attributes: [ + 'id', + 'runId', + 'status', + [ + sequelize.literal( + '(SELECT COUNT(*) FROM `comments` WHERE `comments`.`commentableType` = ' + + sequelize.escape('RunCase') + + ' AND `comments`.`commentableId` = `RunCases`.`id`)' + ), + 'commentCount', + ], + ], // Must be 'true' when filtering by status, otherwise all cases are returned. required: runCaseRequired, where: { diff --git a/backend/routes/cases/show.js b/backend/routes/cases/show.js index ac25f60..332ee5f 100644 --- a/backend/routes/cases/show.js +++ b/backend/routes/cases/show.js @@ -7,6 +7,7 @@ import defineTag from '../../models/tags.js'; import defineAttachment from '../../models/attachments.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; +import defineRunCase from '../../models/runCases.js'; export default function (sequelize) { const Case = defineCase(sequelize, DataTypes); @@ -19,6 +20,10 @@ export default function (sequelize) { Attachment.belongsToMany(Case, { through: 'caseAttachments' }); Case.belongsToMany(Tags, { through: 'caseTags', foreignKey: 'caseId', otherKey: 'tagId' }); Tags.belongsToMany(Case, { through: 'caseTags', foreignKey: 'tagId', otherKey: 'caseId' }); + const RunCase = defineRunCase(sequelize, DataTypes); + RunCase.belongsTo(Case, { foreignKey: 'caseId' }); + Case.hasMany(RunCase, { foreignKey: 'caseId' }); + const { verifySignedIn } = authMiddleware(sequelize); const { verifyProjectVisibleFromCaseId } = visibilityMiddleware(sequelize); @@ -44,6 +49,9 @@ export default function (sequelize) { attributes: ['id', 'name'], through: { attributes: [] }, }, + { + model: RunCase, + }, ], }); return res.json(testcase); diff --git a/backend/routes/comments/delete.js b/backend/routes/comments/delete.js new file mode 100644 index 0000000..75f7b39 --- /dev/null +++ b/backend/routes/comments/delete.js @@ -0,0 +1,38 @@ +import express from 'express'; +const router = express.Router(); +import { DataTypes } from 'sequelize'; +import defineComment from '../../models/comments.js'; +import authMiddleware from '../../middleware/auth.js'; + +export default function (sequelize) { + const { verifySignedIn } = authMiddleware(sequelize); + const Comment = defineComment(sequelize, DataTypes); + + router.delete('/:commentId', verifySignedIn, async (req, res) => { + const commentId = req.params.commentId; + + if (!commentId) { + return res.status(400).json({ error: 'commentId is required' }); + } + + try { + const comment = await Comment.findByPk(commentId); + if (!comment) { + return res.status(404).json({ error: 'Comment not found' }); + } + + // Verify the user owns the comment + if (comment.userId !== req.userId) { + return res.status(403).json({ error: 'Unauthorized' }); + } + + await comment.destroy(); + res.json({ success: true }); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/routes/comments/edit.js b/backend/routes/comments/edit.js new file mode 100644 index 0000000..ef616ff --- /dev/null +++ b/backend/routes/comments/edit.js @@ -0,0 +1,53 @@ +import express from 'express'; +const router = express.Router(); +import { DataTypes } from 'sequelize'; +import defineComment from '../../models/comments.js'; +import defineUser from '../../models/users.js'; +import authMiddleware from '../../middleware/auth.js'; + +export default function (sequelize) { + const { verifySignedIn } = authMiddleware(sequelize); + const Comment = defineComment(sequelize, DataTypes); + const User = defineUser(sequelize, DataTypes); + Comment.belongsTo(User, { foreignKey: 'userId' }); + + router.put('/:commentId', verifySignedIn, async (req, res) => { + const commentId = req.params.commentId; + const { content } = req.body; + + if (!commentId || !content) { + return res.status(400).json({ error: 'id and content are required' }); + } + + try { + const comment = await Comment.findByPk(commentId); + if (!comment) { + return res.status(404).json({ error: 'Comment not found' }); + } + + // Verify the user owns the comment + if (comment.userId !== req.userId) { + return res.status(403).json({ error: 'Unauthorized' }); + } + + await comment.update({ content }); + + // Fetch the comment with user data + const commentWithUser = await Comment.findByPk(commentId, { + include: [ + { + model: sequelize.models.User, + attributes: ['id', 'username', 'email'], + }, + ], + }); + + res.json(commentWithUser); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/routes/comments/index.js b/backend/routes/comments/index.js new file mode 100644 index 0000000..9feec76 --- /dev/null +++ b/backend/routes/comments/index.js @@ -0,0 +1,46 @@ +import express from 'express'; +const router = express.Router(); +import { DataTypes } from 'sequelize'; +import defineComment from '../../models/comments.js'; +import defineUser from '../../models/users.js'; +import authMiddleware from '../../middleware/auth.js'; +import visibilityMiddleware from '../../middleware/verifyVisible.js'; + +export default function (sequelize) { + const { verifySignedIn } = authMiddleware(sequelize); + const { verifyProjectVisibleFromCommentableId } = visibilityMiddleware(sequelize); + const Comment = defineComment(sequelize, DataTypes); + const User = defineUser(sequelize, DataTypes); + Comment.belongsTo(User, { foreignKey: 'userId', onDelete: 'CASCADE' }); + User.hasMany(Comment, { foreignKey: 'userId', onDelete: 'CASCADE' }); + + router.get('/', verifySignedIn, verifyProjectVisibleFromCommentableId, async (req, res) => { + const { commentableType, commentableId } = req.query; + + if (!commentableType || !commentableId) { + return res.status(400).json({ error: 'commentableType and commentableId are required' }); + } + + try { + const comments = await Comment.findAll({ + where: { + commentableType: commentableType, + commentableId: commentableId, + }, + include: [ + { + model: User, + attributes: ['id', 'username', 'email'], + }, + ], + order: [['createdAt', 'ASC']], + }); + res.json(comments); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/routes/comments/new.js b/backend/routes/comments/new.js new file mode 100644 index 0000000..3c6c37b --- /dev/null +++ b/backend/routes/comments/new.js @@ -0,0 +1,50 @@ +import express from 'express'; +const router = express.Router(); +import { DataTypes } from 'sequelize'; +import defineComment from '../../models/comments.js'; +import defineUser from '../../models/users.js'; +import authMiddleware from '../../middleware/auth.js'; +import editableMiddleware from '../../middleware/verifyEditable.js'; + +export default function (sequelize) { + const { verifySignedIn } = authMiddleware(sequelize); + const { verifyProjectReporterFromCommentableId } = editableMiddleware(sequelize); + const Comment = defineComment(sequelize, DataTypes); + const User = defineUser(sequelize, DataTypes); + Comment.belongsTo(User, { foreignKey: 'userId' }); + + router.post('/', verifySignedIn, verifyProjectReporterFromCommentableId, async (req, res) => { + const { commentableType, commentableId } = req.query; + const { content } = req.body; + + if (!commentableType || !commentableId || !content) { + return res.status(400).json({ error: 'commentableType, commentableId, and content are required' }); + } + + try { + const newComment = await Comment.create({ + commentableType: commentableType, + commentableId: commentableId, + userId: req.userId, + content: content, + }); + + // Fetch the comment with user data + const commentWithUser = await Comment.findByPk(newComment.id, { + include: [ + { + model: sequelize.models.User, + attributes: ['id', 'username', 'email'], + }, + ], + }); + + res.json(commentWithUser); + } catch (error) { + console.error(error); + res.status(500).send('Internal Server Error'); + } + }); + + return router; +} diff --git a/backend/routes/runs/download.js b/backend/routes/runs/download.js index 15a6b58..d2d25b0 100644 --- a/backend/routes/runs/download.js +++ b/backend/routes/runs/download.js @@ -7,8 +7,10 @@ import defineRun from '../../models/runs.js'; import defineRunCase from '../../models/runCases.js'; import defineCase from '../../models/cases.js'; import defineFolder from '../../models/folders.js'; +import defineTag from '../../models/tags.js'; import authMiddleware from '../../middleware/auth.js'; import visibilityMiddleware from '../../middleware/verifyVisible.js'; +import { contentDisposition, toSafeFileName } from '../../config/contentDisposition.js'; import { testRunCaseStatus, testRunStatus, priorities, testTypes, automationStatus } from '../../config/enums.js'; export default function (sequelize) { @@ -19,8 +21,11 @@ export default function (sequelize) { const RunCase = defineRunCase(sequelize, DataTypes); const Case = defineCase(sequelize, DataTypes); const Folder = defineFolder(sequelize, DataTypes); + const Tags = defineTag(sequelize, DataTypes); RunCase.belongsTo(Case, { foreignKey: 'caseId' }); + Case.belongsToMany(Tags, { through: 'caseTags', foreignKey: 'caseId', otherKey: 'tagId' }); + Tags.belongsToMany(Case, { through: 'caseTags', foreignKey: 'tagId', otherKey: 'caseId' }); router.get('/download/:runId', verifySignedIn, verifyProjectVisibleFromRunId, async (req, res) => { const { runId } = req.params; @@ -36,9 +41,25 @@ export default function (sequelize) { return res.status(404).send('Run not found'); } + const runName = toSafeFileName(run.name); + const filename = `${runName}.${type}`; + res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition'); + res.setHeader('Content-Disposition', contentDisposition(filename)); + const runCases = await RunCase.findAll({ where: { runId }, - include: [{ model: Case }], + include: [ + { + model: Case, + include: [ + { + model: Tags, + attributes: ['id', 'name'], + through: { attributes: [] }, + }, + ], + }, + ], }); if (type === 'xml') { @@ -92,7 +113,6 @@ export default function (sequelize) { const xmlString = xml.end({ prettyPrint: true }); res.setHeader('Content-Type', 'application/xml'); - res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.xml`); return res.send(xmlString); } else if (type === 'json') { return res.json(runCases); @@ -104,6 +124,7 @@ export default function (sequelize) { priority: priorities[rc.Case.priority] || rc.Case.priority, type: testTypes[rc.Case.type] || rc.Case.type, automationStatus: automationStatus[rc.Case.automationStatus] || rc.Case.automationStatus, + tags: rc.Case.Tags && rc.Case.Tags.length > 0 ? rc.Case.Tags.map((tag) => tag.name).join(', ') : '', status: testRunCaseStatus[rc.status] || rc.status, })); @@ -113,7 +134,6 @@ export default function (sequelize) { }); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.csv`); return res.send(csv); } diff --git a/backend/routes/runs/download.test.js b/backend/routes/runs/download.test.js index 714e1c7..d701737 100644 --- a/backend/routes/runs/download.test.js +++ b/backend/routes/runs/download.test.js @@ -59,6 +59,7 @@ vi.mock('../../models/runCases.js', () => ({ // mock defineCase const mockCase = { belongsTo: vi.fn(), + belongsToMany: vi.fn(), }; vi.mock('../../models/cases.js', () => ({ default: () => mockCase, @@ -72,6 +73,14 @@ vi.mock('../../models/folders.js', () => ({ default: () => mockFolder, })); +// mock defineTag +const mockTags = { + belongsToMany: vi.fn(), +}; +vi.mock('../../models/tags.js', () => ({ + default: () => mockTags, +})); + describe('GET /download/:runId with type=csv', () => { let app; const sequelize = new Sequelize({ @@ -105,6 +114,10 @@ describe('GET /download/:runId with type=csv', () => { priority: 0, // critical type: 4, // functional automationStatus: 0, // automated + Tags: [ + { id: 1, name: 'tag1' }, + { id: 2, name: 'tag2' }, + ], }, }, { @@ -119,6 +132,7 @@ describe('GET /download/:runId with type=csv', () => { priority: 1, // high type: 1, // security automationStatus: 1, // automation-not-required + Tags: [], }, }, { @@ -133,6 +147,7 @@ describe('GET /download/:runId with type=csv', () => { priority: 2, // medium type: 2, // performance automationStatus: 2, // cannot-be-automated + Tags: [{ id: 3, name: 'tag3' }], }, }, ]); @@ -141,7 +156,7 @@ describe('GET /download/:runId with type=csv', () => { expect(response.status).toBe(200); expect(response.headers['content-type']).toBe('text/csv; charset=utf-8'); - expect(response.headers['content-disposition']).toBe('attachment; filename=run_1.csv'); + expect(response.headers['content-disposition']).toContain('attachment; filename="Test Run.csv"'); const csvContent = response.text; @@ -162,6 +177,11 @@ describe('GET /download/:runId with type=csv', () => { expect(csvContent).toContain('inProgress'); expect(csvContent).toContain('underReview'); + // Check that tags are included in the CSV + expect(csvContent).toContain('tags'); + expect(csvContent).toContain('tag1, tag2'); + expect(csvContent).toContain('tag3'); + // Ensure numeric values are not present (except for id which should be numeric) const lines = csvContent.split('\n'); const dataLines = lines.slice(1).filter((line) => line.trim()); // Skip header diff --git a/backend/server.js b/backend/server.js index 52bc492..54d96bb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -175,6 +175,16 @@ app.use('/tags', tagsEditRoute(sequelize)); import caseTagsEditRoute from './routes/casetags/edit.js'; app.use('/casetags', caseTagsEditRoute(sequelize)); +// "/comments" +import commentsIndexRoute from './routes/comments/index.js'; +import commentsNewRoute from './routes/comments/new.js'; +import commentsEditRoute from './routes/comments/edit.js'; +import commentsDeleteRoute from './routes/comments/delete.js'; +app.use('/comments', commentsIndexRoute(sequelize)); +app.use('/comments', commentsNewRoute(sequelize)); +app.use('/comments', commentsEditRoute(sequelize)); +app.use('/comments', commentsDeleteRoute(sequelize)); + // "/home" import homeIndexRoute from './routes/home/index.js'; app.use('/home', homeIndexRoute(sequelize)); diff --git a/frontend/components/CommentItem.tsx b/frontend/components/CommentItem.tsx new file mode 100644 index 0000000..95e8b2d --- /dev/null +++ b/frontend/components/CommentItem.tsx @@ -0,0 +1,95 @@ +import { Button, Textarea, Card, CardBody } from '@heroui/react'; +import { Trash2, Edit2 } from 'lucide-react'; +import UserAvatar from './UserAvatar'; +import { CommentMessages, CommentType } from '@/types/comment'; + +type Props = { + comment: CommentType; + isEditing: boolean; + canEdit: boolean; + editContent: string; + isSubmitting: boolean; + messages: CommentMessages; + onEditContentChange: (value: string) => void; + onStartEdit: () => void; + onCancelEdit: () => void; + onSave: () => void; + onDelete: () => void; +}; + +export default function CommentItem({ + comment, + isEditing, + canEdit, + editContent, + isSubmitting, + messages, + onEditContentChange, + onStartEdit, + onCancelEdit, + onSave, + onDelete, +}: Props) { + return ( + + +
+ +
+
+
+ {comment.User.username} + {new Date(comment.createdAt).toLocaleString()} +
+ {canEdit && ( +
+ + +
+ )} +
+ {isEditing ? ( +
+