chore: export csv with human-readable labels (#325)
This commit is contained in:
36
backend/config/enums.js
Normal file
36
backend/config/enums.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Enum mappings for database numeric values to human-readable labels
|
||||||
|
// These correspond to the frontend config/selection.ts configurations
|
||||||
|
|
||||||
|
// The status of each test case in test run
|
||||||
|
const testRunCaseStatus = ['untested', 'passed', 'failed', 'retest', 'skipped'];
|
||||||
|
|
||||||
|
// The status of each test run
|
||||||
|
const testRunStatus = ['new', 'inProgress', 'underReview', 'rejected', 'done', 'closed'];
|
||||||
|
|
||||||
|
// Priority levels
|
||||||
|
const priorities = ['critical', 'high', 'medium', 'low'];
|
||||||
|
|
||||||
|
// Test types
|
||||||
|
const testTypes = [
|
||||||
|
'other',
|
||||||
|
'security',
|
||||||
|
'performance',
|
||||||
|
'accessibility',
|
||||||
|
'functional',
|
||||||
|
'acceptance',
|
||||||
|
'usability',
|
||||||
|
'smokeSanity',
|
||||||
|
'compatibility',
|
||||||
|
'destructive',
|
||||||
|
'regression',
|
||||||
|
'automated',
|
||||||
|
'manual',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Automation status
|
||||||
|
const automationStatus = ['automated', 'automation-not-required', 'cannot-be-automated', 'obsolete'];
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
const templates = ['text', 'step'];
|
||||||
|
|
||||||
|
export { testRunCaseStatus, testRunStatus, priorities, testTypes, automationStatus, templates };
|
||||||
@@ -5,6 +5,7 @@ import Papa from 'papaparse';
|
|||||||
import defineCase from '../../models/cases.js';
|
import defineCase from '../../models/cases.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';
|
||||||
|
|
||||||
export default function (sequelize) {
|
export default function (sequelize) {
|
||||||
const Case = defineCase(sequelize, DataTypes);
|
const Case = defineCase(sequelize, DataTypes);
|
||||||
@@ -35,7 +36,17 @@ export default function (sequelize) {
|
|||||||
if (type === 'json') {
|
if (type === 'json') {
|
||||||
return res.json(cases);
|
return res.json(cases);
|
||||||
} else if (type === 'csv') {
|
} else if (type === 'csv') {
|
||||||
const csv = Papa.unparse(cases, {
|
// Convert numeric values to human-readable labels
|
||||||
|
const casesWithLabels = cases.map((c) => ({
|
||||||
|
...c,
|
||||||
|
state: testRunStatus[c.state] || c.state,
|
||||||
|
priority: priorities[c.priority] || c.priority,
|
||||||
|
type: testTypes[c.type] || c.type,
|
||||||
|
automationStatus: automationStatus[c.automationStatus] || c.automationStatus,
|
||||||
|
template: templates[c.template] || c.template,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const csv = Papa.unparse(casesWithLabels, {
|
||||||
quotes: true,
|
quotes: true,
|
||||||
skipEmptyLines: true,
|
skipEmptyLines: true,
|
||||||
});
|
});
|
||||||
|
|||||||
149
backend/routes/cases/download.test.js
Normal file
149
backend/routes/cases/download.test.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import express from 'express';
|
||||||
|
import { Sequelize } from 'sequelize';
|
||||||
|
import casesDownloadRoute from './download.js';
|
||||||
|
|
||||||
|
// mock papaparse
|
||||||
|
vi.mock('papaparse', () => ({
|
||||||
|
default: {
|
||||||
|
unparse: vi.fn((data) => {
|
||||||
|
// Simple CSV generation for testing
|
||||||
|
if (data.length === 0) return '';
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const rows = data.map((row) => headers.map((h) => `"${row[h]}"`).join(','));
|
||||||
|
return [headers.join(','), ...rows].join('\n');
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock of authentication middleware
|
||||||
|
vi.mock('../../middleware/auth.js', () => ({
|
||||||
|
default: () => ({
|
||||||
|
verifySignedIn: vi.fn((req, res, next) => {
|
||||||
|
req.userId = 1;
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock('../../middleware/verifyVisible.js', () => ({
|
||||||
|
default: () => ({
|
||||||
|
verifyProjectVisibleFromFolderId: vi.fn((req, res, next) => {
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock defineCase
|
||||||
|
const mockCase = {
|
||||||
|
findAll: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/cases.js', () => ({
|
||||||
|
default: () => mockCase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('GET /download with type=csv', () => {
|
||||||
|
let app;
|
||||||
|
const sequelize = new Sequelize({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
logging: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/', casesDownloadRoute(sequelize));
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return CSV with human-readable labels for state, priority, type, automationStatus, and template', async () => {
|
||||||
|
mockCase.findAll.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Case 1',
|
||||||
|
state: 0, // new
|
||||||
|
priority: 0, // critical
|
||||||
|
type: 4, // functional
|
||||||
|
automationStatus: 0, // automated
|
||||||
|
template: 1, // step
|
||||||
|
description: 'Test description',
|
||||||
|
preConditions: 'Test preconditions',
|
||||||
|
expectedResults: 'Test expected results',
|
||||||
|
folderId: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Test Case 2',
|
||||||
|
state: 1, // inProgress
|
||||||
|
priority: 1, // high
|
||||||
|
type: 1, // security
|
||||||
|
automationStatus: 1, // automation-not-required
|
||||||
|
template: 0, // text
|
||||||
|
description: 'Test description 2',
|
||||||
|
preConditions: 'Test preconditions 2',
|
||||||
|
expectedResults: 'Test expected results 2',
|
||||||
|
folderId: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Test Case 3',
|
||||||
|
state: 2, // underReview
|
||||||
|
priority: 2, // medium
|
||||||
|
type: 2, // performance
|
||||||
|
automationStatus: 2, // cannot-be-automated
|
||||||
|
template: 1, // step
|
||||||
|
description: 'Test description 3',
|
||||||
|
preConditions: 'Test preconditions 3',
|
||||||
|
expectedResults: 'Test expected results 3',
|
||||||
|
folderId: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await request(app).get('/download?folderId=1&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');
|
||||||
|
|
||||||
|
const csvContent = response.text;
|
||||||
|
|
||||||
|
// Check that the CSV contains human-readable labels instead of numeric values
|
||||||
|
expect(csvContent).toContain('critical');
|
||||||
|
expect(csvContent).toContain('high');
|
||||||
|
expect(csvContent).toContain('medium');
|
||||||
|
expect(csvContent).toContain('functional');
|
||||||
|
expect(csvContent).toContain('security');
|
||||||
|
expect(csvContent).toContain('performance');
|
||||||
|
expect(csvContent).toContain('automated');
|
||||||
|
expect(csvContent).toContain('automation-not-required');
|
||||||
|
expect(csvContent).toContain('cannot-be-automated');
|
||||||
|
expect(csvContent).toContain('new');
|
||||||
|
expect(csvContent).toContain('inProgress');
|
||||||
|
expect(csvContent).toContain('underReview');
|
||||||
|
expect(csvContent).toContain('text');
|
||||||
|
expect(csvContent).toContain('step');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 if no cases found', async () => {
|
||||||
|
mockCase.findAll.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const response = await request(app).get('/download?folderId=999&type=csv');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.text).toBe('No cases found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 if folderId is missing', async () => {
|
||||||
|
const response = await request(app).get('/download?type=csv');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBe('folderId is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 if type is missing', async () => {
|
||||||
|
const response = await request(app).get('/download?folderId=1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBe('download type is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import defineCase from '../../models/cases.js';
|
|||||||
import defineFolder from '../../models/folders.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 { testRunCaseStatus, testRunStatus, priorities, testTypes, automationStatus } from '../../config/enums.js';
|
||||||
|
|
||||||
export default function (sequelize) {
|
export default function (sequelize) {
|
||||||
const { verifySignedIn } = authMiddleware(sequelize);
|
const { verifySignedIn } = authMiddleware(sequelize);
|
||||||
@@ -99,11 +100,11 @@ export default function (sequelize) {
|
|||||||
const records = runCases.map((rc) => ({
|
const records = runCases.map((rc) => ({
|
||||||
id: rc.Case.id,
|
id: rc.Case.id,
|
||||||
title: rc.Case.title,
|
title: rc.Case.title,
|
||||||
state: rc.Case.state,
|
state: testRunStatus[rc.Case.state] || rc.Case.state,
|
||||||
priority: rc.Case.priority,
|
priority: priorities[rc.Case.priority] || rc.Case.priority,
|
||||||
type: rc.Case.type,
|
type: testTypes[rc.Case.type] || rc.Case.type,
|
||||||
automationStatus: rc.Case.automationStatus,
|
automationStatus: automationStatus[rc.Case.automationStatus] || rc.Case.automationStatus,
|
||||||
status: rc.status,
|
status: testRunCaseStatus[rc.status] || rc.status,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const csv = Papa.unparse(records, {
|
const csv = Papa.unparse(records, {
|
||||||
@@ -111,7 +112,7 @@ export default function (sequelize) {
|
|||||||
skipEmptyLines: true,
|
skipEmptyLines: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.csv`);
|
res.setHeader('Content-Disposition', `attachment; filename=run_${runId}.csv`);
|
||||||
return res.send(csv);
|
return res.send(csv);
|
||||||
}
|
}
|
||||||
|
|||||||
193
backend/routes/runs/download.test.js
Normal file
193
backend/routes/runs/download.test.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import express from 'express';
|
||||||
|
import { Sequelize } from 'sequelize';
|
||||||
|
import runsDownloadRoute from './download.js';
|
||||||
|
|
||||||
|
// mock papaparse
|
||||||
|
vi.mock('papaparse', () => ({
|
||||||
|
default: {
|
||||||
|
unparse: vi.fn((data) => {
|
||||||
|
// Simple CSV generation for testing
|
||||||
|
if (data.length === 0) return '';
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const rows = data.map((row) => headers.map((h) => `"${row[h]}"`).join(','));
|
||||||
|
return [headers.join(','), ...rows].join('\n');
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock xmlbuilder2
|
||||||
|
vi.mock('xmlbuilder2', () => ({
|
||||||
|
create: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock of authentication middleware
|
||||||
|
vi.mock('../../middleware/auth.js', () => ({
|
||||||
|
default: () => ({
|
||||||
|
verifySignedIn: vi.fn((req, res, next) => {
|
||||||
|
req.userId = 1;
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock('../../middleware/verifyVisible.js', () => ({
|
||||||
|
default: () => ({
|
||||||
|
verifyProjectVisibleFromRunId: vi.fn((req, res, next) => {
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock defineRun
|
||||||
|
const mockRun = {
|
||||||
|
findByPk: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/runs.js', () => ({
|
||||||
|
default: () => mockRun,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock defineRunCase
|
||||||
|
const mockRunCase = {
|
||||||
|
findAll: vi.fn(),
|
||||||
|
belongsTo: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/runCases.js', () => ({
|
||||||
|
default: () => mockRunCase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock defineCase
|
||||||
|
const mockCase = {
|
||||||
|
belongsTo: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/cases.js', () => ({
|
||||||
|
default: () => mockCase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock defineFolder
|
||||||
|
const mockFolder = {
|
||||||
|
findByPk: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mock('../../models/folders.js', () => ({
|
||||||
|
default: () => mockFolder,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('GET /download/:runId with type=csv', () => {
|
||||||
|
let app;
|
||||||
|
const sequelize = new Sequelize({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
logging: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/', runsDownloadRoute(sequelize));
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return CSV with human-readable labels for status, priority, type, and automationStatus', async () => {
|
||||||
|
mockRun.findByPk.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Run',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockRunCase.findAll.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
runId: 1,
|
||||||
|
caseId: 1,
|
||||||
|
status: 1, // passed
|
||||||
|
Case: {
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Case 1',
|
||||||
|
state: 0, // new
|
||||||
|
priority: 0, // critical
|
||||||
|
type: 4, // functional
|
||||||
|
automationStatus: 0, // automated
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
runId: 1,
|
||||||
|
caseId: 2,
|
||||||
|
status: 2, // failed
|
||||||
|
Case: {
|
||||||
|
id: 2,
|
||||||
|
title: 'Test Case 2',
|
||||||
|
state: 1, // inProgress
|
||||||
|
priority: 1, // high
|
||||||
|
type: 1, // security
|
||||||
|
automationStatus: 1, // automation-not-required
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
runId: 1,
|
||||||
|
caseId: 3,
|
||||||
|
status: 0, // untested
|
||||||
|
Case: {
|
||||||
|
id: 3,
|
||||||
|
title: 'Test Case 3',
|
||||||
|
state: 2, // underReview
|
||||||
|
priority: 2, // medium
|
||||||
|
type: 2, // performance
|
||||||
|
automationStatus: 2, // cannot-be-automated
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await request(app).get('/download/1?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');
|
||||||
|
|
||||||
|
const csvContent = response.text;
|
||||||
|
|
||||||
|
// Check that the CSV contains human-readable labels instead of numeric values
|
||||||
|
expect(csvContent).toContain('passed');
|
||||||
|
expect(csvContent).toContain('failed');
|
||||||
|
expect(csvContent).toContain('untested');
|
||||||
|
expect(csvContent).toContain('critical');
|
||||||
|
expect(csvContent).toContain('high');
|
||||||
|
expect(csvContent).toContain('medium');
|
||||||
|
expect(csvContent).toContain('functional');
|
||||||
|
expect(csvContent).toContain('security');
|
||||||
|
expect(csvContent).toContain('performance');
|
||||||
|
expect(csvContent).toContain('automated');
|
||||||
|
expect(csvContent).toContain('automation-not-required');
|
||||||
|
expect(csvContent).toContain('cannot-be-automated');
|
||||||
|
expect(csvContent).toContain('new');
|
||||||
|
expect(csvContent).toContain('inProgress');
|
||||||
|
expect(csvContent).toContain('underReview');
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Parse CSV rows to verify values
|
||||||
|
dataLines.forEach((line) => {
|
||||||
|
const values = line.split(',').map((v) => v.replace(/"/g, '').trim());
|
||||||
|
// Skip the id column (first column) and title column (second column)
|
||||||
|
const nonIdTitleValues = values.slice(2);
|
||||||
|
|
||||||
|
// Check that state, priority, type, automationStatus, status are not just single digits
|
||||||
|
nonIdTitleValues.forEach((value) => {
|
||||||
|
if (value && !isNaN(value) && value.length === 1) {
|
||||||
|
// This would indicate a numeric value wasn't converted
|
||||||
|
// But we allow it if it could be valid (this is a weak check, mainly for demonstration)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 if run not found', async () => {
|
||||||
|
mockRun.findByPk.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const response = await request(app).get('/download/999?type=csv');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.text).toBe('Run not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user