Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fd37bc1
feat(manual): add manual test case management with immutable versioning
snevesbarros Aug 23, 2026
7604b2e
docs(manual): document the manual test case endpoints in swagger
snevesbarros Aug 23, 2026
88a4caf
feat(manual): add admin-configurable custom fields for manual test cases
snevesbarros Aug 23, 2026
a6136fa
fix(swagger): move the auth provider schemas into components/schemas
snevesbarros Aug 23, 2026
48fd4d3
feat(manual): add re-usable shared steps with forward-cascading versions
snevesbarros Aug 23, 2026
168a383
feat(manual): add image attachments for manual test steps
snevesbarros Aug 23, 2026
d5fb029
feat(manual): record who changed what on test cases, shared steps and…
snevesbarros Aug 23, 2026
e1a1183
feat(manual): add manual test runs, joining results to the automated …
snevesbarros Aug 23, 2026
ed153ae
feat(metrics): filter dashboards and metrics by automated or manual
snevesbarros Aug 23, 2026
14d6e30
docs(plan): bring the Java and Python clients into phase 9
snevesbarros Aug 23, 2026
e193134
docs(manual): add the manual test case management guide
Aug 23, 2026
8e6a263
docs(plan): mark the dashboard filtering work done, note the deferred…
Aug 23, 2026
42ce121
fix(manual): stop a failed head save from wedging a test case
Aug 23, 2026
ef5a46b
fix(manual): name the offending step in step validation messages
Aug 23, 2026
28b83a7
feat(manual): organise test cases in a nested folder tree
Aug 23, 2026
bad4c97
fix(manual): accept expanded shared steps echoed back on save
Aug 23, 2026
9ae3fde
fix(api): report a cast failure as 422 rather than 500
Aug 23, 2026
5b1a80c
feat(manual): order test cases by folder so a page groups cleanly
snevesbarros Aug 23, 2026
269b47e
feat(settings): let admins disable manual test case management
snevesbarros Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ build
Dockerfile
Dockerfile.prod
screenshots/
attachments/
compares/

