feat: support JSON import for test cases and add Traditional Chinese locale
Some checks failed
UnitTCMS CI / unittcms-test (push) Has been cancelled
Some checks failed
UnitTCMS CI / unittcms-test (push) Has been cancelled
JSON import lets users round-trip cases exported via the JSON download endpoint back into a folder, alongside the existing Excel import path.
This commit is contained in:
@@ -12,18 +12,15 @@ import editableMiddleware from '../../middleware/verifyEditable.js';
|
||||
import { priorities, testTypes, automationStatus, templates } from '../../config/enums.js';
|
||||
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const allowedFileTypes = ['.xlsx', '.xls'];
|
||||
const allowedMimeTypes = [
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel',
|
||||
];
|
||||
// Accept Excel and JSON by extension. JSON mime types vary across browsers
|
||||
// (application/json, text/plain, application/octet-stream), so rely on the extension.
|
||||
const allowedFileTypes = ['.xlsx', '.xls', '.json'];
|
||||
const extname = allowedFileTypes.includes(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedMimeTypes.includes(file.mimetype);
|
||||
|
||||
if (extname && mimetype) {
|
||||
if (extname) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only Excel files (.xlsx, .xls) are allowed!'));
|
||||
cb(new Error('Only Excel (.xlsx, .xls) or JSON (.json) files are allowed!'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,61 +63,23 @@ export default function (sequelize) {
|
||||
return res.status(400).json({ error: 'folderId is required' });
|
||||
}
|
||||
|
||||
// Parse and validate the file before opening a transaction.
|
||||
// Both parsers return the same { casesToCreate, stepsToCreate } shape,
|
||||
// or throw an Error with a user-facing validation message.
|
||||
let casesToCreate;
|
||||
let stepsToCreate;
|
||||
try {
|
||||
const ext = path.extname(req.file.originalname).toLowerCase();
|
||||
const parsed =
|
||||
ext === '.json' ? _parseJsonBuffer(req.file.buffer, folderId) : _parseExcelBuffer(req.file.buffer, folderId);
|
||||
casesToCreate = parsed.casesToCreate;
|
||||
stepsToCreate = parsed.stepsToCreate;
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
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 currentTitle = null;
|
||||
let previousTitle = null;
|
||||
let stepNo = 1;
|
||||
const casesToCreate = [];
|
||||
const stepsToCreate = [];
|
||||
for (const [index, row] of jsonData.entries()) {
|
||||
const errorMessage = _getRowValidationError(row, index);
|
||||
if (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({
|
||||
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'] || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 'Manually' create cases, steps and caseStep association.
|
||||
const createdCases = await Case.bulkCreate(casesToCreate, { transaction: t });
|
||||
for (const stepData of stepsToCreate) {
|
||||
@@ -147,6 +106,9 @@ export default function (sequelize) {
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
console.error(error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -154,6 +116,120 @@ export default function (sequelize) {
|
||||
return router;
|
||||
}
|
||||
|
||||
// Parse an Excel (.xlsx, .xls) buffer into { casesToCreate, stepsToCreate }.
|
||||
// Rows sharing the same title are grouped into a single case with multiple steps.
|
||||
function _parseExcelBuffer(buffer, folderId) {
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||
|
||||
let currentTitle = null;
|
||||
let previousTitle = null;
|
||||
let stepNo = 1;
|
||||
const casesToCreate = [];
|
||||
const stepsToCreate = [];
|
||||
for (const [index, row] of jsonData.entries()) {
|
||||
const errorMessage = _getRowValidationError(row, index);
|
||||
if (errorMessage) {
|
||||
throw new 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({
|
||||
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'] || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { casesToCreate, stepsToCreate };
|
||||
}
|
||||
|
||||
// Parse a JSON buffer into { casesToCreate, stepsToCreate }.
|
||||
// Accepts the same shape produced by the JSON export (`/cases/download?type=json`):
|
||||
// either an array of case objects, or an object map keyed by id.
|
||||
// `id`, `folderId`, `folder` and `state` are ignored; cases are created in the
|
||||
// target folder with the default `new` state.
|
||||
function _parseJsonBuffer(buffer, folderId) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(buffer.toString('utf-8'));
|
||||
} catch {
|
||||
throw new Error('Invalid JSON file');
|
||||
}
|
||||
|
||||
const rawCases = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object' ? Object.values(parsed) : null;
|
||||
if (!rawCases || rawCases.length === 0) {
|
||||
throw new Error('No cases found in JSON file');
|
||||
}
|
||||
|
||||
const casesToCreate = [];
|
||||
const stepsToCreate = [];
|
||||
for (const [index, c] of rawCases.entries()) {
|
||||
const errorMessage = _getJsonCaseValidationError(c, index);
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
casesToCreate.push({
|
||||
folderId: folderId,
|
||||
title: String(c.title).trim(),
|
||||
description: c.description || '',
|
||||
state: 0, // default state
|
||||
priority: priorities.indexOf(c.priority),
|
||||
type: testTypes.indexOf(c.type),
|
||||
preConditions: c.preConditions || '',
|
||||
expectedResults: c.expectedResults || '',
|
||||
automationStatus: c.automationStatus
|
||||
? automationStatus.indexOf(c.automationStatus)
|
||||
: automationStatus.indexOf('automation-not-required'),
|
||||
template: templates.indexOf(c.template),
|
||||
});
|
||||
|
||||
const steps = Array.isArray(c.Steps) ? c.Steps : [];
|
||||
steps.forEach((s, stepIndex) => {
|
||||
stepsToCreate.push({
|
||||
caseIndex: casesToCreate.length - 1,
|
||||
stepNo: stepIndex + 1,
|
||||
step: s?.step || '',
|
||||
result: s?.expectedStepResult || s?.result || '',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { casesToCreate, stepsToCreate };
|
||||
}
|
||||
|
||||
function _getRowValidationError(row, index) {
|
||||
const requiredFields = ['title', 'priority', 'type', 'template'];
|
||||
const rowNumber = index + 2;
|
||||
@@ -198,3 +274,37 @@ function _getRowValidationError(row, index) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function _getJsonCaseValidationError(c, index) {
|
||||
const caseNumber = index + 1;
|
||||
|
||||
if (!c || typeof c !== 'object') {
|
||||
return `Case ${caseNumber} is not a valid object`;
|
||||
}
|
||||
|
||||
const requiredFields = ['title', 'priority', 'type', 'template'];
|
||||
for (const field of requiredFields) {
|
||||
if (!c[field]) {
|
||||
return `Case ${caseNumber} is missing required field: ${field}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Values are human-readable labels, matching the JSON export format.
|
||||
if (priorities.indexOf(c.priority) === -1) {
|
||||
return `Case ${caseNumber} has invalid priority: ${c.priority}`;
|
||||
}
|
||||
|
||||
if (testTypes.indexOf(c.type) === -1) {
|
||||
return `Case ${caseNumber} has invalid type: ${c.type}`;
|
||||
}
|
||||
|
||||
if (templates.indexOf(c.template) === -1) {
|
||||
return `Case ${caseNumber} has invalid template: ${c.template}`;
|
||||
}
|
||||
|
||||
if (c.automationStatus && automationStatus.indexOf(c.automationStatus) === -1) {
|
||||
return `Case ${caseNumber} has invalid automationStatus: ${c.automationStatus}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
163
backend/routes/cases/import.test.js
Normal file
163
backend/routes/cases/import.test.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
import { Sequelize } from 'sequelize';
|
||||
import casesImportRoute from './import.js';
|
||||
|
||||
// mock of authentication / permission middleware
|
||||
vi.mock('../../middleware/auth.js', () => ({
|
||||
default: () => ({
|
||||
verifySignedIn: vi.fn((req, res, next) => {
|
||||
req.userId = 1;
|
||||
next();
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
vi.mock('../../middleware/verifyEditable.js', () => ({
|
||||
default: () => ({
|
||||
verifyProjectDeveloperFromFolderId: vi.fn((req, res, next) => {
|
||||
next();
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockCase = {
|
||||
bulkCreate: vi.fn(),
|
||||
belongsToMany: vi.fn(),
|
||||
};
|
||||
vi.mock('../../models/cases.js', () => ({
|
||||
default: () => mockCase,
|
||||
}));
|
||||
|
||||
const mockStep = {
|
||||
create: vi.fn(),
|
||||
belongsToMany: vi.fn(),
|
||||
};
|
||||
vi.mock('../../models/steps.js', () => ({
|
||||
default: () => mockStep,
|
||||
}));
|
||||
|
||||
const mockCaseStep = {
|
||||
create: vi.fn(),
|
||||
};
|
||||
vi.mock('../../models/caseSteps.js', () => ({
|
||||
default: () => mockCaseStep,
|
||||
}));
|
||||
|
||||
describe('POST /import with JSON file', () => {
|
||||
let app;
|
||||
const sequelize = new Sequelize({ dialect: 'sqlite', logging: false });
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/', casesImportRoute(sequelize));
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(sequelize, 'transaction').mockResolvedValue({
|
||||
commit: vi.fn(),
|
||||
rollback: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
const attachJson = (data) =>
|
||||
request(app)
|
||||
.post('/import?folderId=1')
|
||||
.attach('file', Buffer.from(JSON.stringify(data)), 'cases.json');
|
||||
|
||||
it('creates cases from a JSON array, mapping labels to enum indexes', async () => {
|
||||
mockCase.bulkCreate.mockResolvedValue([{ id: 101 }, { id: 102 }]);
|
||||
mockStep.create.mockResolvedValue({ id: 201 });
|
||||
mockCaseStep.create.mockResolvedValue({});
|
||||
|
||||
const response = await attachJson([
|
||||
{
|
||||
title: 'Text case',
|
||||
priority: 'high',
|
||||
type: 'functional',
|
||||
template: 'text',
|
||||
description: 'desc',
|
||||
Steps: [],
|
||||
},
|
||||
{
|
||||
title: 'Step case',
|
||||
priority: 'medium',
|
||||
type: 'acceptance',
|
||||
template: 'step',
|
||||
automationStatus: 'cannot-be-automated',
|
||||
Steps: [
|
||||
{ step: 'do A', expectedStepResult: 'A done' },
|
||||
{ step: 'do B', expectedStepResult: 'B done' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const created = mockCase.bulkCreate.mock.calls[0][0];
|
||||
expect(created).toHaveLength(2);
|
||||
expect(created[0]).toMatchObject({
|
||||
folderId: '1',
|
||||
title: 'Text case',
|
||||
state: 0,
|
||||
priority: 1, // high
|
||||
type: 4, // functional
|
||||
template: 0, // text
|
||||
automationStatus: 1, // default automation-not-required
|
||||
});
|
||||
expect(created[1]).toMatchObject({
|
||||
title: 'Step case',
|
||||
priority: 2, // medium
|
||||
type: 5, // acceptance
|
||||
template: 1, // step
|
||||
automationStatus: 2, // cannot-be-automated
|
||||
});
|
||||
|
||||
// Only the second case has steps -> 2 steps created
|
||||
expect(mockStep.create).toHaveBeenCalledTimes(2);
|
||||
expect(mockCaseStep.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('accepts the object-map shape produced by the JSON export', async () => {
|
||||
mockCase.bulkCreate.mockResolvedValue([{ id: 101 }]);
|
||||
|
||||
const response = await attachJson({
|
||||
1: { title: 'Mapped case', priority: 'low', type: 'other', template: 'text', Steps: [] },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockCase.bulkCreate.mock.calls[0][0]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns 400 when a required field is missing', async () => {
|
||||
const response = await attachJson([{ priority: 'high', type: 'functional', template: 'text' }]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('missing required field: title');
|
||||
expect(mockCase.bulkCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 for an invalid priority label', async () => {
|
||||
const response = await attachJson([
|
||||
{ title: 'Bad priority', priority: 'urgent', type: 'functional', template: 'text' },
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('invalid priority');
|
||||
});
|
||||
|
||||
it('returns 400 for malformed JSON', async () => {
|
||||
const response = await request(app)
|
||||
.post('/import?folderId=1')
|
||||
.attach('file', Buffer.from('{ not json'), 'cases.json');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Invalid JSON file');
|
||||
});
|
||||
|
||||
it('rejects unsupported file extensions', async () => {
|
||||
const response = await request(app).post('/import?folderId=1').attach('file', Buffer.from('hello'), 'cases.txt');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('Only Excel');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user