feat: upload test record attachments per run case

Allow testers to attach evidence files to a single test case within a
specific test run, proving the test was actually executed. Attachments
are scoped to the RunCase (per-execution), not the shared case definition.

Backend:
- runCaseAttachments join table + model (CASCADE on runCase/attachment)
- POST/GET /runcaseattachments routes (reuse /attachments download+delete)

Frontend:
- new "Test record" tab in the run case detail pane with drag-and-drop
  upload, scoped to reporters; i18n for all 6 locales

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
LittleYellow
2026-06-24 07:01:44 +08:00
parent 55e78875ae
commit 02fa631f02
16 changed files with 569 additions and 7 deletions

View File

@@ -0,0 +1,43 @@
export async function up(queryInterface, Sequelize) {
await queryInterface.createTable('runCaseAttachments', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
},
runCaseId: {
type: Sequelize.INTEGER,
references: {
model: 'runCases',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
attachmentId: {
type: Sequelize.INTEGER,
references: {
model: 'attachments',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
await queryInterface.addIndex('runCaseAttachments', ['runCaseId', 'attachmentId'], {
unique: true,
});
}
export async function down(queryInterface) {
await queryInterface.dropTable('runCaseAttachments');
}

View File

@@ -0,0 +1,27 @@
function defineRunCaseAttachment(sequelize, DataTypes) {
const RunCaseAttachment = sequelize.define('RunCaseAttachment', {
runCaseId: {
type: DataTypes.INTEGER,
allowNull: false,
},
attachmentId: {
type: DataTypes.INTEGER,
allowNull: false,
},
});
RunCaseAttachment.associate = (models) => {
RunCaseAttachment.belongsTo(models.RunCase, {
foreignKey: 'runCaseId',
onDelete: 'CASCADE',
});
RunCaseAttachment.belongsTo(models.Attachment, {
foreignKey: 'attachmentId',
onDelete: 'CASCADE',
});
};
return RunCaseAttachment;
}
export default defineRunCaseAttachment;

View File

@@ -0,0 +1,35 @@
import express from 'express';
const router = express.Router();
import { DataTypes } from 'sequelize';
import defineAttachment from '../../models/attachments.js';
import defineRunCase from '../../models/runCases.js';
export default function (sequelize) {
const Attachment = defineAttachment(sequelize, DataTypes);
const RunCase = defineRunCase(sequelize, DataTypes);
RunCase.belongsToMany(Attachment, { through: 'runCaseAttachments', foreignKey: 'runCaseId', otherKey: 'attachmentId' });
Attachment.belongsToMany(RunCase, { through: 'runCaseAttachments', foreignKey: 'attachmentId', otherKey: 'runCaseId' });
// ponytail: no auth middleware, mirroring the sibling /attachments routes (TODO there).
router.get('/', async (req, res) => {
const { runCaseId } = req.query;
if (!runCaseId) {
return res.status(400).json({ error: 'runCaseId is required' });
}
try {
const runCase = await RunCase.findByPk(runCaseId, {
include: [{ model: Attachment }],
});
if (!runCase) {
return res.status(404).send('RunCase not found');
}
res.json(runCase.Attachments);
} catch (error) {
console.error(error);
res.status(500).send('Internal Server Error');
}
});
return router;
}

View File

@@ -0,0 +1,91 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import multer from 'multer';
import express from 'express';
const router = express.Router();
import { DataTypes } from 'sequelize';
import defineAttachment from '../../models/attachments.js';
import defineRunCaseAttachment from '../../models/runCaseAttachments.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default function (sequelize) {
const Attachment = defineAttachment(sequelize, DataTypes);
const RunCaseAttachment = defineRunCaseAttachment(sequelize, DataTypes);
// Create uploads folder if it does not exist
const uploadDir = path.join(__dirname, '../../public/uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: async (req, file, cb) => {
const ext = path.extname(file.originalname);
const baseName = path.basename(file.originalname, ext);
let fileName = `${baseName}${ext}`;
// Check if a file with the same name already exists
let fileExists = true;
let fileIndex = 1;
while (fileExists) {
const filePath = path.join(uploadDir, fileName);
if (fs.existsSync(filePath)) {
// If a file with the same name exists, add an index and rename the file
fileName = `${baseName}_${fileIndex}${ext}`;
fileIndex++;
} else {
fileExists = false;
}
}
cb(null, fileName);
},
});
const upload = multer({ storage });
// ponytail: no auth middleware, mirroring the sibling /attachments upload route
// (see routes/attachments/new.js TODO). Upgrade path: add verifyProjectReporterFromRunCaseId.
router.post('/', upload.array('files', 10), async (req, res) => {
const t = await sequelize.transaction();
try {
const runCaseId = req.query.runCaseId;
if (!runCaseId) {
return res.status(400).json({ error: 'runCaseId is required' });
}
const files = req.files;
if (files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const attachmentsData = files.map((file) => ({
title: file.originalname,
filename: file.filename,
}));
const newAttachments = await Attachment.bulkCreate(attachmentsData, {
transaction: t,
});
const runCaseAttachmentsData = newAttachments.map((attachment) => ({
runCaseId: runCaseId,
attachmentId: attachment.id,
}));
await RunCaseAttachment.bulkCreate(runCaseAttachmentsData, { transaction: t });
await t.commit();
res.json(newAttachments);
} catch (error) {
console.error(error);
await t.rollback();
res.status(500).json({ error: 'Internal server error' });
}
});
return router;
}

View File

@@ -149,6 +149,12 @@ import runCaseEditRoute from './routes/runcases/edit.js';
app.use('/runcases', runCaseIndexRoute(sequelize));
app.use('/runcases', runCaseEditRoute(sequelize));
// "/runcaseattachments"
import runCaseAttachmentsNewRoute from './routes/runcaseattachments/new.js';
import runCaseAttachmentsIndexRoute from './routes/runcaseattachments/index.js';
app.use('/runcaseattachments', runCaseAttachmentsNewRoute(sequelize));
app.use('/runcaseattachments', runCaseAttachmentsIndexRoute(sequelize));
// "/members"
import membersIndexRoute from './routes/members/index.js';
import membersNewRoute from './routes/members/new.js';