.git
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.idea/
.DS_Store
screenshots/
attachments/
compares/*
!compares/description.txt
/build/
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ ENV SWAGGER_SCHEMES=http

VOLUME /app/screenshots
VOLUME /app/compares
VOLUME /app/attachments

# runtime tools required by the clean-up crontab:
# bash (clean-up.sh uses readarray), curl + jq (query the API), dcron (scheduler)
Expand Down
208 changes: 208 additions & 0 deletions app/controllers/attachment.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
const path = require('path');
const { validationResult } = require('express-validator');
const debug = require('debug');

const Attachment = require('../models/attachment.js');
const ManualTestCase = require('../models/manual-test-case.js');
const SharedStep = require('../models/shared-step.js');
const attachmentUtils = require('../utils/attachment-utils.js');
const authMiddleware = require('../utils/auth-middleware.js');
const {
NotFoundError,
ForbiddenError,
ConflictError,
handleError,
} = require('../exceptions/errors.js');

const log = debug('attachment:controller');

// Resolves the entity an upload is being attached to, and the team that owns it. The team
// comes from the entity rather than the request so a caller cannot claim an attachment
// belongs to a team they happen to have access to.
const resolveOwner = async (body) => {
const { testCaseId, sharedStepId, executionId } = body;
if (testCaseId) {
const testCase = await ManualTestCase.findById(testCaseId).select('_id team').lean().exec();
if (!testCase) {
throw new NotFoundError(`No manual test case found with id ${testCaseId}`);
}
return { scope: 'testcase', field: 'testCase', owner: testCase };
}
if (sharedStepId) {
const sharedStep = await SharedStep.findById(sharedStepId).select('_id team').lean().exec();
if (!sharedStep) {
throw new NotFoundError(`No shared step found with id ${sharedStepId}`);
}
return { scope: 'sharedstep', field: 'sharedStep', owner: sharedStep };
}
// Manual executions arrive in phase 6; the upload path already accepts them.
throw new NotFoundError(`No attachable entity found with id ${executionId}`);
};

exports.create = (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
if (!req.file) {
return res.status(400).send({ message: 'An image file is required in the "attachment" field.' });
}

return resolveOwner(req.body)
.then(async ({ scope, field, owner }) => {
if (!authMiddleware.hasTeamAccess(req.user, owner.team)) {
throw new ForbiddenError('You do not have access to this team');
}

const { thumbnail, width, height } = await attachmentUtils
.generateThumbnail(req.file.path);

const attachment = new Attachment({
team: owner.team,
scope,
[field]: owner._id,
filename: req.file.filename,
// Display only - never used to build a path, and stripped of any directory
// component the client may have included.
originalName: path.basename(req.file.originalname || ''),
mimeType: req.file.mimetype,
size: req.file.size,
path: req.file.path,
thumbnail,
width,
height,
uploadedBy: req.user ? req.user._id : undefined,
});
const saved = await attachment.save();
log(`Stored attachment ${saved._id} for ${scope} ${owner._id}`);
return saved;
})
.then((saved) => res.status(201).send(saved))
.catch(async (err) => {
// The file is already on disk by the time any of this runs, so a rejected upload
// must not leave it orphaned there. multer also created the directory before the
// request was authorised, so tidy that away too when nothing else landed in it.
if (req.file) {
await attachmentUtils.removeFiles({ path: req.file.path });
await attachmentUtils.removeDirectoryIfEmpty(path.dirname(req.file.path));
}
return handleError(err, res);
});
};

// Express only treats a middleware as an error handler when it declares four parameters,
// so `next` must stay in the signature even though it is unused - without it multer's
// rejections (bad mime type, missing owner id) fall through to the default handler and
// are returned as a 500 with an HTML stack trace instead of this 400.
// eslint-disable-next-line no-unused-vars
exports.createFail = (error, req, res, next) => res.status(400).send({ error: error.message });

exports.findAll = (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
const { testCaseId, sharedStepId } = req.query;

return resolveOwner({ testCaseId, sharedStepId })
.then(({ field, owner }) => {
if (!authMiddleware.hasTeamAccess(req.user, owner.team)) {
throw new ForbiddenError('You do not have access to this team');
}
return Attachment.find({ [field]: owner._id })
.populate('uploadedBy', 'username')
.sort('-createdAt')
.lean()
.exec();
})
.then((attachments) => res.status(200).send({ attachments }))
.catch((err) => handleError(err, res));
};

const findWithAccess = async (attachmentId, user) => {
const attachment = await Attachment.findById(attachmentId).lean().exec();
if (!attachment) {
throw new NotFoundError(`No attachment found with id ${attachmentId}`);
}
if (!authMiddleware.hasTeamAccess(user, attachment.team)) {
throw new ForbiddenError('You do not have access to this attachment');
}
return attachment;
};

exports.findOne = (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
return findWithAccess(req.params.attachmentId, req.user)
.then((attachment) => Attachment.findById(attachment._id)
.populate('uploadedBy', 'username')
.lean()
.exec())
.then((attachment) => res.status(200).send(attachment))
.catch((err) => handleError(err, res));
};

exports.findFile = (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
return findWithAccess(req.params.attachmentId, req.user)
.then((attachment) => res.sendFile(path.resolve(attachment.path)))
.catch((err) => handleError(err, res));
};

exports.findThumbnail = (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
return findWithAccess(req.params.attachmentId, req.user)
.then((attachment) => {
// An image that could not be thumbnailed still has a usable original, so fall back
// to it rather than 404ing a request the UI makes for every listed attachment.
const target = attachment.thumbnail || attachment.path;
return res.sendFile(path.resolve(target));
})
.catch((err) => handleError(err, res));
};

exports.delete = (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
const { attachmentId } = req.params;

return findWithAccess(attachmentId, req.user)
.then(async (attachment) => {
// An attachment referenced by a frozen version is never removed. Dropping the image
// from a step in the editor takes it off the head, but the file is the only copy of
// what a historical execution has to render.
const references = await attachmentUtils.countVersionReferences(attachment._id);
if (references > 0) {
throw new ConflictError(`Unable to delete this attachment as ${references} frozen test case version(s) still reference it. Remove it from the test case instead; the image stays available to the executions that used it.`);
}

await attachmentUtils.removeFiles(attachment);
await Attachment.findByIdAndRemove(attachmentId).exec();

// Leave no dangling reference on the head - a step pointing at a removed attachment
// would render as a broken image.
await ManualTestCase.updateMany(
{ 'steps.attachments': attachment._id },
{ $pull: { 'steps.$[].attachments': attachment._id } },
).exec();
await SharedStep.updateMany(
{ 'steps.attachments': attachment._id },
{ $pull: { 'steps.$[].attachments': attachment._id } },
).exec();

log(`Deleted attachment ${attachmentId}`);
return true;
})
.then(() => res.status(200).send({ message: 'Attachment deleted successfully!' }))
.catch((err) => handleError(err, res));
};
4 changes: 4 additions & 0 deletions app/controllers/build.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ exports.findAll = (req, res) => {
componentIds,
fromDate,
toDate,
executionType,
} = req.query;
const limit = parseInt(req.query.limit, 10) || 10;
const skip = parseInt(req.query.skip, 10) || 0;
Expand All @@ -165,6 +166,9 @@ exports.findAll = (req, res) => {
team: mongoose.Types.ObjectId(teamId),
};
}
// Absent means both, so every existing client keeps seeing exactly what it did
// before manual runs existed.
if (executionType) query.executionType = executionType;
if (environmentIds) query.environment = { $in: environmentIds.split(',').map((environmentId) => (mongoose.Types.ObjectId(environmentId))) };
if (componentIds) query.component = { $in: componentIds.split(',').map((componentId) => (mongoose.Types.ObjectId(componentId))) };
if (fromDate) {
Expand Down
Loading