diff --git a/.dockerignore b/.dockerignore index e2ce24f..c7ba9c9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,7 @@ build Dockerfile Dockerfile.prod screenshots/ +attachments/ compares/ .git diff --git a/.gitignore b/.gitignore index 676a7bc..97db975 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .idea/ .DS_Store screenshots/ +attachments/ compares/* !compares/description.txt /build/ diff --git a/Dockerfile b/Dockerfile index 8ae3881..9a3f6b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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) diff --git a/app/controllers/attachment.controller.js b/app/controllers/attachment.controller.js new file mode 100644 index 0000000..7e0695b --- /dev/null +++ b/app/controllers/attachment.controller.js @@ -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)); +}; diff --git a/app/controllers/build.controller.js b/app/controllers/build.controller.js index c8efa0d..62be9bf 100644 --- a/app/controllers/build.controller.js +++ b/app/controllers/build.controller.js @@ -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; @@ -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) { diff --git a/app/controllers/custom-field.controller.js b/app/controllers/custom-field.controller.js new file mode 100644 index 0000000..c988758 --- /dev/null +++ b/app/controllers/custom-field.controller.js @@ -0,0 +1,309 @@ +const { validationResult } = require('express-validator'); +const debug = require('debug'); + +const CustomFieldDefinition = require('../models/custom-field-definition.js'); +const ManualTestCase = require('../models/manual-test-case.js'); +const ManualTestCaseVersion = require('../models/manual-test-case-version.js'); +const { Team } = require('../models/team.js'); +const { optionBackedTypes } = require('../models/custom-field-definition.js'); +const historyUtils = require('../utils/history-utils.js'); +const authMiddleware = require('../utils/auth-middleware.js'); +const { + NotFoundError, + ForbiddenError, + ConflictError, + InvalidRequestError, + handleError, +} = require('../exceptions/errors.js'); + +const log = debug('custom-field:controller'); + +// select/multiselect are meaningless without something to select from, and a field of any +// other type carrying options would silently ignore them. +const validateOptions = (type, options) => { + if (optionBackedTypes.includes(type)) { + if (!Array.isArray(options) || options.length === 0) { + throw new InvalidRequestError(`A field of type "${type}" requires at least one option.`); + } + const unique = new Set(options); + if (unique.size !== options.length) { + throw new InvalidRequestError('Field options must be unique.'); + } + } else if (options !== undefined && options !== null && options.length > 0) { + throw new InvalidRequestError(`A field of type "${type}" cannot have options.`); + } +}; + +exports.create = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { + team, + key, + label, + type, + options, + required, + defaultValue, + order, + appliesTo, + } = req.body; + + return Team.findById(team).select('_id').lean().exec() + .then(async (teamFound) => { + if (!teamFound) { + throw new NotFoundError(`No team found with id ${team}`); + } + validateOptions(type, options); + + // Checked explicitly rather than relying on the unique index, so the caller gets a + // 409 explaining which key collided instead of a driver error. The index is still + // the backstop against a concurrent create. + const existing = await CustomFieldDefinition + .findOne({ team: teamFound._id, key }) + .lean() + .exec(); + if (existing) { + const suffix = existing.archived ? ' (it is archived)' : ''; + throw new ConflictError(`A custom field with key "${key}" already exists for this team${suffix}.`); + } + + const definition = new CustomFieldDefinition({ + team: teamFound._id, + key, + label, + type, + options, + required, + defaultValue, + order, + appliesTo, + }); + const saved = await definition.save(); + log(`Created custom field "${key}" for team ${teamFound._id}`); + historyUtils.recordChange({ + entityType: 'customfield', + entityId: saved._id, + team: saved.team, + action: 'CREATE', + user: req.user ? req.user._id : undefined, + comment: `Created custom field "${saved.label}" (${saved.type})`, + }); + return saved; + }) + .then((saved) => res.status(201).send(saved)) + .catch((err) => handleError(err, res)); +}; + +exports.findAll = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { teamId, includeArchived, appliesTo } = req.query; + + return Team.findById(teamId).select('_id').lean().exec() + .then((teamFound) => { + if (!teamFound) { + throw new NotFoundError(`No team found with id ${teamId}`); + } + // Readable by anyone with team access - the authoring UI needs the definitions to + // render its form. Only writes are admin-only. + if (!authMiddleware.hasTeamAccess(req.user, teamId)) { + throw new ForbiddenError('You do not have access to this team'); + } + const query = { team: teamFound._id }; + if (includeArchived !== 'true') { + query.archived = false; + } + if (appliesTo) { + query.appliesTo = { $in: [appliesTo, 'both'] }; + } + return CustomFieldDefinition.find(query).sort('order').lean().exec(); + }) + .then((definitions) => res.status(200).send({ customFields: definitions })) + .catch((err) => handleError(err, res)); +}; + +exports.findOne = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { fieldId } = req.params; + + return CustomFieldDefinition.findById(fieldId).lean().exec() + .then((definition) => { + if (!definition) { + throw new NotFoundError(`No custom field found with id ${fieldId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, definition.team)) { + throw new ForbiddenError('You do not have access to this custom field'); + } + return res.status(200).send(definition); + }) + .catch((err) => handleError(err, res)); +}; + +exports.update = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { fieldId } = req.params; + + /* eslint no-param-reassign: ["error", { "props": false }] */ + return CustomFieldDefinition.findById(fieldId) + .then(async (definition) => { + if (!definition) { + throw new NotFoundError(`No custom field found with id ${fieldId}`); + } + + // Captured before the document is mutated, so the diff sees what was persisted. + const before = definition.toObject(); + + // The key is the storage key: every test case's customFields map and every frozen + // version is keyed by it, so changing it would orphan all existing values. The + // label is what the UI shows and can be changed freely. + if (req.body.key !== undefined && req.body.key !== definition.key) { + throw new InvalidRequestError('A custom field key cannot be changed once created. Create a new field instead.'); + } + + // Changing the type would leave existing values in the old representation, which + // then fail validation on the next save of a case that has never touched this field. + if (req.body.type !== undefined && req.body.type !== definition.type) { + const inUse = await ManualTestCase.countDocuments({ + team: definition.team, + [`customFields.${definition.key}`]: { $exists: true }, + }).exec(); + if (inUse > 0) { + throw new ConflictError(`Cannot change the type of "${definition.label}" because ${inUse} test case(s) already hold a value for it. Archive this field and create a new one instead.`); + } + } + + const nextType = req.body.type === undefined ? definition.type : req.body.type; + if (req.body.options !== undefined || req.body.type !== undefined) { + const nextOptions = req.body.options === undefined ? definition.options : req.body.options; + validateOptions(nextType, nextOptions); + + // Removing an option that existing cases still hold would make those values + // invalid on their next save, which the author of the case cannot fix. + if (optionBackedTypes.includes(nextType) && req.body.options !== undefined) { + const removed = (definition.options || []) + .filter((option) => !req.body.options.includes(option)); + if (removed.length > 0) { + const stillUsed = await ManualTestCase.countDocuments({ + team: definition.team, + [`customFields.${definition.key}`]: { $in: removed }, + }).exec(); + if (stillUsed > 0) { + throw new ConflictError(`Cannot remove option(s) [${removed.join(', ')}] because ${stillUsed} test case(s) still use them.`); + } + } + } + } + + ['label', 'type', 'options', 'required', 'defaultValue', 'order', 'appliesTo', 'archived'] + .forEach((field) => { + if (req.body[field] !== undefined) { + definition[field] = req.body[field]; + } + }); + log(`Updated custom field ${fieldId}`); + const saved = await definition.save(); + const changes = historyUtils.diffDocuments( + before, + saved.toObject(), + historyUtils.TRACKED_PATHS.customfield, + ); + if (changes.length > 0) { + historyUtils.recordChange({ + entityType: 'customfield', + entityId: saved._id, + team: saved.team, + action: 'UPDATE', + changes, + user: req.user ? req.user._id : undefined, + }); + } + return saved; + }) + .then((saved) => res.status(200).send(saved)) + .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 { fieldId } = req.params; + + return CustomFieldDefinition.findById(fieldId) + .then(async (definition) => { + if (!definition) { + throw new NotFoundError(`No custom field found with id ${fieldId}`); + } + + // A field whose values appear anywhere - on a live case or in a frozen version - is + // archived rather than removed. Deleting the definition would leave those values + // with no label, type or options to render them by, which for a frozen version + // means silently losing part of what a tester saw. + const key = `customFields.${definition.key}`; + const [liveUses, versionUses] = await Promise.all([ + ManualTestCase.countDocuments({ + team: definition.team, + [key]: { $exists: true }, + }).exec(), + ManualTestCaseVersion.countDocuments({ + team: definition.team, + [key]: { $exists: true }, + }).exec(), + ]); + + if (liveUses > 0 || versionUses > 0) { + if (definition.archived) { + return { definition, archived: true, alreadyArchived: true }; + } + definition.archived = true; + const saved = await definition.save(); + log(`Archived custom field ${fieldId} (in use by ${liveUses} case(s) and ${versionUses} version(s))`); + historyUtils.recordChange({ + entityType: 'customfield', + entityId: saved._id, + team: saved.team, + action: 'ARCHIVE', + changes: [{ field: 'archived', from: false, to: true }], + user: req.user ? req.user._id : undefined, + comment: `Archived rather than deleted: in use by ${liveUses} test case(s) and ${versionUses} frozen version(s)`, + }); + return { + definition: saved, archived: true, liveUses, versionUses, + }; + } + + await CustomFieldDefinition.findByIdAndRemove(fieldId).exec(); + log(`Deleted unused custom field ${fieldId}`); + historyUtils.recordChange({ + entityType: 'customfield', + entityId: definition._id, + team: definition.team, + action: 'DELETE', + user: req.user ? req.user._id : undefined, + comment: `Deleted unused custom field "${definition.label}"`, + }); + return { archived: false }; + }) + .then((result) => { + if (result.archived) { + const message = result.alreadyArchived + ? 'Custom field is in use and was already archived.' + : 'Custom field is in use by existing test cases and has been archived rather than deleted.'; + return res.status(200).send({ message, archived: true, customField: result.definition }); + } + return res.status(200).send({ message: 'Custom field deleted successfully!', archived: false }); + }) + .catch((err) => handleError(err, res)); +}; diff --git a/app/controllers/manual-folder.controller.js b/app/controllers/manual-folder.controller.js new file mode 100644 index 0000000..8ed4a04 --- /dev/null +++ b/app/controllers/manual-folder.controller.js @@ -0,0 +1,307 @@ +const debug = require('debug'); +const mongoose = require('mongoose'); +const { validationResult } = require('express-validator'); +const ManualFolder = require('../models/manual-folder.js'); +const ManualTestCase = require('../models/manual-test-case.js'); +const { Team } = require('../models/team.js'); +const authMiddleware = require('../utils/auth-middleware.js'); +const manualFolderUtils = require('../utils/manual-folder-utils.js'); +const historyUtils = require('../utils/history-utils.js'); +const { + NotFoundError, + ForbiddenError, + ConflictError, + InvalidRequestError, + handleError, +} = require('../exceptions/errors.js'); + +const log = debug('manual-folder:controller'); + +// A duplicate name under the same parent is a user-facing conflict, not a server fault. +const asConflict = (error, name) => { + if (error && error.code === 11000) { + return new ConflictError(`A folder named "${name}" already exists here.`); + } + return error; +}; + +exports.create = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { + team, name, description, parent, + } = req.body; + + return Team.findById(team).select('_id').lean().exec() + .then(async (teamFound) => { + if (!teamFound) { + throw new NotFoundError(`No team found with id ${team}`); + } + if (!authMiddleware.hasTeamAccess(req.user, teamFound._id)) { + throw new ForbiddenError('You do not have access to this team'); + } + const placement = await manualFolderUtils.resolvePlacement(parent, teamFound._id); + const folder = new ManualFolder({ + team: teamFound._id, + name, + description, + ...placement, + createdBy: req.user ? req.user._id : undefined, + }); + const saved = await folder.save().catch((error) => { throw asConflict(error, name); }); + log(`Created folder ${saved._id} at depth ${saved.depth}`); + return res.status(201).send(saved); + }) + .catch((err) => handleError(err, res)); +}; + +/* +The team's folder tree, with a test case count per folder. + +Returned nested rather than flat: every caller wants the tree, and building it once here +keeps the shape consistent between the UI, the picker and anything else that lists folders. + */ +exports.findAll = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { teamId } = req.query; + + return Team.findById(teamId).select('_id').lean().exec() + .then(async (teamFound) => { + if (!teamFound) { + throw new NotFoundError(`No team found with id ${teamId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, teamId)) { + throw new ForbiddenError('You do not have access to this team'); + } + const [folders, counts, unfiledCount] = await Promise.all([ + ManualFolder.find({ team: teamFound._id }).lean().exec(), + ManualTestCase.aggregate([ + { $match: { team: mongoose.Types.ObjectId(teamId), folder: { $ne: null } } }, + { $group: { _id: '$folder', count: { $sum: 1 } } }, + ]).exec(), + ManualTestCase.countDocuments({ team: teamFound._id, folder: null }).exec(), + ]); + const countsByFolder = {}; + counts.forEach((entry) => { countsByFolder[entry._id.toString()] = entry.count; }); + return res.status(200).send({ + folders: manualFolderUtils.buildTree(folders, countsByFolder), + // Cases that have never been filed still have to be reachable, so the root is + // reported alongside the tree rather than being an implicit empty folder. + unfiledCount, + metrics: { totalFolders: folders.length }, + }); + }) + .catch((err) => handleError(err, res)); +}; + +exports.findOne = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + return ManualFolder.findById(req.params.folderId) + .populate('path', 'name') + .lean() + .exec() + .then(async (folder) => { + if (!folder) { + throw new NotFoundError(`No folder found with id ${req.params.folderId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, folder.team)) { + throw new ForbiddenError('You do not have access to this folder'); + } + const contents = await manualFolderUtils.getContents(folder); + return res.status(200).send({ ...folder, ...contents }); + }) + .catch((err) => handleError(err, res)); +}; + +/* +Renames a folder and/or moves it under a different parent. + +A move rewrites the stored paths of everything beneath it, so the guard against moving a +folder into its own subtree runs first - that would strand the whole branch. + */ +/* eslint no-param-reassign: ["error", { "props": false }] */ +exports.update = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { name, description, parent } = req.body; + + return ManualFolder.findById(req.params.folderId).exec() + .then(async (folder) => { + if (!folder) { + throw new NotFoundError(`No folder found with id ${req.params.folderId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, folder.team)) { + throw new ForbiddenError('You do not have access to this folder'); + } + const before = folder.toObject(); + + if (parent !== undefined) { + const newParentId = parent === null || parent === '' ? null : parent; + const currentParentId = folder.parent ? folder.parent.toString() : null; + if (newParentId !== currentParentId) { + if (newParentId) { + const newParent = await ManualFolder.findById(newParentId).lean().exec(); + if (!newParent || newParent.team.toString() !== folder.team.toString()) { + throw new NotFoundError(`No folder found for this team with id ${newParentId}`); + } + manualFolderUtils.assertMoveIsLegal(folder, newParent); + } + const oldPath = [...(folder.path || [])]; + const placement = await manualFolderUtils.resolvePlacement(newParentId, folder.team); + folder.parent = placement.parent; + folder.path = placement.path; + folder.depth = placement.depth; + // Descendants are rewritten before the folder itself is saved, so a rejected + // depth check leaves the tree exactly as it was. + await manualFolderUtils.rewriteSubtreePaths(folder, oldPath, placement.path); + } + } + if (name !== undefined) folder.name = name; + if (description !== undefined) folder.description = description; + folder.updatedBy = req.user ? req.user._id : undefined; + + const saved = await folder.save() + .catch((error) => { throw asConflict(error, name || folder.name); }); + + const changes = historyUtils.diffDocuments( + before, + saved.toObject(), + ['name', 'description', 'parent'], + ); + if (changes.length > 0) { + historyUtils.recordChange({ + entityType: 'folder', + entityId: saved._id, + team: saved.team, + action: 'UPDATE', + changes, + user: req.user, + }); + } + return res.status(200).send(saved); + }) + .catch((err) => handleError(err, res)); +}; + +/* +Deletes an empty folder. + +Refused with a 409 while anything is still inside, naming the counts. The alternatives - +re-parenting the contents or cascading the delete - both rearrange or destroy work in one +click with no warning, and a test case carries execution history that should not disappear +because a folder was tidied up. + */ +exports.delete = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + return ManualFolder.findById(req.params.folderId).lean().exec() + .then(async (folder) => { + if (!folder) { + throw new NotFoundError(`No folder found with id ${req.params.folderId}`); + } + if (!authMiddleware.hasTeamLeadAccess(req.user, folder.team)) { + throw new ForbiddenError('You do not have permission to delete folders for this team'); + } + const { testCaseCount, subFolderCount } = await manualFolderUtils.getContents(folder); + if (testCaseCount > 0 || subFolderCount > 0) { + const parts = []; + if (testCaseCount > 0) parts.push(`${testCaseCount} test case(s)`); + if (subFolderCount > 0) parts.push(`${subFolderCount} sub-folder(s)`); + throw new ConflictError(`Folder "${folder.name}" still contains ${parts.join(' and ')}. Move or delete them first.`); + } + await ManualFolder.deleteOne({ _id: folder._id }).exec(); + historyUtils.recordChange({ + entityType: 'folder', + entityId: folder._id, + team: folder.team, + action: 'DELETE', + changes: [{ field: 'name', from: folder.name, to: undefined }], + user: req.user, + }); + return res.status(200).send({ message: 'Folder deleted successfully.' }); + }) + .catch((err) => handleError(err, res)); +}; + +/* +Files a set of test cases into a folder (or to the root, with a null folder). + +Bulk rather than one call per case because moving is how a team reorganises - doing it a +case at a time would be a request per row and a partially-moved selection on any failure. + */ +exports.moveTestCases = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { testCaseIds, folder } = req.body; + + return ManualTestCase.find({ _id: { $in: testCaseIds } }).select('_id team folder title').lean() + .exec() + .then(async (testCases) => { + if (testCases.length !== testCaseIds.length) { + const found = new Set(testCases.map((testCase) => testCase._id.toString())); + const missing = testCaseIds.filter((id) => !found.has(id.toString())); + throw new NotFoundError(`No manual test case found with id(s): ${missing.join(', ')}`); + } + const teams = new Set(testCases.map((testCase) => testCase.team.toString())); + if (teams.size > 1) { + throw new InvalidRequestError('All test cases in a move must belong to the same team.'); + } + const [team] = Array.from(teams); + if (!authMiddleware.hasTeamAccess(req.user, team)) { + throw new ForbiddenError('You do not have access to this team'); + } + + let target = null; + if (folder) { + const targetFolder = await ManualFolder.findById(folder).lean().exec(); + if (!targetFolder || targetFolder.team.toString() !== team) { + throw new NotFoundError(`No folder found for this team with id ${folder}`); + } + target = targetFolder._id; + } + + await ManualTestCase.updateMany( + { _id: { $in: testCaseIds } }, + { $set: { folder: target, updatedBy: req.user ? req.user._id : undefined } }, + ).exec(); + + // Filing is not content, so it burns no version - but "who moved this, and when?" + // is still a question the history exists to answer. + testCases.forEach((testCase) => { + historyUtils.recordChange({ + entityType: 'testcase', + entityId: testCase._id, + team: testCase.team, + action: 'MOVE', + changes: [{ + field: 'folder', + from: testCase.folder || null, + to: target, + }], + user: req.user, + }); + }); + + log(`Moved ${testCases.length} test case(s) to folder ${target || 'root'}`); + return res.status(200).send({ + message: `Moved ${testCases.length} test case(s).`, + moved: testCases.length, + folder: target, + }); + }) + .catch((err) => handleError(err, res)); +}; diff --git a/app/controllers/manual-test-case.controller.js b/app/controllers/manual-test-case.controller.js new file mode 100644 index 0000000..d181e8c --- /dev/null +++ b/app/controllers/manual-test-case.controller.js @@ -0,0 +1,672 @@ +const { validationResult } = require('express-validator'); +const mongoose = require('mongoose'); +const debug = require('debug'); + +const ManualTestCase = require('../models/manual-test-case.js'); +const ManualTestCaseVersion = require('../models/manual-test-case-version.js'); +const { Team } = require('../models/team.js'); +const ManualFolder = require('../models/manual-folder.js'); +const SharedStep = require('../models/shared-step.js'); +const Attachment = require('../models/attachment.js'); +const manualTestCaseUtils = require('../utils/manual-test-case-utils.js'); +const manualStepUtils = require('../utils/manual-step-utils.js'); +const customFieldUtils = require('../utils/custom-field-utils.js'); +const attachmentUtils = require('../utils/attachment-utils.js'); +const historyUtils = require('../utils/history-utils.js'); +const validationUtils = require('../utils/validation-utils.js'); +const authMiddleware = require('../utils/auth-middleware.js'); +const { + NotFoundError, + ForbiddenError, + handleError, +} = require('../exceptions/errors.js'); + +const log = debug('manual-test-case:controller'); + +// Required custom fields are enforced for any status other than DRAFT, so a half-written +// draft can always be saved but a case that is published carries everything it must. +// +// An absent status means the schema default (DRAFT) will apply, so it must not be treated +// as "some other status" - doing so would enforce required fields on exactly the +// create-a-draft case this rule exists to exempt. +const DEFAULT_STATUS = 'DRAFT'; +const shouldEnforceRequired = (status) => (status || DEFAULT_STATUS) !== DEFAULT_STATUS; + +// Steps arrive from the client without a guaranteed order value. Normalising here means +// the stored order always matches the array order the author sees, and the frozen version +// is written with the same ordering. +/* +Rejects a folder that does not exist or belongs to another team. + +A cross-team folder reference would file one team's test case inside another team's tree, +where it would be listed by a team that should not see it at all. + */ +const resolveFolder = async (folderId, teamId) => { + if (folderId === undefined || folderId === null || folderId === '') return null; + const folder = await ManualFolder.findById(folderId).select('_id team').lean().exec(); + if (!folder || folder.team.toString() !== teamId.toString()) { + throw new NotFoundError(`No folder found for this team with id ${folderId}`); + } + return folder._id; +}; + +const normaliseSteps = (steps) => (steps || []).map((step, index) => ({ + ...step, + order: step.order === undefined ? index + 1 : step.order, +})); + +/* +Rejects an attachment reference that does not exist or belongs to another team. + +Same reasoning as the shared step check below: a dangling attachment renders as a broken +image in the middle of a test a QA is trying to follow, and a cross-team reference would +expose one team's screenshots inside another team's case. + */ +const validateAttachmentReferences = async (steps, teamId) => { + const ids = Array.from(new Set( + (steps || []).flatMap((step) => (step.attachments || []).map((id) => id.toString())), + )); + if (ids.length === 0) return; + const found = await Attachment.find({ _id: { $in: ids }, team: teamId }) + .select('_id') + .lean() + .exec(); + const foundIds = new Set(found.map((attachment) => attachment._id.toString())); + const missing = ids.filter((id) => !foundIds.has(id)); + if (missing.length > 0) { + throw new NotFoundError(`No attachment found for this team with id(s): ${missing.join(', ')}`); + } +}; + +/* +Rejects a shared step reference that does not exist or belongs to another team. + +Caught at write time rather than at expansion: a dangling reference expands to an +unresolved placeholder, which silently costs the case its steps. A cross-team reference +would additionally leak one team's content into another's test case. + */ +const validateSharedStepReferences = async (steps, teamId) => { + const ids = manualStepUtils.collectSharedStepIds(steps); + if (ids.length === 0) return; + const found = await SharedStep.find({ _id: { $in: ids }, team: teamId }) + .select('_id') + .lean() + .exec(); + const foundIds = new Set(found.map((sharedStep) => sharedStep._id.toString())); + const missing = ids.filter((id) => !foundIds.has(id)); + if (missing.length > 0) { + throw new NotFoundError(`No shared step found for this team with id(s): ${missing.join(', ')}`); + } +}; + +exports.create = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { + team, + component, + title, + description, + preconditions, + status, + priority, + tags, + steps, + customFields, + folder, + } = req.body; + + return Team.findById(team).lean().exec() + .then(async (teamFound) => { + if (!teamFound) { + throw new NotFoundError(`No team found with id ${team}`); + } + if (!authMiddleware.hasTeamAccess(req.user, teamFound._id)) { + throw new ForbiddenError('You do not have access to this team'); + } + if (component) { + const matchComponent = teamFound.components + .find((teamComponent) => teamComponent._id.toString() === component.toString()); + if (!matchComponent) { + throw new NotFoundError(`No component found with id ${component} for team ${teamFound.name}`); + } + } + + const folderId = await resolveFolder(folder, teamFound._id); + + await validateSharedStepReferences(steps, teamFound._id); + await validateAttachmentReferences(steps, teamFound._id); + + // Values are validated and coerced against the team's configured fields before the + // case is built, so nothing invalid ever reaches a frozen version. + const definitions = await customFieldUtils.getDefinitionsForTeam(teamFound._id); + const validatedFields = await customFieldUtils.validateCustomFields( + definitions, + customFields, + shouldEnforceRequired(status), + ); + + const testCase = new ManualTestCase({ + team: teamFound._id, + component, + folder: folderId, + title, + description, + preconditions, + status, + priority, + tags, + steps: normaliseSteps(steps), + customFields: validatedFields, + version: 1, + createdBy: req.user ? req.user._id : undefined, + updatedBy: req.user ? req.user._id : undefined, + }); + + // The version is written first so a crash between the two writes leaves an orphan + // version rather than a head pointing at a version that does not exist. + // + // Steps are expanded before freezing: a version must never hold a shared-step + // reference, or a later edit to that shared step would rewrite what this version + // shows. + const snapshot = customFieldUtils.buildDefinitionSnapshot(definitions, validatedFields); + const expandedSteps = await manualStepUtils.expandStepsFor(testCase.steps); + // Validated before the version is written: a version that lands for a head that then + // fails to save is an orphan the unique { testCase, version } index later collides + // with. Same ordering as update, for the same reason. + await testCase.validate(); + await manualTestCaseUtils + .saveVersion(testCase, testCase.createdBy, snapshot, expandedSteps); + const savedCase = await testCase.save(); + log(`Created manual test case "${savedCase.title}" (v1) with id ${savedCase._id}`); + // Not awaited: the case is saved, and losing an audit line must never fail the + // request that produced it. + historyUtils.recordChange({ + entityType: 'testcase', + entityId: savedCase._id, + team: savedCase.team, + action: 'CREATE', + version: savedCase.version, + user: savedCase.createdBy, + comment: req.body.comment, + }); + return savedCase; + }) + .then((savedCase) => ManualTestCase.findById(savedCase._id) + .populate('team') + .populate('createdBy', 'username') + .populate('updatedBy', 'username') + .lean() + .exec()) + .then((savedCase) => res.status(201).send(savedCase)) + .catch((err) => handleError(err, res)); +}; + +exports.findAll = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { + teamId, + status, + tags, + search, + priority, + folder, + includeSubFolders, + } = req.query; + const limit = parseInt(req.query.limit, 10) || 25; + const skip = parseInt(req.query.skip, 10) || 0; + + return Team.findById(teamId).select('_id').lean().exec() + .then(async (teamFound) => { + if (!teamFound) { + throw new NotFoundError(`No team found with id ${teamId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, teamId)) { + throw new ForbiddenError('You do not have access to this team'); + } + const query = { team: mongoose.Types.ObjectId(teamId) }; + if (status) query.status = { $in: status.split(',') }; + if (priority) query.priority = { $in: priority.split(',') }; + if (tags) query.tags = { $all: tags.split(',').map((tag) => tag.trim().toLowerCase()) }; + if (folder !== undefined) { + if (folder === 'none' || folder === '') { + // Cases that have never been filed. Distinct from "no folder filter at all". + query.folder = null; + } else if (includeSubFolders === 'true') { + // Browsing a branch shows everything beneath it, which is what a folder tree + // implies - otherwise a parent looks empty while its children hold the cases. + const descendants = await ManualFolder + .find({ team: teamFound._id, path: folder }) + .select('_id') + .lean() + .exec(); + query.folder = { + $in: [ + mongoose.Types.ObjectId(folder), + ...descendants.map((entry) => entry._id), + ], + }; + } else { + query.folder = mongoose.Types.ObjectId(folder); + } + } + if (search) { + // Interpolated into a $regex, so it must be escaped - an unescaped pattern is a + // denial of service against the database and can widen the match. + const escaped = validationUtils.escapeRegex(search); + query.$or = [ + { title: { $regex: escaped, $options: 'i' } }, + { description: { $regex: escaped, $options: 'i' } }, + ]; + } + log(`QUERY: ${JSON.stringify(query)}`); + const promises = [ + ManualTestCase.find(query, null, { limit, skip }) + .populate('createdBy', 'username') + .populate('updatedBy', 'username') + // Folder first so a paged read returns whole folders together rather than + // interleaving them. The UI groups a page into folder sections, and a purely + // recency-ordered page would scatter one folder's cases across several pages, + // showing the same folder heading on each. Recency still orders within a folder. + .sort({ folder: 1, updatedAt: -1 }) + .lean() + .exec(), + ManualTestCase.countDocuments(query).exec(), + ]; + return Promise.all(promises); + }) + .then((results) => res.status(200).send({ + testCases: results[0], + metrics: { totalTestCases: results[1] }, + })) + .catch((err) => handleError(err, res)); +}; + +exports.findOne = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { caseId } = req.params; + return ManualTestCase.findById(caseId) + .populate('team') + .populate('createdBy', 'username') + .populate('updatedBy', 'username') + .lean() + .exec() + .then(async (testCase) => { + if (!testCase) { + throw new NotFoundError(`No manual test case found with id ${caseId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, testCase.team._id)) { + throw new ForbiddenError('You do not have access to this test case'); + } + // The head stores shared steps as placeholders. `expand=true` resolves them into + // the steps a tester would actually follow - what the authoring UI renders as a + // preview, and what the next frozen version will contain. + if (req.query.expand === 'true') { + return res.status(200).send({ + ...testCase, + steps: await manualStepUtils.expandStepsFor(testCase.steps), + expanded: true, + }); + } + return res.status(200).send(testCase); + }) + .catch((err) => handleError(err, res)); +}; + +exports.update = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { caseId } = req.params; + + /* eslint no-param-reassign: ["error", { "props": false }] */ + return ManualTestCase.findById(caseId) + .then(async (testCase) => { + if (!testCase) { + throw new NotFoundError(`No manual test case found with id ${caseId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, testCase.team)) { + throw new ForbiddenError('You do not have access to this test case'); + } + + if (req.body.steps !== undefined) { + await validateSharedStepReferences(req.body.steps, testCase.team); + await validateAttachmentReferences(req.body.steps, testCase.team); + } + + // Captured before anything is mutated, so the diff compares against what was + // actually persisted rather than the half-updated document. + const before = testCase.toObject(); + + const definitions = await customFieldUtils.getDefinitionsForTeam(testCase.team); + + // The status the case will end up with decides whether required fields are + // enforced - publishing a draft has to satisfy them, editing a draft does not. + const resultingStatus = req.body.status === undefined ? testCase.status : req.body.status; + + // Custom fields are validated before the content comparison so the comparison sees + // coerced values. Without this a client re-sending an unchanged date as a string + // would compare unequal to the stored Date and burn a version for no change. + let comparableBody = req.body; + if (req.body.customFields !== undefined) { + const validatedFields = await customFieldUtils.validateCustomFields( + definitions, + req.body.customFields, + shouldEnforceRequired(resultingStatus), + ); + comparableBody = { ...req.body, customFields: validatedFields }; + } else if (shouldEnforceRequired(resultingStatus) + && !shouldEnforceRequired(testCase.status)) { + // Publishing without restating the custom fields still has to satisfy the + // required ones, so re-validate what is already stored. + await customFieldUtils.validateCustomFields( + definitions, + Object.fromEntries(testCase.customFields || new Map()), + true, + ); + } + + // Decided before mutating the document, against the values as they were persisted. + const contentChanged = manualTestCaseUtils.hasContentChanged(testCase, comparableBody); + + const updatable = [ + 'title', + 'description', + 'preconditions', + 'status', + 'priority', + 'tags', + 'customFields', + 'component', + ]; + // Deliberately handled outside `updatable` and outside CONTENT_FIELDS: filing a case + // is organisation, not content, so it burns no version and never reaches a frozen + // version document. It is still audited, via TRACKED_PATHS below. + if (req.body.folder !== undefined) { + testCase.folder = await resolveFolder(req.body.folder, testCase.team); + } + updatable.forEach((field) => { + if (comparableBody[field] !== undefined) { + testCase[field] = comparableBody[field]; + } + }); + if (req.body.steps !== undefined) { + testCase.steps = normaliseSteps(req.body.steps); + } + testCase.updatedBy = req.user ? req.user._id : undefined; + + let savedCase; + if (contentChanged) { + // A content change burns a version; a status-only edit (or an edit that changes + // nothing) deliberately does not. + testCase.version += 1; + // Recovers a case whose head fell behind its versions - see reconcileVersion. + await manualTestCaseUtils.reconcileVersion(testCase); + const snapshot = customFieldUtils + .buildDefinitionSnapshot(definitions, testCase.customFields); + const expandedSteps = await manualStepUtils.expandStepsFor(testCase.steps); + + // The head is validated before the version is written. Writing the version first + // and then failing to save the head leaves an orphan version at the new number, + // and because { testCase, version } is unique the next edit collides with it - + // wedging the case permanently. Validating first turns that into a clean 422 with + // nothing written. + await testCase.validate(); + await manualTestCaseUtils + .saveVersion(testCase, testCase.updatedBy, snapshot, expandedSteps); + log(`Manual test case ${caseId} content changed, wrote version ${testCase.version}`); + savedCase = await testCase.save(); + } else { + log(`Manual test case ${caseId} updated without a content change, staying on version ${testCase.version}`); + savedCase = await testCase.save(); + } + + // A status transition is audited even though it burns no version - "who published + // this?" is exactly the sort of question history exists to answer, and the version + // collection cannot express it. + const statusChanged = before.status !== savedCase.status; + const changes = historyUtils.diffDocuments( + before, + savedCase.toObject(), + historyUtils.TRACKED_PATHS.testcase, + ); + if (changes.length > 0) { + historyUtils.recordChange({ + entityType: 'testcase', + entityId: savedCase._id, + team: savedCase.team, + action: statusChanged && !contentChanged ? 'STATUS_CHANGE' : 'UPDATE', + version: savedCase.version, + changes, + user: savedCase.updatedBy, + comment: req.body.comment, + }); + } + return savedCase; + }) + .then((savedCase) => ManualTestCase.findById(savedCase._id) + .populate('team') + .populate('createdBy', 'username') + .populate('updatedBy', 'username') + .lean() + .exec()) + .then((savedCase) => res.status(200).send(savedCase)) + .catch((err) => handleError(err, res)); +}; + +exports.clone = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { caseId } = req.params; + const { title } = req.body; + + return ManualTestCase.findById(caseId).lean().exec() + .then(async (testCase) => { + if (!testCase) { + throw new NotFoundError(`No manual test case found with id ${caseId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, testCase.team)) { + throw new ForbiddenError('You do not have access to this test case'); + } + // The clone starts its own version history at 1 - it is a new test case, not + // another version of the one it was copied from. Steps are copied without their + // _ids so the clone's steps are distinct documents. + const clonedSteps = (testCase.steps || []).map((step) => { + const rest = { ...step }; + delete rest._id; + return rest; + }); + const clonedCase = new ManualTestCase({ + team: testCase.team, + component: testCase.component, + // A clone lands beside its source; that is where the author is looking. + folder: testCase.folder, + title: title || `${testCase.title} (copy)`, + description: testCase.description, + preconditions: testCase.preconditions, + // A clone always starts as a draft regardless of the source's status. + status: 'DRAFT', + priority: testCase.priority, + tags: testCase.tags, + steps: clonedSteps, + customFields: testCase.customFields, + version: 1, + createdBy: req.user ? req.user._id : undefined, + updatedBy: req.user ? req.user._id : undefined, + }); + // The clone carries the source's custom field values, so its first version needs + // the same definition snapshot to render them. + const definitions = await customFieldUtils.getDefinitionsForTeam(testCase.team); + const snapshot = customFieldUtils + .buildDefinitionSnapshot(definitions, testCase.customFields); + const expandedSteps = await manualStepUtils.expandStepsFor(clonedCase.steps); + await manualTestCaseUtils + .saveVersion(clonedCase, clonedCase.createdBy, snapshot, expandedSteps); + const savedClone = await clonedCase.save(); + log(`Cloned manual test case ${caseId} into ${savedClone._id}`); + historyUtils.recordChange({ + entityType: 'testcase', + entityId: savedClone._id, + team: savedClone.team, + action: 'CLONE', + version: savedClone.version, + user: savedClone.createdBy, + comment: req.body.comment || `Cloned from test case ${caseId}`, + }); + return savedClone; + }) + .then((savedClone) => ManualTestCase.findById(savedClone._id) + .populate('team') + .populate('createdBy', 'username') + .lean() + .exec()) + .then((savedClone) => res.status(201).send(savedClone)) + .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 { caseId } = req.params; + + return ManualTestCase.findById(caseId) + .then(async (testCase) => { + if (!testCase) { + throw new NotFoundError(`No manual test case found with id ${caseId}`); + } + if (!authMiddleware.hasTeamLeadAccess(req.user, testCase.team)) { + throw new ForbiddenError('You do not have permission to delete this test case'); + } + // Deleting the case removes its version history too - the immutability rule protects + // versions from being *rewritten*, not from the whole case being removed. Once + // manual runs exist (phase 6) this needs a guard rejecting deletion of a case with + // executions bound to it; there is nothing to bind yet. + const versionsRemoved = await ManualTestCaseVersion + .deleteMany({ testCase: testCase._id }) + .exec(); + // Versions are removed first, so the reference check that protects an attachment + // referenced by a frozen version no longer applies - the thing referencing it has + // gone. Files and documents are collected together. + const attachmentsRemoved = await attachmentUtils + .removeAttachmentsForOwner('testcase', testCase._id); + log(`Deleting manual test case ${caseId} along with ${versionsRemoved.deletedCount} version(s) and ${attachmentsRemoved} attachment(s).`); + // Recorded before the removal, while the team is still readable from the document. + // The entry outlives the case on purpose: "what happened to that test case?" is + // only answerable if the deletion itself is logged. + historyUtils.recordChange({ + entityType: 'testcase', + entityId: testCase._id, + team: testCase.team, + action: 'DELETE', + version: testCase.version, + user: req.user ? req.user._id : undefined, + comment: req.body.comment || `Deleted "${testCase.title}"`, + }); + return ManualTestCase.findByIdAndRemove(caseId); + }) + .then((testCase) => { + if (!testCase) { + throw new NotFoundError(`No manual test case found with id ${caseId}`); + } + return res.status(200).send({ message: 'Manual test case deleted successfully!' }); + }) + .catch((err) => handleError(err, res)); +}; + +exports.findHistory = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { caseId } = req.params; + const limit = parseInt(req.query.limit, 10) || 20; + const skip = parseInt(req.query.skip, 10) || 0; + + return ManualTestCase.findById(caseId).select('_id team').lean().exec() + .then((testCase) => { + if (!testCase) { + throw new NotFoundError(`No manual test case found with id ${caseId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, testCase.team)) { + throw new ForbiddenError('You do not have access to this test case'); + } + return historyUtils.findHistory(testCase._id, limit, skip); + }) + .then(({ entries, count }) => res.status(200).send({ + history: entries, + metrics: { totalEntries: count }, + })) + .catch((err) => handleError(err, res)); +}; + +exports.findVersions = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { caseId } = req.params; + + return ManualTestCase.findById(caseId).lean().exec() + .then((testCase) => { + if (!testCase) { + throw new NotFoundError(`No manual test case found with id ${caseId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, testCase.team)) { + throw new ForbiddenError('You do not have access to this test case'); + } + // Metadata only - the full frozen content is served per version by findVersion. + return ManualTestCaseVersion.find({ testCase: testCase._id }) + .select('version title createdAt createdBy') + .populate('createdBy', 'username') + .sort('-version') + .lean() + .exec(); + }) + .then((versions) => res.status(200).send({ versions })) + .catch((err) => handleError(err, res)); +}; + +exports.findVersion = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { caseId, version } = req.params; + const versionNumber = parseInt(version, 10); + + return ManualTestCase.findById(caseId).lean().exec() + .then(async (testCase) => { + if (!testCase) { + throw new NotFoundError(`No manual test case found with id ${caseId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, testCase.team)) { + throw new ForbiddenError('You do not have access to this test case'); + } + const frozenVersion = await manualTestCaseUtils.getVersion(testCase, versionNumber); + if (frozenVersion === null) { + // No version documents at all: pre-migration data, so the head is the only + // content available. Flagged in the response so a caller can tell this apart from + // a genuine frozen version. + return { ...testCase, version: testCase.version, unversioned: true }; + } + if (!frozenVersion) { + throw new NotFoundError(`No version ${versionNumber} found for manual test case ${caseId}`); + } + return frozenVersion; + }) + .then((frozenVersion) => res.status(200).send(frozenVersion)) + .catch((err) => handleError(err, res)); +}; diff --git a/app/controllers/manual-test-run.controller.js b/app/controllers/manual-test-run.controller.js new file mode 100644 index 0000000..ee827f8 --- /dev/null +++ b/app/controllers/manual-test-run.controller.js @@ -0,0 +1,462 @@ +const { validationResult } = require('express-validator'); +const mongoose = require('mongoose'); +const debug = require('debug'); + +const ManualTestRun = require('../models/manual-test-run.js'); +const ManualTestCase = require('../models/manual-test-case.js'); +const ManualTestCaseVersion = require('../models/manual-test-case-version.js'); +const TestExecution = require('../models/execution.js'); +const Build = require('../models/build.js'); +const { Team } = require('../models/team.js'); +const Environment = require('../models/environment.js'); +const Phase = require('../models/phase.js'); +const manualTestRunUtils = require('../utils/manual-test-run-utils.js'); +const buildMetricsUtils = require('../utils/build-utils.js'); +const authMiddleware = require('../utils/auth-middleware.js'); +const { + NotFoundError, + ForbiddenError, + ConflictError, + InvalidRequestError, + handleError, +} = require('../exceptions/errors.js'); + +const log = debug('manual-test-run:controller'); + +const RUN_POPULATE = [ + { path: 'team' }, + { path: 'environment' }, + { path: 'phase' }, + { path: 'assignedTo', select: 'username' }, + { path: 'createdBy', select: 'username' }, + { path: 'testCases.executedBy', select: 'username' }, +]; + +const populateRun = (runId) => { + const query = ManualTestRun.findById(runId); + RUN_POPULATE.forEach((populate) => query.populate(populate)); + return query.lean().exec(); +}; + +exports.create = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { + name, + description, + team, + component, + environment, + phase, + assignedTo, + platforms, + testCaseIds, + } = req.body; + + return Promise.all([ + Team.findById(team).lean().exec(), + Environment.findOne({ name: environment }).lean().exec(), + phase ? Phase.findOne({ name: phase }).lean().exec() : Promise.resolve(null), + ]) + .then(async ([teamFound, environmentFound, phaseFound]) => { + if (!teamFound) { + throw new NotFoundError(`No team found with id ${team}`); + } + if (!authMiddleware.hasTeamAccess(req.user, teamFound._id)) { + throw new ForbiddenError('You do not have access to this team'); + } + if (!environmentFound) { + throw new NotFoundError(`No environment found with name ${environment}`); + } + if (phase && !phaseFound) { + throw new NotFoundError(`No phase found with name ${phase}`); + } + + let matchComponent; + if (component) { + matchComponent = teamFound.components + .find((teamComponent) => teamComponent._id.toString() === component.toString()); + if (!matchComponent) { + throw new NotFoundError(`No component found with id ${component} for team ${teamFound.name}`); + } + } else { + // The backing build requires a component, so fall back to the team's first one + // rather than refusing a run the caller did not think to scope. + [matchComponent] = teamFound.components; + if (!matchComponent) { + throw new InvalidRequestError(`Team ${teamFound.name} has no components to run against.`); + } + } + + // Bound to each case's current frozen version. Everything downstream reads the + // version, never the mutable head. + const boundCases = await manualTestRunUtils + .bindTestCases(testCaseIds || [], teamFound._id, matchComponent._id); + + const start = new Date(); + // The build is created up front so executions have somewhere to land, and is tagged + // manual so every existing dashboard and metrics query counts it correctly. + const build = new Build({ + name: name.toLowerCase(), + team: teamFound._id, + environment: environmentFound._id, + component: matchComponent._id, + phase: phaseFound ? phaseFound._id : undefined, + suites: [], + start, + executionType: 'manual', + result: new Map(buildMetricsUtils.defaultResultMap), + status: buildMetricsUtils.executionStates[0], + }); + const savedBuild = await build.save(); + + const run = new ManualTestRun({ + name, + description, + team: teamFound._id, + component: matchComponent._id, + environment: environmentFound._id, + phase: phaseFound ? phaseFound._id : undefined, + build: savedBuild._id, + status: 'PLANNED', + assignedTo, + platforms, + testCases: boundCases, + start, + createdBy: req.user ? req.user._id : undefined, + }); + + try { + const savedRun = await run.save(); + log(`Created manual test run "${name}" with ${boundCases.length} case(s), build ${savedBuild._id}`); + return savedRun; + } catch (error) { + // Don't leave a manual build behind with no run pointing at it - it would show on + // the dashboard as an empty run nobody can complete. + await Build.deleteOne({ _id: savedBuild._id }).exec(); + throw error; + } + }) + .then((savedRun) => populateRun(savedRun._id)) + .then((savedRun) => res.status(201).send(savedRun)) + .catch((err) => handleError(err, res)); +}; + +exports.findAll = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { teamId, status, assignedTo } = req.query; + const limit = parseInt(req.query.limit, 10) || 25; + const skip = parseInt(req.query.skip, 10) || 0; + + return Team.findById(teamId).select('_id').lean().exec() + .then((teamFound) => { + if (!teamFound) { + throw new NotFoundError(`No team found with id ${teamId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, teamId)) { + throw new ForbiddenError('You do not have access to this team'); + } + const query = { team: mongoose.Types.ObjectId(teamId) }; + if (status) query.status = { $in: status.split(',') }; + if (assignedTo) query.assignedTo = mongoose.Types.ObjectId(assignedTo); + + return Promise.all([ + ManualTestRun.find(query, null, { limit, skip }) + .populate('environment') + .populate('phase') + .populate('assignedTo', 'username') + .populate('createdBy', 'username') + .sort('-createdAt') + .lean() + .exec(), + ManualTestRun.countDocuments(query).exec(), + ]); + }) + .then(([runs, count]) => res.status(200).send({ + testRuns: runs, + metrics: { totalTestRuns: count }, + })) + .catch((err) => handleError(err, res)); +}; + +exports.findOne = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { runId } = req.params; + + return populateRun(runId) + .then(async (run) => { + if (!run) { + throw new NotFoundError(`No manual test run found with id ${runId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, run.team._id)) { + throw new ForbiddenError('You do not have access to this test run'); + } + // The tester needs the steps, and they must come from the bound version rather than + // the case - that is the whole point of the binding. + if (req.query.expand === 'true') { + const versionIds = run.testCases.map((runCase) => runCase.testCaseVersion); + const versions = await ManualTestCaseVersion + .find({ _id: { $in: versionIds } }) + .lean() + .exec(); + const byId = new Map(versions.map((version) => [version._id.toString(), version])); + return { + ...run, + testCases: run.testCases.map((runCase) => ({ + ...runCase, + version: byId.get(runCase.testCaseVersion.toString()), + })), + expanded: true, + }; + } + return run; + }) + .then((run) => res.status(200).send(run)) + .catch((err) => handleError(err, res)); +}; + +exports.update = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { runId } = req.params; + + /* eslint no-param-reassign: ["error", { "props": false }] */ + return ManualTestRun.findById(runId) + .then(async (run) => { + if (!run) { + throw new NotFoundError(`No manual test run found with id ${runId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, run.team)) { + throw new ForbiddenError('You do not have access to this test run'); + } + + ['name', 'description', 'assignedTo', 'platforms'].forEach((field) => { + if (req.body[field] !== undefined) { + run[field] = req.body[field]; + } + }); + + // Cases can be added to a run in flight; each is bound to its current version at + // the moment it is added, not the moment the run was created. + if (req.body.testCaseIds !== undefined) { + const existingIds = new Set(run.testCases.map((runCase) => runCase.testCase.toString())); + const newIds = req.body.testCaseIds + .map((id) => id.toString()) + .filter((id) => !existingIds.has(id)); + if (newIds.length > 0) { + const bound = await manualTestRunUtils + .bindTestCases(newIds, run.team, run.component); + bound.forEach((runCase) => run.testCases.push(runCase)); + } + } + + run.status = manualTestRunUtils.deriveStatus(run); + return run.save(); + }) + .then((savedRun) => populateRun(savedRun._id)) + .then((savedRun) => res.status(200).send(savedRun)) + .catch((err) => handleError(err, res)); +}; + +exports.recordResult = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { runId, caseId } = req.params; + const { status, stepResults, notes } = req.body; + + /* eslint no-param-reassign: ["error", { "props": false }] */ + return ManualTestRun.findById(runId) + .then(async (run) => { + if (!run) { + throw new NotFoundError(`No manual test run found with id ${runId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, run.team)) { + throw new ForbiddenError('You do not have access to this test run'); + } + if (run.status === 'CANCELLED') { + throw new ConflictError('Cannot record a result against a cancelled test run.'); + } + + const runCase = run.testCases + .find((entry) => entry.testCase.toString() === caseId.toString()); + if (!runCase) { + throw new NotFoundError(`Test case ${caseId} is not part of this test run`); + } + + // Read from the bound version, never the head - the tester executed this content. + const version = await ManualTestCaseVersion + .findById(runCase.testCaseVersion) + .lean() + .exec(); + if (!version) { + throw new NotFoundError(`The frozen version bound to test case ${caseId} no longer exists.`); + } + + if (!runCase.start) { + runCase.start = new Date(); + } + if (stepResults !== undefined) { + runCase.stepResults = stepResults; + } + if (notes !== undefined) { + runCase.notes = notes; + } + runCase.status = status; + runCase.executedBy = req.user ? req.user._id : undefined; + if (status !== 'IN_PROGRESS' && status !== 'NOT_RUN') { + runCase.end = new Date(); + } + + // Written through the same path automated results take, so the build's result map + // and status are computed identically for both. + const execution = await manualTestRunUtils.recordExecution({ + run, + runCase, + version, + status, + user: req.user ? req.user._id : undefined, + }); + runCase.execution = execution._id; + + run.status = manualTestRunUtils.deriveStatus(run); + if (run.status === 'COMPLETED' && !run.end) { + run.end = new Date(); + } + return run.save(); + }) + .then((savedRun) => populateRun(savedRun._id)) + .then((savedRun) => res.status(200).send(savedRun)) + .catch((err) => handleError(err, res)); +}; + +/* +Moves a not-yet-run case onto the latest version of its test case. + +Refused once a result has been recorded: re-pointing an execution at content it was never +run against is exactly the silent history rewrite the version binding exists to prevent. + */ +exports.rebind = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { runId, caseId } = req.params; + + /* eslint no-param-reassign: ["error", { "props": false }] */ + return ManualTestRun.findById(runId) + .then(async (run) => { + if (!run) { + throw new NotFoundError(`No manual test run found with id ${runId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, run.team)) { + throw new ForbiddenError('You do not have access to this test run'); + } + const runCase = run.testCases + .find((entry) => entry.testCase.toString() === caseId.toString()); + if (!runCase) { + throw new NotFoundError(`Test case ${caseId} is not part of this test run`); + } + if (runCase.status !== 'NOT_RUN' || runCase.execution) { + throw new ConflictError('Cannot rebind a test case that already has a recorded result. Add the test case to a new run instead.'); + } + + const testCase = await ManualTestCase.findById(caseId).lean().exec(); + if (!testCase) { + throw new NotFoundError(`No manual test case found with id ${caseId}`); + } + if (testCase.version === runCase.versionNumber) { + throw new ConflictError(`Test case is already bound to its latest version (v${runCase.versionNumber}).`); + } + + const [bound] = await manualTestRunUtils + .bindTestCases([caseId], run.team, run.component); + runCase.testCaseVersion = bound.testCaseVersion; + runCase.versionNumber = bound.versionNumber; + runCase.snapshotTitle = bound.snapshotTitle; + // Results recorded against the old version's step ids would be meaningless here. + runCase.stepResults = []; + log(`Rebound case ${caseId} in run ${runId} to v${bound.versionNumber}`); + return run.save(); + }) + .then((savedRun) => populateRun(savedRun._id)) + .then((savedRun) => res.status(200).send(savedRun)) + .catch((err) => handleError(err, res)); +}; + +exports.updateStatus = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { runId } = req.params; + const { status } = req.body; + + /* eslint no-param-reassign: ["error", { "props": false }] */ + return ManualTestRun.findById(runId) + .then(async (run) => { + if (!run) { + throw new NotFoundError(`No manual test run found with id ${runId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, run.team)) { + throw new ForbiddenError('You do not have access to this test run'); + } + run.status = status; + if (status === 'COMPLETED' || status === 'CANCELLED') { + run.end = run.end || new Date(); + } + if (status === 'IN_PROGRESS' && !run.start) { + run.start = new Date(); + } + log(`Manual test run ${runId} moved to ${status}`); + return run.save(); + }) + .then((savedRun) => populateRun(savedRun._id)) + .then((savedRun) => res.status(200).send(savedRun)) + .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 { runId } = req.params; + + return ManualTestRun.findById(runId) + .then(async (run) => { + if (!run) { + throw new NotFoundError(`No manual test run found with id ${runId}`); + } + if (!authMiddleware.hasTeamLeadAccess(req.user, run.team)) { + throw new ForbiddenError('You do not have permission to delete this test run'); + } + // The backing build and its executions exist only to represent this run on the + // dashboard, so they go with it - leaving them would show results for a run that no + // longer exists. + if (run.build) { + await TestExecution.deleteMany({ build: run.build }).exec(); + await Build.deleteOne({ _id: run.build }).exec(); + } + log(`Deleting manual test run ${runId} along with its build ${run.build}`); + return ManualTestRun.findByIdAndRemove(runId); + }) + .then((run) => { + if (!run) { + throw new NotFoundError(`No manual test run found with id ${runId}`); + } + return res.status(200).send({ message: 'Manual test run deleted successfully!' }); + }) + .catch((err) => handleError(err, res)); +}; diff --git a/app/controllers/metrics.controller.js b/app/controllers/metrics.controller.js index cb34960..93d089b 100644 --- a/app/controllers/metrics.controller.js +++ b/app/controllers/metrics.controller.js @@ -60,6 +60,7 @@ exports.retrieveMetricsPerPhase = (req, res) => { fromDate, toDate, groupingPeriod, + executionType, } = req.query; let query = {}; return Team.findById({ _id: teamId }) @@ -68,6 +69,13 @@ exports.retrieveMetricsPerPhase = (req, res) => { throw new NotFoundError(`No team found with id ${teamId}`); } const buildQuery = { team: teamFound._id }; + // Applied to the build query rather than the execution aggregation: the executions + // are already scoped to these builds, so narrowing here narrows both. Absent means + // both types, preserving the response every existing client already receives. + if (executionType) { + buildQuery.executionType = executionType; + metrics.executionType = executionType; + } if (componentId) { // match componentId with name (and add it to query) teamFound.components.forEach((component) => { @@ -143,6 +151,14 @@ exports.retrieveMetricsPerPhase = (req, res) => { // { $addFields: { phase: { $ifNull: ['$phase', 'default'] } } }, // { $unwind: '$phase' }, { $addFields: { length: { $subtract: ['$end', '$start'] } } }, + // Defaulted here rather than relying on the field being present: executions + // written before manual runs existed have no executionType on disk, and would + // otherwise fall out of the breakdown entirely. + { + $addFields: { + executionType: { $ifNull: ['$executionType', 'automated'] }, + }, + }, { $unset: ['build', 'actions'] }, ]; return Execution.aggregate(query); @@ -171,6 +187,7 @@ exports.retrieveMetricsPerPhase = (req, res) => { metrics.periods.forEach((currentPeriod) => { const period = currentPeriod; period.result = { TOTAL: 0 }; + period.executionTypeBreakdown = { automated: 0, manual: 0 }; period.buildIds = new Set(); const { items: executions } = period; executions.map((execution) => { @@ -200,6 +217,11 @@ exports.retrieveMetricsPerPhase = (req, res) => { } period.result[execution.status] += 1; period.result.TOTAL += 1; + const type = execution.executionType || 'automated'; + if (period.executionTypeBreakdown[type] === undefined) { + period.executionTypeBreakdown[type] = 0; + } + period.executionTypeBreakdown[type] += 1; const phaseGroup = period.phases.find((phase) => phase.name === execution.phase); if (phaseGroup.result[execution.status] === undefined) { diff --git a/app/controllers/prometheus.controller.js b/app/controllers/prometheus.controller.js index 640d7c9..9fe7301 100644 --- a/app/controllers/prometheus.controller.js +++ b/app/controllers/prometheus.controller.js @@ -151,6 +151,15 @@ const domainFamilies = (domain) => { executionStatusSamples.push({ value: count, labels: { status } }); }); + // Emitted as its own family rather than as an extra label on angles_builds. Adding a + // label to an existing series changes what every current query and Grafana panel sums + // over, so the established metrics keep their exact shape and the new dimension is + // additive. + const buildTypeSamples = []; + domain.builds.byExecutionType.forEach((count, executionType) => { + buildTypeSamples.push({ value: count, labels: { execution_type: executionType } }); + }); + return [ { name: 'angles_builds', @@ -158,13 +167,24 @@ const domainFamilies = (domain) => { help: 'Number of builds stored, by status.', samples: buildStatusSamples, }, + { + name: 'angles_builds_by_execution_type', + type: 'gauge', + help: 'Number of builds stored, by execution type (automated or manual).', + samples: buildTypeSamples, + }, { name: 'angles_builds_by_team', type: 'gauge', - help: 'Number of builds stored, by team, environment and status.', + help: 'Number of builds stored, by team, environment, status and execution type.', samples: domain.builds.byTeam.map((entry) => ({ value: entry.count, - labels: { team: entry.team, environment: entry.environment, status: entry.status }, + labels: { + team: entry.team, + environment: entry.environment, + status: entry.status, + execution_type: entry.executionType, + }, })), }, { @@ -173,6 +193,15 @@ const domainFamilies = (domain) => { help: 'Number of test executions stored, by status.', samples: executionStatusSamples, }, + { + name: 'angles_executions_by_execution_type', + type: 'gauge', + help: 'Number of test executions stored, by status and execution type (automated or manual).', + samples: domain.executions.byStatusAndType.map((entry) => ({ + value: entry.count, + labels: { status: entry.status, execution_type: entry.executionType }, + })), + }, { name: 'angles_screenshots', type: 'gauge', diff --git a/app/controllers/settings.controller.js b/app/controllers/settings.controller.js index 8731c99..5c8f2ba 100644 --- a/app/controllers/settings.controller.js +++ b/app/controllers/settings.controller.js @@ -1,6 +1,7 @@ const { validationResult } = require('express-validator'); const debug = require('debug'); const settingsService = require('../utils/auth-settings-service.js'); +const featureSettingsService = require('../utils/feature-settings-service.js'); const { configureProviders } = require('../utils/passport-setup.js'); const { handleError } = require('../exceptions/errors.js'); @@ -40,3 +41,27 @@ exports.updateAuthSettings = async (req, res) => { return handleError(err, res); } }; + +exports.getFeatureSettings = async (req, res) => { + try { + const settings = await featureSettingsService.getFeatureSettings(); + return res.status(200).json(settings); + } catch (err) { + return handleError(err, res); + } +}; + +exports.updateFeatureSettings = async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + + try { + const settings = await featureSettingsService.updateFeatureSettings(req.body); + log('Feature settings updated by admin.'); + return res.status(200).json(settings); + } catch (err) { + return handleError(err, res); + } +}; diff --git a/app/controllers/shared-step.controller.js b/app/controllers/shared-step.controller.js new file mode 100644 index 0000000..7912e06 --- /dev/null +++ b/app/controllers/shared-step.controller.js @@ -0,0 +1,369 @@ +const { validationResult } = require('express-validator'); +const mongoose = require('mongoose'); +const debug = require('debug'); + +const SharedStep = require('../models/shared-step.js'); +const ManualTestCase = require('../models/manual-test-case.js'); +const { Team } = require('../models/team.js'); +const sharedStepUtils = require('../utils/shared-step-utils.js'); +const manualTestCaseUtils = require('../utils/manual-test-case-utils.js'); +const attachmentUtils = require('../utils/attachment-utils.js'); +const historyUtils = require('../utils/history-utils.js'); +const validationUtils = require('../utils/validation-utils.js'); +const authMiddleware = require('../utils/auth-middleware.js'); +const { + NotFoundError, + ForbiddenError, + ConflictError, + InvalidRequestError, + handleError, +} = require('../exceptions/errors.js'); + +const log = debug('shared-step:controller'); + +// A shared step's own steps are always literal. Allowing one to include another would make +// expansion recursive and open the door to a reference cycle, for no real gain - an author +// wanting a longer sequence can add the steps directly. +const rejectNestedSharedSteps = (steps) => { + const nested = (steps || []).filter((step) => step.sharedStep); + if (nested.length > 0) { + throw new InvalidRequestError('A shared step cannot include another shared step.'); + } +}; + +const normaliseSteps = (steps) => (steps || []).map((step, index) => ({ + ...step, + order: step.order === undefined ? index + 1 : step.order, +})); + +exports.create = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { + team, name, description, steps, + } = req.body; + + return Team.findById(team).select('_id name').lean().exec() + .then(async (teamFound) => { + if (!teamFound) { + throw new NotFoundError(`No team found with id ${team}`); + } + if (!authMiddleware.hasTeamLeadAccess(req.user, teamFound._id)) { + throw new ForbiddenError('You do not have permission to manage shared steps for this team'); + } + rejectNestedSharedSteps(steps); + + // Checked explicitly so the caller gets a 409 naming the collision rather than a + // driver error. The unique index remains the backstop against a concurrent create. + const existing = await SharedStep.findOne({ team: teamFound._id, name }).lean().exec(); + if (existing) { + throw new ConflictError(`A shared step named "${name}" already exists for this team.`); + } + + const sharedStep = new SharedStep({ + team: teamFound._id, + name, + description, + steps: normaliseSteps(steps), + version: 1, + createdBy: req.user ? req.user._id : undefined, + updatedBy: req.user ? req.user._id : undefined, + }); + const saved = await sharedStep.save(); + log(`Created shared step "${name}" for team ${teamFound._id}`); + historyUtils.recordChange({ + entityType: 'sharedstep', + entityId: saved._id, + team: saved.team, + action: 'CREATE', + version: saved.version, + user: saved.createdBy, + comment: req.body.comment, + }); + return saved; + }) + .then((saved) => SharedStep.findById(saved._id) + .populate('createdBy', 'username') + .populate('updatedBy', 'username') + .lean() + .exec()) + .then((saved) => res.status(201).send(saved)) + .catch((err) => handleError(err, res)); +}; + +exports.findAll = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { teamId, search } = req.query; + const limit = parseInt(req.query.limit, 10) || 25; + const skip = parseInt(req.query.skip, 10) || 0; + + return Team.findById(teamId).select('_id').lean().exec() + .then((teamFound) => { + if (!teamFound) { + throw new NotFoundError(`No team found with id ${teamId}`); + } + // Readable by any team member - authors need to browse the library to include a + // step. Only writes require team lead access. + if (!authMiddleware.hasTeamAccess(req.user, teamId)) { + throw new ForbiddenError('You do not have access to this team'); + } + const query = { team: mongoose.Types.ObjectId(teamId) }; + if (search) { + const escaped = validationUtils.escapeRegex(search); + query.$or = [ + { name: { $regex: escaped, $options: 'i' } }, + { description: { $regex: escaped, $options: 'i' } }, + ]; + } + return Promise.all([ + SharedStep.find(query, null, { limit, skip }) + .populate('updatedBy', 'username') + .sort('-updatedAt') + .lean() + .exec(), + SharedStep.countDocuments(query).exec(), + ]); + }) + .then((results) => res.status(200).send({ + sharedSteps: results[0], + metrics: { totalSharedSteps: results[1] }, + })) + .catch((err) => handleError(err, res)); +}; + +exports.findOne = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { sharedStepId } = req.params; + + return SharedStep.findById(sharedStepId) + .populate('createdBy', 'username') + .populate('updatedBy', 'username') + .lean() + .exec() + .then((sharedStep) => { + if (!sharedStep) { + throw new NotFoundError(`No shared step found with id ${sharedStepId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, sharedStep.team)) { + throw new ForbiddenError('You do not have access to this shared step'); + } + return res.status(200).send(sharedStep); + }) + .catch((err) => handleError(err, res)); +}; + +/* +Lists the test cases that include this shared step, so the UI can warn an author how many +cases an edit is about to version before they commit to it. + */ +exports.findUsage = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { sharedStepId } = req.params; + + return SharedStep.findById(sharedStepId).lean().exec() + .then((sharedStep) => { + if (!sharedStep) { + throw new NotFoundError(`No shared step found with id ${sharedStepId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, sharedStep.team)) { + throw new ForbiddenError('You do not have access to this shared step'); + } + return ManualTestCase + .find({ 'steps.sharedStep': sharedStep._id }) + .select('_id title status version') + .sort('title') + .lean() + .exec(); + }) + .then((testCases) => res.status(200).send({ + testCases, + metrics: { totalTestCases: testCases.length }, + })) + .catch((err) => handleError(err, res)); +}; + +exports.findHistory = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { sharedStepId } = req.params; + const limit = parseInt(req.query.limit, 10) || 20; + const skip = parseInt(req.query.skip, 10) || 0; + + return SharedStep.findById(sharedStepId).select('_id team').lean().exec() + .then((sharedStep) => { + if (!sharedStep) { + throw new NotFoundError(`No shared step found with id ${sharedStepId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, sharedStep.team)) { + throw new ForbiddenError('You do not have access to this shared step'); + } + return historyUtils.findHistory(sharedStep._id, limit, skip); + }) + .then(({ entries, count }) => res.status(200).send({ + history: entries, + metrics: { totalEntries: count }, + })) + .catch((err) => handleError(err, res)); +}; + +exports.update = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { sharedStepId } = req.params; + + /* eslint no-param-reassign: ["error", { "props": false }] */ + return SharedStep.findById(sharedStepId) + .then(async (sharedStep) => { + if (!sharedStep) { + throw new NotFoundError(`No shared step found with id ${sharedStepId}`); + } + if (!authMiddleware.hasTeamLeadAccess(req.user, sharedStep.team)) { + throw new ForbiddenError('You do not have permission to manage this shared step'); + } + if (req.body.steps !== undefined) { + rejectNestedSharedSteps(req.body.steps); + } + // Captured before the document is mutated, so the diff sees what was persisted. + const before = sharedStep.toObject(); + if (req.body.name !== undefined && req.body.name !== sharedStep.name) { + const clash = await SharedStep + .findOne({ team: sharedStep.team, name: req.body.name }) + .lean() + .exec(); + if (clash) { + throw new ConflictError(`A shared step named "${req.body.name}" already exists for this team.`); + } + } + + // Only a change to the steps themselves alters what a tester would do, so only that + // bumps the version and cascades. Renaming or re-describing the shared step leaves + // every referencing case's content identical and must not version forty of them. + const stepsChanged = req.body.steps !== undefined + && JSON.stringify(manualTestCaseUtils.normaliseValue(sharedStep.steps)) + !== JSON.stringify(manualTestCaseUtils.normaliseValue(req.body.steps)); + + ['name', 'description'].forEach((field) => { + if (req.body[field] !== undefined) { + sharedStep[field] = req.body[field]; + } + }); + if (req.body.steps !== undefined) { + sharedStep.steps = normaliseSteps(req.body.steps); + } + sharedStep.updatedBy = req.user ? req.user._id : undefined; + + if (stepsChanged) { + sharedStep.version += 1; + } + const saved = await sharedStep.save(); + + const changes = historyUtils.diffDocuments( + before, + saved.toObject(), + historyUtils.TRACKED_PATHS.sharedstep, + ); + if (changes.length > 0) { + historyUtils.recordChange({ + entityType: 'sharedstep', + entityId: saved._id, + team: saved.team, + action: 'UPDATE', + version: saved.version, + changes, + user: saved.updatedBy, + comment: req.body.comment, + }); + } + + if (!stepsChanged) { + log(`Shared step ${sharedStepId} updated without a step change; no cascade`); + return { saved, cascade: { versioned: 0, failed: [] } }; + } + + // The shared step is saved before the cascade so the cascade expands against the + // content that is now current. A partial cascade is reported rather than rolled + // back: the edit itself succeeded, and re-running it would version every case a + // second time. + const cascade = await sharedStepUtils.cascadeToTestCases( + saved, + req.user ? req.user._id : undefined, + ); + return { saved, cascade }; + }) + .then(({ saved, cascade }) => SharedStep.findById(saved._id) + .populate('createdBy', 'username') + .populate('updatedBy', 'username') + .lean() + .exec() + .then((populated) => res.status(200).send({ + ...populated, + cascade: { + testCasesVersioned: cascade.versioned, + failures: cascade.failed, + }, + }))) + .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 { sharedStepId } = req.params; + + return SharedStep.findById(sharedStepId) + .then(async (sharedStep) => { + if (!sharedStep) { + throw new NotFoundError(`No shared step found with id ${sharedStepId}`); + } + if (!authMiddleware.hasTeamLeadAccess(req.user, sharedStep.team)) { + throw new ForbiddenError('You do not have permission to manage this shared step'); + } + + // A referenced shared step is never deleted: the referencing cases would expand to + // an unresolved placeholder on their next version, silently losing steps. The + // author removes the inclusions first, which the usage endpoint helps them find. + const references = await sharedStepUtils.countReferencingCases(sharedStep._id); + if (references > 0) { + throw new ConflictError(`Unable to delete shared step "${sharedStep.name}" as it is included by ${references} test case(s).`); + } + + // Nothing references the shared step, so nothing references its attachments either. + const attachmentsRemoved = await attachmentUtils + .removeAttachmentsForOwner('sharedstep', sharedStep._id); + log(`Deleting unreferenced shared step ${sharedStepId} along with ${attachmentsRemoved} attachment(s)`); + historyUtils.recordChange({ + entityType: 'sharedstep', + entityId: sharedStep._id, + team: sharedStep.team, + action: 'DELETE', + version: sharedStep.version, + user: req.user ? req.user._id : undefined, + comment: req.body.comment || `Deleted "${sharedStep.name}"`, + }); + return SharedStep.findByIdAndRemove(sharedStepId); + }) + .then((sharedStep) => { + if (!sharedStep) { + throw new NotFoundError(`No shared step found with id ${sharedStepId}`); + } + return res.status(200).send({ message: 'Shared step deleted successfully!' }); + }) + .catch((err) => handleError(err, res)); +}; diff --git a/app/exceptions/errors.js b/app/exceptions/errors.js index a6a7e73..f19eba2 100644 --- a/app/exceptions/errors.js +++ b/app/exceptions/errors.js @@ -58,6 +58,23 @@ const handleError = (error, res) => { const { statusCode, message } = error; return res.status(statusCode).send({ message }); } + // A mongoose schema validation failure is the caller sending something the model + // rejects, which is the same class of problem the route validators return 422 for. + // Without this it surfaces as a 500, reading as a server fault rather than bad input. + if (error.name === 'ValidationError' && error.errors) { + const message = Object.values(error.errors) + .map((fieldError) => fieldError.message) + .join(', '); + return res.status(422).send({ message: message || error.message }); + } + // A cast failure is the caller sending the wrong shape for a field - an object where a + // reference was expected, say - which is bad input rather than a server fault. Reported + // as 422 with the path, so it says which field rather than only that a cast failed. + if (error.name === 'CastError') { + return res.status(422).send({ + message: `Invalid value for "${error.path}": expected a ${error.kind}.`, + }); + } const message = error.message || 'Server Error'; return res.status(500).send({ message }); }; diff --git a/app/models/attachment.js b/app/models/attachment.js new file mode 100644 index 0000000..5434b4f --- /dev/null +++ b/app/models/attachment.js @@ -0,0 +1,98 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +// What the attachment hangs off. `execution` is accepted now so the upload path does not +// need reworking when manual runs land; nothing writes it yet. +const attachmentScopes = ['testcase', 'sharedstep', 'execution']; + +// An image a QA attached to a manual test step, referenced from a step's expected result +// as `![alt](attachment:)` and resolved by the UI to /attachment/:id/file. +// +// Deliberately separate from Screenshot: that model requires a build and carries phash, +// platformId, view and baseline-comparison semantics that an authoring attachment has no +// use for. Loosening Screenshot.build to optional would weaken an invariant every +// screenshot query and the whole image-engine depends on. +// +// An attachment referenced by a frozen ManualTestCaseVersion is never hard-deleted - a +// historical execution has to render the image the tester actually saw. +const AttachmentSchema = mongoose.Schema({ + team: { + type: Schema.Types.ObjectId, + ref: 'Team', + required: true, + }, + scope: { + type: String, + enum: attachmentScopes, + required: true, + }, + // Whichever of these matches `scope`. Left unset otherwise. + testCase: { + type: Schema.Types.ObjectId, + ref: 'ManualTestCase', + required: false, + }, + sharedStep: { + type: Schema.Types.ObjectId, + ref: 'SharedStep', + required: false, + }, + manualExecution: { + type: Schema.Types.ObjectId, + ref: 'TestExecution', + required: false, + }, + // Server-generated. The client's filename is never used to build a path. + filename: { + type: String, + required: true, + }, + // Kept for display only, stripped of any path component. + originalName: { + type: String, + required: false, + }, + mimeType: { + type: String, + required: false, + }, + size: { + type: Number, + required: false, + }, + path: { + type: String, + required: true, + }, + // Path to the generated .thumb.png, served by /attachment/:id/thumbnail. Absent when + // the image could not be thumbnailed - the original is still usable. + thumbnail: { + type: String, + required: false, + }, + width: { + type: Number, + required: false, + }, + height: { + type: Number, + required: false, + }, + uploadedBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: false, + }, +}, { + timestamps: true, + collection: 'attachments', +}); + +AttachmentSchema.index({ team: 1 }, { unique: false }); +AttachmentSchema.index({ testCase: 1 }, { unique: false }); +AttachmentSchema.index({ sharedStep: 1 }, { unique: false }); +AttachmentSchema.index({ manualExecution: 1 }, { unique: false }); + +module.exports = mongoose.model('Attachment', AttachmentSchema); +module.exports.attachmentScopes = attachmentScopes; diff --git a/app/models/build.js b/app/models/build.js index 2870015..3fadd49 100644 --- a/app/models/build.js +++ b/app/models/build.js @@ -2,6 +2,7 @@ const mongoose = require('mongoose'); const { Schema } = mongoose; const executionStates = ['SKIPPED', 'PASS', 'ERROR', 'FAIL']; +const executionTypes = ['automated', 'manual']; const Artifact = new Schema({ groupId: { @@ -108,6 +109,16 @@ const BuildSchema = Schema({ type: Suite, required: true, }], + // Whether this build's results came from an automated run or a manual test run. + // Defaults to 'automated' so every existing document reads back correctly with no + // migration; scripts/backfill-execution-type.js sets it explicitly so the index below + // is dense. + executionType: { + type: String, + enum: executionTypes, + default: 'automated', + required: true, + }, }, { timestamps: true, collection: 'builds', @@ -116,5 +127,9 @@ const BuildSchema = Schema({ BuildSchema.index({ team: 1 }, { unique: false }); BuildSchema.index({ team: 1, createdAt: -1 }, { unique: false }); BuildSchema.index({ team: 1, start: -1 }, { unique: false }); +// Backs the dashboard's automated/manual filter without falling back to the broader +// { team: 1, start: -1 } index and discarding most of what it reads. +BuildSchema.index({ team: 1, executionType: 1, start: -1 }, { unique: false }); module.exports = mongoose.model('Build', BuildSchema); +module.exports.executionTypes = executionTypes; diff --git a/app/models/custom-field-definition.js b/app/models/custom-field-definition.js new file mode 100644 index 0000000..18732b4 --- /dev/null +++ b/app/models/custom-field-definition.js @@ -0,0 +1,89 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +const fieldTypes = ['text', 'textarea', 'number', 'date', 'boolean', 'select', 'multiselect', 'user']; +const fieldScopes = ['testcase', 'execution', 'both']; + +// Types whose value must come from the definition's `options` list. +const optionBackedTypes = ['select', 'multiselect']; + +// An admin-configured extra field for a team's manual test cases. +// +// `key` is the stable storage key - it is what a test case's `customFields` map is keyed +// by, and what a frozen version records - so it is immutable once created. `label` is the +// display name and can be changed freely; every version snapshot keeps the label that was +// in force when it was written, so relabelling never rewrites history. +const CustomFieldDefinitionSchema = mongoose.Schema({ + team: { + type: Schema.Types.ObjectId, + ref: 'Team', + required: true, + }, + key: { + type: String, + required: true, + trim: true, + match: [ + /^[a-z][a-z0-9_]{0,39}$/, + 'Field key must start with a lowercase letter and contain only lowercase letters, numbers and underscores (max 40 characters).', + ], + }, + label: { + type: String, + required: true, + trim: true, + maxlength: 100, + }, + type: { + type: String, + enum: fieldTypes, + required: true, + }, + // Required for select/multiselect, ignored otherwise. + options: [{ + type: String, + required: false, + trim: true, + }], + // Enforced only when a test case is saved with a status other than DRAFT, so a + // half-written draft can always be saved. + required: { + type: Boolean, + default: false, + }, + defaultValue: { + type: Schema.Types.Mixed, + required: false, + }, + order: { + type: Number, + required: false, + }, + // Which entity the field applies to. Only 'testcase' and 'both' are consumed today; + // 'execution' becomes meaningful when manual runs land. + appliesTo: { + type: String, + enum: fieldScopes, + default: 'testcase', + }, + // Archived fields are hidden from authoring but still resolve for historical values, so + // a field that has been used somewhere is archived rather than deleted. + archived: { + type: Boolean, + default: false, + }, +}, { + timestamps: true, + collection: 'customfielddefinitions', +}); + +// A key identifies a field within a team, including archived ones - reusing the key of an +// archived field would make its historical values ambiguous. +CustomFieldDefinitionSchema.index({ team: 1, key: 1 }, { unique: true }); +CustomFieldDefinitionSchema.index({ team: 1, archived: 1 }, { unique: false }); + +module.exports = mongoose.model('CustomFieldDefinition', CustomFieldDefinitionSchema); +module.exports.fieldTypes = fieldTypes; +module.exports.fieldScopes = fieldScopes; +module.exports.optionBackedTypes = optionBackedTypes; diff --git a/app/models/execution.js b/app/models/execution.js index fd24397..229c0eb 100644 --- a/app/models/execution.js +++ b/app/models/execution.js @@ -3,6 +3,7 @@ const Platform = require('./platform.js'); const { Schema } = mongoose; const executionStates = ['SKIPPED', 'PASS', 'ERROR', 'FAIL']; +const executionTypes = ['automated', 'manual']; const Step = mongoose.Schema({ name: { @@ -36,6 +37,14 @@ const Step = mongoose.Schema({ ref: 'Screenshot', required: false, }, + // Images a QA attached while recording a manual step result. Automated runs use + // `screenshot` above; this is the manual equivalent and shares the same subdocument so + // there is no parallel step structure to keep in sync. + attachments: [{ + type: Schema.Types.ObjectId, + ref: 'Attachment', + required: false, + }], }, { _id: false }); const Action = mongoose.Schema({ @@ -117,11 +126,47 @@ const TestExecutionSchema = mongoose.Schema({ enum: executionStates, required: true, }, + // Mirrors Build.executionType, defaulted the same way so existing documents read back + // unchanged. Carried on the execution as well as the build because the metrics + // aggregations group executions without always loading their build. + executionType: { + type: String, + enum: executionTypes, + default: 'automated', + required: true, + }, + // Set on a manual execution: the test case it came from, and - critically - the frozen + // version it was executed against. + manualTestCase: { + type: Schema.Types.ObjectId, + ref: 'ManualTestCase', + required: false, + }, + // The version ref lives here as well as on the run entry on purpose. An execution is + // reachable from the dashboard and from the execution history without ever loading the + // run, and it has to be able to render the content it was executed against on its own. + manualTestCaseVersion: { + type: Schema.Types.ObjectId, + ref: 'ManualTestCaseVersion', + required: false, + }, + versionNumber: { + type: Number, + required: false, + }, + executedBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: false, + }, }, { timestamps: true, }, { collection: 'testexecutions' }); TestExecutionSchema.index({ build: 1 }, { unique: false }); TestExecutionSchema.index({ suite: 1, title: 1 }, { unique: false }); +// "Every run of this manual test case", which the case detail view asks for directly. +TestExecutionSchema.index({ manualTestCase: 1, createdAt: -1 }, { unique: false }); module.exports = mongoose.model('TestExecution', TestExecutionSchema); +module.exports.executionTypes = executionTypes; diff --git a/app/models/feature-settings.js b/app/models/feature-settings.js new file mode 100644 index 0000000..c216e75 --- /dev/null +++ b/app/models/feature-settings.js @@ -0,0 +1,23 @@ +const mongoose = require('mongoose'); + +// Single-document collection holding runtime-configurable feature toggles, kept separate +// from the auth settings singleton so the two concerns can be read, written and validated +// independently (auth settings carry provider secrets; these do not). +// +// A toggle defaults to on: enabling a feature that already shipped must not require an +// admin to act, and an instance upgrading to this version keeps the behaviour it had. +const FeatureSettingsSchema = mongoose.Schema({ + singleton: { + type: String, + default: 'features', + unique: true, + enum: ['features'], + }, + // Manual test case management: the test cases, folders, shared steps and test runs. + // They are one feature - a run is a run *of* cases - so they are gated together. + manualTestingEnabled: { type: Boolean, default: true }, +}, { + timestamps: true, +}, { collection: 'featureSettings' }); + +module.exports = mongoose.model('FeatureSettings', FeatureSettingsSchema); diff --git a/app/models/manual-change-history.js b/app/models/manual-change-history.js new file mode 100644 index 0000000..ae423b7 --- /dev/null +++ b/app/models/manual-change-history.js @@ -0,0 +1,113 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +const entityTypes = ['testcase', 'sharedstep', 'customfield', 'folder']; +const historyActions = [ + 'CREATE', + 'UPDATE', + 'DELETE', + 'CLONE', + 'STATUS_CHANGE', + 'SHARED_STEP_UPDATE', + 'ARCHIVE', + // Filing a case into a folder. Organisation rather than content, so it burns no version - + // but it still answers "who moved this, and when?". + 'MOVE', +]; + +// One field-level change. `from` and `to` are Mixed because a custom field value can be a +// string, number, boolean, date or array, and a steps change carries an array of objects. +const Change = new Schema({ + field: { + type: String, + required: true, + }, + from: { + type: Schema.Types.Mixed, + required: false, + }, + to: { + type: Schema.Types.Mixed, + required: false, + }, +}, { _id: false }); + +// An audit record of one mutation to a test case, shared step or custom field definition. +// +// This is a *log*, distinct from ManualTestCaseVersion: a version is the content a tester +// saw and is what an execution binds to, while a history entry says who changed what and +// why. A status change writes history without burning a version, and a shared step edit +// writes an entry against every case it cascaded into - neither of which the version +// collection alone can express. +// +// Entries are only ever inserted. Nothing updates or deletes them, and they are not +// auto-pruned; if volume becomes a problem the cleanup/ scripts get a follow-up task. +const ManualChangeHistorySchema = mongoose.Schema({ + entityType: { + type: String, + enum: entityTypes, + required: true, + }, + entityId: { + type: Schema.Types.ObjectId, + required: true, + }, + team: { + type: Schema.Types.ObjectId, + ref: 'Team', + required: true, + }, + action: { + type: String, + enum: historyActions, + required: true, + }, + // The version the entity ended up on. Absent for entities that are not versioned + // (custom field definitions), and unchanged from the previous entry for an action that + // deliberately does not burn a version, such as a status change. + version: { + type: Number, + required: false, + }, + changes: [{ + type: Change, + required: false, + }], + // Who made the change. Not required: a change can originate from a process without a + // session, and losing the whole audit entry because the actor is unknown would be worse + // than recording it without one. + changedBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: false, + }, + changedAt: { + type: Date, + default: Date.now, + required: true, + }, + // Free-text reason supplied by the author, or a generated one explaining a cascade. + comment: { + type: String, + required: false, + }, + // Set on a SHARED_STEP_UPDATE entry: the shared step whose edit caused this test case + // to be versioned, so the history can say why a case changed when nobody edited it. + causedBy: { + type: Schema.Types.ObjectId, + ref: 'SharedStep', + required: false, + }, +}, { + // Only createdAt would be meaningful and `changedAt` already carries it. + timestamps: false, + collection: 'manualchangehistory', +}); + +ManualChangeHistorySchema.index({ entityId: 1, changedAt: -1 }, { unique: false }); +ManualChangeHistorySchema.index({ team: 1, changedAt: -1 }, { unique: false }); + +module.exports = mongoose.model('ManualChangeHistory', ManualChangeHistorySchema); +module.exports.entityTypes = entityTypes; +module.exports.historyActions = historyActions; diff --git a/app/models/manual-folder.js b/app/models/manual-folder.js new file mode 100644 index 0000000..1a4527e --- /dev/null +++ b/app/models/manual-folder.js @@ -0,0 +1,74 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +/* +A folder in a team's manual test case tree. + +Nesting is stored as a parent reference *plus* a materialised path of ancestor ids. The +parent alone would make "everything under this folder" a recursive walk - one query per +level, at arbitrary depth - which the tree view and the delete guard both need on every +request. The path makes that a single indexed query, at the cost of having to rewrite the +paths of a subtree when a folder moves. Moves are rare; reads are not. + +`path` holds ancestors only, root first, and never the folder itself. A root folder has an +empty path. + */ +const ManualFolderSchema = new Schema({ + team: { + type: Schema.Types.ObjectId, + ref: 'Team', + required: true, + index: true, + }, + name: { + type: String, + required: true, + trim: true, + maxlength: 100, + }, + description: { + type: String, + required: false, + maxlength: 500, + }, + parent: { + type: Schema.Types.ObjectId, + ref: 'ManualFolder', + required: false, + default: null, + }, + // Ancestor ids, outermost first. Denormalised from `parent` - see the note above. + path: [{ + type: Schema.Types.ObjectId, + ref: 'ManualFolder', + required: false, + }], + // Depth of this folder, 0 for a root. Denormalised from path.length so a query can bound + // depth without loading the array. + depth: { + type: Number, + required: true, + default: 0, + }, + createdBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: false, + }, + updatedBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: false, + }, +}, { timestamps: true }); + +// Two folders cannot share a name under the same parent. Mongo treats every null as +// distinct in a unique index, so roots (parent: null) would escape the constraint - the +// partial filter plus an explicit null default is what keeps them covered. +ManualFolderSchema.index({ team: 1, parent: 1, name: 1 }, { unique: true }); +// Drives "everything under this folder" for the tree, the delete guard and the move check. +ManualFolderSchema.index({ team: 1, path: 1 }, { unique: false }); +ManualFolderSchema.index({ team: 1, parent: 1 }, { unique: false }); + +module.exports = mongoose.model('ManualFolder', ManualFolderSchema); diff --git a/app/models/manual-test-case-version.js b/app/models/manual-test-case-version.js new file mode 100644 index 0000000..50a0383 --- /dev/null +++ b/app/models/manual-test-case-version.js @@ -0,0 +1,105 @@ +const mongoose = require('mongoose'); +const { ManualStep, testCasePriorities } = require('./manual-test-case.js'); + +const { Schema } = mongoose; + +// A frozen copy of a custom field definition as it stood when a version was written, so a +// field that is later relabelled, retyped or archived still renders a historical execution +// with the label and type the QA actually saw. Populated from phase 2 onwards; versions +// written before then simply carry an empty array. +const CustomFieldDefinitionSnapshot = new Schema({ + key: { + type: String, + required: true, + }, + label: { + type: String, + required: false, + }, + type: { + type: String, + required: false, + }, + options: [{ + type: String, + required: false, + }], +}, { _id: false }); + +// An immutable, fully self-contained copy of one version of a manual test case. +// +// Documents in this collection are only ever inserted - nothing in the application +// updates or deletes them. That is the property the whole feature rests on: an execution +// bound to version 3 cannot be made to render version 4's content, because there is no +// write path that can alter version 3's document. +// +// The copy is deliberately complete rather than a diff or a step-only snapshot: an +// execution has to render exactly what the tester saw, which includes the title, +// preconditions, priority and custom field values, not just the steps. Steps are stored +// with shared steps already expanded (phase 3) for the same reason - a shared step edited +// later must not rewrite the history of every case that included it. +const ManualTestCaseVersionSchema = mongoose.Schema({ + testCase: { + type: Schema.Types.ObjectId, + ref: 'ManualTestCase', + required: true, + }, + version: { + type: Number, + required: true, + }, + team: { + type: Schema.Types.ObjectId, + ref: 'Team', + required: true, + }, + title: { + type: String, + required: true, + }, + description: { + type: String, + required: false, + }, + preconditions: { + type: String, + required: false, + }, + priority: { + type: String, + enum: testCasePriorities, + required: false, + }, + tags: [{ + type: String, + required: false, + }], + steps: [{ + type: ManualStep, + required: false, + }], + customFields: { + type: Map, + of: Schema.Types.Mixed, + required: false, + }, + fieldDefinitions: [{ + type: CustomFieldDefinitionSnapshot, + required: false, + }], + createdBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: false, + }, +}, { + // Only createdAt is meaningful - these documents are never updated. + timestamps: { createdAt: true, updatedAt: false }, + collection: 'manualtestcaseversions', +}); + +// Guarantees a case can never end up with two documents claiming the same version, which +// would make "which content was this execution run against?" ambiguous. +ManualTestCaseVersionSchema.index({ testCase: 1, version: 1 }, { unique: true }); + +module.exports = mongoose.model('ManualTestCaseVersion', ManualTestCaseVersionSchema); diff --git a/app/models/manual-test-case.js b/app/models/manual-test-case.js new file mode 100644 index 0000000..1132ba3 --- /dev/null +++ b/app/models/manual-test-case.js @@ -0,0 +1,168 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +const testCaseStates = ['DRAFT', 'ACTIVE', 'DEPRECATED']; +const testCasePriorities = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; + +// A single step of a manual test case. Unlike most subdocuments in this project this one +// keeps its `_id`: a recorded step result binds to a specific step of a specific frozen +// version, so a step reordered or removed in a later version can never pull a historical +// result onto the wrong step. +const ManualStep = new Schema({ + order: { + type: Number, + required: true, + }, + action: { + type: String, + // Required only for a literal step. A shared step inclusion carries no action of its + // own - the shared step's contents are expanded in its place, and expandSteps supplies + // a placeholder action if the reference cannot be resolved - so demanding one here + // would force callers to invent a dummy value that is never displayed. + required: [ + function actionRequiredForLiteralSteps() { return !this.sharedStep && !this.sharedStepRef; }, + 'Each step requires an action unless it includes a shared step', + ], + trim: true, + }, + expected: { + type: String, + required: false, + }, + // Set when this step is an inclusion of a re-usable shared step. On the mutable head + // it marks a placeholder whose own action/expected are ignored; the shared step's + // contents are expanded in its place. A frozen version never stores a placeholder - + // it stores the expanded result, with the two attribution fields below. + sharedStep: { + type: Schema.Types.ObjectId, + ref: 'SharedStep', + required: false, + }, + // Display attribution on an expanded step: which shared step it came from and at which + // version. Carried so the UI can show "from v2" and so a frozen version + // records what the tester was actually looking at, without the step still being a + // reference that could resolve differently later. + sharedStepRef: { + type: Schema.Types.ObjectId, + ref: 'SharedStep', + required: false, + }, + sharedStepVersion: { + type: Number, + required: false, + }, + attachments: [{ + type: Schema.Types.ObjectId, + ref: 'Attachment', + required: false, + }], + // Test data the QA needs for this step (accounts, payloads, fixture references). + data: { + type: String, + required: false, + }, +}); + +// The mutable head of a manual test case: it always reflects the latest content. Every +// version that has ever existed is additionally written to the append-only +// ManualTestCaseVersion collection, and it is those frozen documents that executions bind +// to - never this one. See docs/plans/manual-test-case-management.md. +const ManualTestCaseSchema = mongoose.Schema({ + team: { + type: Schema.Types.ObjectId, + ref: 'Team', + required: true, + }, + // Matches Build.component: the component _id from the team's components array. + component: { + type: Schema.Types.ObjectId, + required: false, + }, + // The folder this case is filed under, or null for the team's root. Organisation rather + // than content: a move does not burn a version, and a frozen version carries no folder - + // an execution renders what was tested, not where the case has since been filed. + folder: { + type: Schema.Types.ObjectId, + ref: 'ManualFolder', + required: false, + default: null, + }, + title: { + type: String, + required: true, + trim: true, + maxlength: 200, + }, + description: { + type: String, + required: false, + }, + preconditions: { + type: String, + required: false, + }, + status: { + type: String, + enum: testCaseStates, + default: 'DRAFT', + required: true, + }, + priority: { + type: String, + enum: testCasePriorities, + default: 'MEDIUM', + required: true, + }, + tags: [{ + type: String, + required: false, + trim: true, + lowercase: true, + }], + steps: [{ + type: ManualStep, + required: false, + }], + // Values for the custom fields an admin configured for this team (phase 2). Mixed + // because the value type depends on the field definition's `type`. + customFields: { + type: Map, + of: Schema.Types.Mixed, + required: false, + default: () => new Map(), + }, + // Incremented on every content change. Always points at an existing document in the + // version collection. + version: { + type: Number, + default: 1, + required: true, + }, + createdBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: false, + }, + updatedBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: false, + }, +}, { + timestamps: true, + collection: 'manualtestcases', +}); + +ManualTestCaseSchema.index({ team: 1, status: 1 }, { unique: false }); +ManualTestCaseSchema.index({ team: 1, createdAt: -1 }, { unique: false }); +ManualTestCaseSchema.index({ tags: 1 }, { unique: false }); +// Drives the folder-filtered case list. +ManualTestCaseSchema.index({ team: 1, folder: 1 }, { unique: false }); +// Backs the `search` query parameter on GET /manual-test-case. +ManualTestCaseSchema.index({ title: 'text', description: 'text' }); + +module.exports = mongoose.model('ManualTestCase', ManualTestCaseSchema); +module.exports.ManualStep = ManualStep; +module.exports.testCaseStates = testCaseStates; +module.exports.testCasePriorities = testCasePriorities; diff --git a/app/models/manual-test-run.js b/app/models/manual-test-run.js new file mode 100644 index 0000000..0f95cba --- /dev/null +++ b/app/models/manual-test-run.js @@ -0,0 +1,212 @@ +const mongoose = require('mongoose'); +const Platform = require('./platform.js'); + +const { Schema } = mongoose; + +const runStates = ['PLANNED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']; + +// Per-case result states. Richer than the closed `executionStates` enum the metrics layer +// depends on: BLOCKED and IN_PROGRESS have no equivalent there and are kept here only. +const caseResultStates = ['NOT_RUN', 'IN_PROGRESS', 'PASS', 'FAIL', 'ERROR', 'SKIPPED', 'BLOCKED']; + +// How a per-case state is written to the TestExecution the metrics layer reads. +// +// BLOCKED maps to SKIPPED rather than ERROR: a blocked test was never executed, so nothing +// was verified and no defect was found. Recording it as ERROR would inflate the failure +// count on every dashboard and alert that counts errors. IN_PROGRESS has no execution yet. +const EXECUTION_STATUS_BY_CASE_STATUS = { + PASS: 'PASS', + FAIL: 'FAIL', + ERROR: 'ERROR', + SKIPPED: 'SKIPPED', + BLOCKED: 'SKIPPED', + NOT_RUN: 'SKIPPED', + IN_PROGRESS: 'SKIPPED', +}; + +// The result a tester recorded against one step of the frozen version. +const StepResult = new Schema({ + // The _id of the step in the frozen ManualTestCaseVersion. ManualStep keeps its _id + // precisely so this binding survives a later reorder or deletion. + stepId: { + type: Schema.Types.ObjectId, + required: false, + }, + status: { + type: String, + enum: ['NOT_RUN', 'PASS', 'FAIL', 'BLOCKED', 'SKIPPED'], + default: 'NOT_RUN', + required: true, + }, + actual: { + type: String, + required: false, + }, + notes: { + type: String, + required: false, + }, + attachments: [{ + type: Schema.Types.ObjectId, + ref: 'Attachment', + required: false, + }], + timestamp: { + type: Date, + required: false, + }, +}, { _id: false }); + +// One test case as scheduled into a run. +const RunTestCase = new Schema({ + // The mutable head, so "show me every run of this case" is a single query. + testCase: { + type: Schema.Types.ObjectId, + ref: 'ManualTestCase', + required: true, + }, + // The frozen content actually being executed. Bound at run creation and never moved + // implicitly: editing a case mid-run must not shift the steps under the tester. + testCaseVersion: { + type: Schema.Types.ObjectId, + ref: 'ManualTestCaseVersion', + required: true, + }, + // Denormalised so the run list can show "v3" without joining the version collection. + versionNumber: { + type: Number, + required: false, + }, + // Copied from the version at binding time, so the tester's step list is stable even if + // the version document is somehow unavailable. Display only - the version is the record. + snapshotTitle: { + type: String, + required: false, + }, + status: { + type: String, + enum: caseResultStates, + default: 'NOT_RUN', + required: true, + }, + stepResults: [{ + type: StepResult, + required: false, + }], + execution: { + type: Schema.Types.ObjectId, + ref: 'TestExecution', + required: false, + }, + executedBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: false, + }, + notes: { + type: String, + required: false, + }, + start: { + type: Date, + required: false, + }, + end: { + type: Date, + required: false, + }, +}); + +// A planned or in-flight session of manual testing. +// +// The run sits beside a Build rather than replacing it: results are written as +// TestExecution documents against a Build tagged executionType 'manual', so every existing +// dashboard and metrics aggregation counts them without a parallel pipeline. What the run +// holds is everything a build has no concept of - who it is assigned to, which cases are +// still outstanding, and which frozen version each one is bound to. +const ManualTestRunSchema = mongoose.Schema({ + name: { + type: String, + required: true, + trim: true, + maxlength: 200, + }, + description: { + type: String, + required: false, + }, + team: { + type: Schema.Types.ObjectId, + ref: 'Team', + required: true, + }, + // One component per run, matching Build.component - the backing build carries it, and a + // run spanning components would make the component metrics approximate. + component: { + type: Schema.Types.ObjectId, + required: false, + }, + environment: { + type: Schema.Types.ObjectId, + ref: 'Environment', + required: true, + }, + phase: { + type: Schema.Types.ObjectId, + ref: 'Phase', + required: false, + }, + // The manual-tagged build this run's executions are written into. + build: { + type: Schema.Types.ObjectId, + ref: 'Build', + required: false, + }, + status: { + type: String, + enum: runStates, + default: 'PLANNED', + required: true, + }, + assignedTo: { + type: Schema.Types.ObjectId, + ref: 'User', + required: false, + }, + // Reused from the automated model verbatim, so manual runs capture the same + // platform/device metrics and the platform aggregations need no new fields. + platforms: [{ + type: Platform, + required: false, + }], + testCases: [{ + type: RunTestCase, + required: false, + }], + start: { + type: Date, + required: false, + }, + end: { + type: Date, + required: false, + }, + createdBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: false, + }, +}, { + timestamps: true, + collection: 'manualtestruns', +}); + +ManualTestRunSchema.index({ team: 1, createdAt: -1 }, { unique: false }); +ManualTestRunSchema.index({ team: 1, status: 1 }, { unique: false }); +ManualTestRunSchema.index({ assignedTo: 1, status: 1 }, { unique: false }); +ManualTestRunSchema.index({ 'testCases.testCase': 1 }, { unique: false }); + +module.exports = mongoose.model('ManualTestRun', ManualTestRunSchema); +module.exports.runStates = runStates; +module.exports.caseResultStates = caseResultStates; +module.exports.EXECUTION_STATUS_BY_CASE_STATUS = EXECUTION_STATUS_BY_CASE_STATUS; diff --git a/app/models/shared-step.js b/app/models/shared-step.js new file mode 100644 index 0000000..9891b18 --- /dev/null +++ b/app/models/shared-step.js @@ -0,0 +1,61 @@ +const mongoose = require('mongoose'); +const { ManualStep } = require('./manual-test-case.js'); + +const { Schema } = mongoose; + +// A re-usable sequence of steps that many manual test cases can include - a login +// sequence, a checkout flow, a teardown. +// +// A test case includes a shared step by reference on its mutable head, but never in a +// frozen version: versions store the expanded steps instead. That is what stops an edit +// here rewriting the history of every case that includes it. Editing a shared step +// instead cascades forward, writing a fresh version for each referencing case (see +// shared-step-utils.cascadeToTestCases). +const SharedStepSchema = mongoose.Schema({ + team: { + type: Schema.Types.ObjectId, + ref: 'Team', + required: true, + }, + name: { + type: String, + required: true, + trim: true, + maxlength: 150, + }, + description: { + type: String, + required: false, + }, + // Nested shared steps are rejected at the controller: a shared step's steps are always + // literal, so expansion is a single pass with no cycle risk. + steps: [{ + type: ManualStep, + required: false, + }], + // Incremented whenever the steps change. Recorded on every expanded step so a frozen + // version can show which revision of the shared step the tester saw. + version: { + type: Number, + default: 1, + required: true, + }, + createdBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: false, + }, + updatedBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: false, + }, +}, { + timestamps: true, + collection: 'sharedsteps', +}); + +SharedStepSchema.index({ team: 1, name: 1 }, { unique: true }); +SharedStepSchema.index({ team: 1, updatedAt: -1 }, { unique: false }); + +module.exports = mongoose.model('SharedStep', SharedStepSchema); diff --git a/app/routes/attachment.routes.js b/app/routes/attachment.routes.js new file mode 100644 index 0000000..aae92eb --- /dev/null +++ b/app/routes/attachment.routes.js @@ -0,0 +1,50 @@ +const { + check, + param, + query, + oneOf, +} = require('express-validator'); +const multerConfig = require('../utils/multer-config-attachments.js'); +const attachmentController = require('../controllers/attachment.controller.js'); + +module.exports = (app, path) => { + app.post( + `${path}/attachment`, + multerConfig.single('attachment'), + [ + // Exactly one owner is required. multer has already validated the id's shape before + // writing the file (it builds the directory from it), but the request is only + // accepted once express-validator confirms it here too. + oneOf([ + check('testCaseId').exists().isMongoId(), + check('sharedStepId').exists().isMongoId(), + check('executionId').exists().isMongoId(), + ], 'A valid testCaseId, sharedStepId or executionId is required'), + ], + attachmentController.create, + attachmentController.createFail, + ); + + app.get(`${path}/attachment`, [ + oneOf([ + query('testCaseId').exists().isMongoId(), + query('sharedStepId').exists().isMongoId(), + ], 'A valid testCaseId or sharedStepId is required'), + ], attachmentController.findAll); + + app.get(`${path}/attachment/:attachmentId`, [ + param('attachmentId').exists().isMongoId(), + ], attachmentController.findOne); + + app.get(`${path}/attachment/:attachmentId/file`, [ + param('attachmentId').exists().isMongoId(), + ], attachmentController.findFile); + + app.get(`${path}/attachment/:attachmentId/thumbnail`, [ + param('attachmentId').exists().isMongoId(), + ], attachmentController.findThumbnail); + + app.delete(`${path}/attachment/:attachmentId`, [ + param('attachmentId').exists().isMongoId(), + ], attachmentController.delete); +}; diff --git a/app/routes/auth.routes.js b/app/routes/auth.routes.js index 51a9dee..1b1e03b 100644 --- a/app/routes/auth.routes.js +++ b/app/routes/auth.routes.js @@ -2,6 +2,7 @@ const { check, validationResult } = require('express-validator'); const passport = require('passport'); const debug = require('debug'); const authConfig = require('../../config/auth.config.js'); +const featureConfig = require('../../config/feature.config.js'); const { isProviderReady, getReadyProvider, @@ -14,9 +15,15 @@ const log = debug('auth:routes'); module.exports = (app, path) => { // Config. Drives the login page: which sign-in methods to offer, and where each goes. + // It also carries the feature toggles, because every client needs them to decide which + // navigation and pages exist, and this is the one config endpoint fetched before login. + // Only the toggles themselves are exposed - never anything about how they are stored. app.get(`${path}/auth/config`, (req, res) => { res.json({ localAuthEnabled: authConfig.localAuthEnabled !== false, + features: { + manualTestingEnabled: featureConfig.manualTestingEnabled !== false, + }, providers: listEnabledProviders(), }); }); diff --git a/app/routes/build.routes.js b/app/routes/build.routes.js index 36cbe50..ebeb15a 100644 --- a/app/routes/build.routes.js +++ b/app/routes/build.routes.js @@ -62,6 +62,10 @@ module.exports = (app, path) => { query('componentIds') .optional() .isString(), + query('executionType') + .optional() + .isIn(['automated', 'manual']) + .withMessage('executionType must be either automated or manual'), query('limit') .optional() .isNumeric(), diff --git a/app/routes/custom-field.routes.js b/app/routes/custom-field.routes.js new file mode 100644 index 0000000..b962cb3 --- /dev/null +++ b/app/routes/custom-field.routes.js @@ -0,0 +1,101 @@ +const { check, param, query } = require('express-validator'); +const customFieldController = require('../controllers/custom-field.controller.js'); +const authMiddleware = require('../utils/auth-middleware.js'); +const { fieldTypes, fieldScopes } = require('../models/custom-field-definition.js'); + +// Shared by create and update. `key` and `type` are only required on create, so their +// presence rules live with the individual routes. +const definitionValidators = [ + check('label') + .optional() + .isString() + .isLength({ min: 1, max: 100 }) + .withMessage('Max length for a field label is 100 characters'), + check('options') + .optional() + .isArray({ max: 100 }) + .withMessage('A field can have at most 100 options'), + check('options.*') + .optional() + .isString() + .isLength({ min: 1, max: 100 }) + .withMessage('Max length for a field option is 100 characters'), + check('required') + .optional() + .isBoolean(), + check('order') + .optional() + .isNumeric(), + check('appliesTo') + .optional() + .isIn(fieldScopes) + .withMessage(`appliesTo must be one of [${fieldScopes.join(', ')}]`), +]; + +module.exports = (app, path) => { + // Configuring the fields available to a team is an admin action; reading them is not, + // so authorizeAdmin is applied per-route rather than to the whole path. + app.post(`${path}/custom-field`, authMiddleware.authorizeAdmin, [ + check('team') + .exists() + .isMongoId(), + check('key') + .exists({ checkFalsy: true }) + .matches(/^[a-z][a-z0-9_]{0,39}$/) + .withMessage('Field key must start with a lowercase letter and contain only lowercase letters, numbers and underscores (max 40 characters).'), + check('label') + .exists({ checkFalsy: true }) + .isString() + .isLength({ min: 1, max: 100 }) + .withMessage('Max length for a field label is 100 characters'), + check('type') + .exists() + .isIn(fieldTypes) + .withMessage(`Field type must be one of [${fieldTypes.join(', ')}]`), + ...definitionValidators, + ], customFieldController.create); + + app.get(`${path}/custom-field`, [ + query('teamId') + .exists() + .isMongoId(), + query('includeArchived') + .optional() + .isBoolean(), + query('appliesTo') + .optional() + .isIn(fieldScopes) + .withMessage(`appliesTo must be one of [${fieldScopes.join(', ')}]`), + ], customFieldController.findAll); + + app.get(`${path}/custom-field/:fieldId`, [ + param('fieldId') + .exists() + .isMongoId(), + ], customFieldController.findOne); + + app.put(`${path}/custom-field/:fieldId`, authMiddleware.authorizeAdmin, [ + param('fieldId') + .exists() + .isMongoId(), + // Accepted so the controller can return an explanatory 400 rather than silently + // ignoring an attempt to rename the storage key. + check('key') + .optional() + .isString(), + check('type') + .optional() + .isIn(fieldTypes) + .withMessage(`Field type must be one of [${fieldTypes.join(', ')}]`), + check('archived') + .optional() + .isBoolean(), + ...definitionValidators, + ], customFieldController.update); + + app.delete(`${path}/custom-field/:fieldId`, authMiddleware.authorizeAdmin, [ + param('fieldId') + .exists() + .isMongoId(), + ], customFieldController.delete); +}; diff --git a/app/routes/manual-folder.routes.js b/app/routes/manual-folder.routes.js new file mode 100644 index 0000000..202c22f --- /dev/null +++ b/app/routes/manual-folder.routes.js @@ -0,0 +1,81 @@ +const { check, param, query } = require('express-validator'); +const manualFolderController = require('../controllers/manual-folder.controller.js'); +const featureMiddleware = require('../utils/feature-middleware.js'); + +module.exports = (app, path) => { + // Manual testing is an optional feature; when an admin has turned it off every + // route below responds 404, so the data is unreachable and not merely hidden in the UI. + app.use(`${path}/manual-folder`, featureMiddleware.requireManualTesting); + app.post(`${path}/manual-folder`, [ + check('team') + .exists() + .isMongoId(), + check('name') + .exists() + .isString() + .trim() + .isLength({ min: 1, max: 100 }) + .withMessage('A folder name is required (max 100 characters)'), + check('description') + .optional() + .isString() + .isLength({ max: 500 }) + .withMessage('Max length for a folder description is 500 characters'), + // Absent or null files the folder at the root. + check('parent') + .optional({ nullable: true }) + .isMongoId(), + ], manualFolderController.create); + + app.get(`${path}/manual-folder`, [ + query('teamId') + .exists() + .isMongoId(), + ], manualFolderController.findAll); + + // Declared before /:folderId so "move" is not captured as an id. + app.put(`${path}/manual-folder/move`, [ + check('testCaseIds') + .exists() + .custom((ids) => Array.isArray(ids) && ids.length > 0) + .withMessage('At least one test case id is required'), + check('testCaseIds.*') + .isMongoId(), + check('folder') + .optional({ nullable: true }) + .isMongoId(), + ], manualFolderController.moveTestCases); + + app.get(`${path}/manual-folder/:folderId`, [ + param('folderId') + .exists() + .isMongoId(), + ], manualFolderController.findOne); + + app.put(`${path}/manual-folder/:folderId`, [ + param('folderId') + .exists() + .isMongoId(), + check('name') + .optional() + .isString() + .trim() + .isLength({ min: 1, max: 100 }) + .withMessage('A folder name is required (max 100 characters)'), + check('description') + .optional() + .isString() + .isLength({ max: 500 }) + .withMessage('Max length for a folder description is 500 characters'), + // null moves the folder to the root. + check('parent') + .optional({ nullable: true }) + .isMongoId(), + ], manualFolderController.update); + + app.delete(`${path}/manual-folder/:folderId`, [ + param('folderId') + .exists() + .isMongoId(), + ], manualFolderController.delete); +}; diff --git a/app/routes/manual-test-case.routes.js b/app/routes/manual-test-case.routes.js new file mode 100644 index 0000000..41ce6ab --- /dev/null +++ b/app/routes/manual-test-case.routes.js @@ -0,0 +1,232 @@ +const { check, param, query } = require('express-validator'); +const manualTestCaseController = require('../controllers/manual-test-case.controller.js'); +const featureMiddleware = require('../utils/feature-middleware.js'); +const { + testCaseStates, + testCasePriorities, +} = require('../models/manual-test-case.js'); + +// Shared by POST and PUT. Every field except the title is optional on create (a case +// starts as a draft and is filled in progressively), and all of them are optional on +// update - an omitted field is left untouched rather than cleared. +// Turns an express-validator path like "steps[2].action" into "Step 3", so a message can +// say which step is wrong rather than leaving the author to count. +const describeStep = (validatorPath) => { + const match = /steps\[(\d+)\]/.exec(validatorPath || ''); + return match ? `Step ${Number(match[1]) + 1}` : 'Each step'; +}; + +const contentValidators = [ + check('description') + .optional() + .isString() + .isLength({ max: 5000 }) + .withMessage('Max length for the description is 5000 characters'), + check('preconditions') + .optional() + .isString() + .isLength({ max: 5000 }) + .withMessage('Max length for the preconditions is 5000 characters'), + check('status') + .optional() + .isIn(testCaseStates) + .withMessage(`Status must be one of [${testCaseStates.join(', ')}]`), + check('priority') + .optional() + .isIn(testCasePriorities) + .withMessage(`Priority must be one of [${testCasePriorities.join(', ')}]`), + check('tags') + .optional() + .isArray(), + check('tags.*') + .optional() + .isString() + .isLength({ max: 50 }) + .withMessage('Max length for a tag is 50 characters'), + check('steps') + .optional() + .isArray(), + // A shared step inclusion has no action of its own - the shared step's contents are + // expanded in its place - so the action is only required for a literal step. + check('steps.*.action') + .if((value, { req, path }) => { + const index = path.match(/steps\[(\d+)\]/); + const step = index && req.body.steps ? req.body.steps[Number(index[1])] : undefined; + // Two shapes carry no action of their own and must both be exempt: + // sharedStep - an inclusion the author just added; the shared step's contents + // are expanded in its place. + // sharedStepRef - a step that came *from* a shared step, echoed back by a client + // that read the case and is now saving it. Its action is whatever + // the shared step said, which may legitimately be empty. + return !(step && (step.sharedStep || step.sharedStepRef)); + }) + // Each check carries its own message and names the step. withMessage() only applies to + // the validator immediately before it, so a shared trailing message would leave the + // others reporting a bare "Invalid value" - which tells the author nothing about which + // of several steps is wrong. + .exists() + .withMessage((value, { path }) => `${describeStep(path)} requires an action, unless it includes a shared step`) + .bail() + .isString() + .withMessage((value, { path }) => `${describeStep(path)} action must be text`) + .bail() + .isLength({ max: 2000 }) + .withMessage((value, { path }) => `${describeStep(path)} action must be 2000 characters or fewer`), + check('steps.*.expected') + .optional() + .isString() + .isLength({ max: 2000 }) + .withMessage('Max length for a step expected result is 2000 characters'), + check('steps.*.data') + .optional() + .isString() + .isLength({ max: 2000 }) + .withMessage('Max length for step data is 2000 characters'), + check('steps.*.order') + .optional() + .isNumeric(), + // null or absent files the case at the team root. + check('folder') + .optional({ nullable: true }) + .isMongoId(), + check('customFields') + .optional() + .isObject() + .withMessage('customFields must be an object of field key to value'), + // Optional free-text reason, recorded against the change history entry. + check('comment') + .optional() + .isString() + .isLength({ max: 500 }) + .withMessage('Max length for a change comment is 500 characters'), +]; + +module.exports = (app, path) => { + // Manual testing is an optional feature; when an admin has turned it off every + // route below responds 404, so the data is unreachable and not merely hidden in the UI. + app.use(`${path}/manual-test-case`, featureMiddleware.requireManualTesting); + app.post(`${path}/manual-test-case`, [ + check('team') + .exists() + .isMongoId(), + check('component') + .optional() + .isMongoId(), + check('title') + .exists({ checkFalsy: true }) + .isString() + .isLength({ max: 200 }) + .withMessage('Max length for the test case title is 200 characters'), + ...contentValidators, + ], manualTestCaseController.create); + + app.get(`${path}/manual-test-case`, [ + // A folder id narrows to that folder; the literal "none" narrows to unfiled cases. + query('folder') + .optional() + .custom((value) => value === 'none' || /^[a-f\d]{24}$/i.test(value)) + .withMessage('folder must be a valid id, or "none" for unfiled test cases'), + query('includeSubFolders') + .optional() + .isBoolean(), + query('teamId') + .exists() + .isMongoId(), + query('status') + .optional() + .isString(), + query('priority') + .optional() + .isString(), + query('tags') + .optional() + .isString(), + // Interpolated into a $regex in the controller (escaped there), so bound the length + // here rather than leaving it entirely unvalidated. + query('search') + .optional() + .isString() + .isLength({ max: 100 }), + query('limit') + .optional() + .isNumeric(), + query('skip') + .optional() + .isNumeric(), + ], manualTestCaseController.findAll); + + app.get(`${path}/manual-test-case/:caseId`, [ + param('caseId') + .exists() + .isMongoId(), + ], manualTestCaseController.findOne); + + app.get(`${path}/manual-test-case/:caseId/history`, [ + param('caseId') + .exists() + .isMongoId(), + query('limit') + .optional() + .isNumeric(), + query('skip') + .optional() + .isNumeric(), + ], manualTestCaseController.findHistory); + + app.get(`${path}/manual-test-case/:caseId/version`, [ + param('caseId') + .exists() + .isMongoId(), + ], manualTestCaseController.findVersions); + + app.get(`${path}/manual-test-case/:caseId/version/:version`, [ + param('caseId') + .exists() + .isMongoId(), + param('version') + .exists() + .isInt({ min: 1 }), + ], manualTestCaseController.findVersion); + + app.put(`${path}/manual-test-case/:caseId`, [ + param('caseId') + .exists() + .isMongoId(), + check('component') + .optional() + .isMongoId(), + check('title') + .optional() + .isString() + .isLength({ min: 1, max: 200 }) + .withMessage('Max length for the test case title is 200 characters'), + ...contentValidators, + ], manualTestCaseController.update); + + app.post(`${path}/manual-test-case/:caseId/clone`, [ + param('caseId') + .exists() + .isMongoId(), + check('title') + .optional() + .isString() + .isLength({ min: 1, max: 200 }) + .withMessage('Max length for the test case title is 200 characters'), + check('comment') + .optional() + .isString() + .isLength({ max: 500 }) + .withMessage('Max length for a change comment is 500 characters'), + ], manualTestCaseController.clone); + + app.delete(`${path}/manual-test-case/:caseId`, [ + param('caseId') + .exists() + .isMongoId(), + check('comment') + .optional() + .isString() + .isLength({ max: 500 }) + .withMessage('Max length for a change comment is 500 characters'), + ], manualTestCaseController.delete); +}; diff --git a/app/routes/manual-test-run.routes.js b/app/routes/manual-test-run.routes.js new file mode 100644 index 0000000..a0bfeca --- /dev/null +++ b/app/routes/manual-test-run.routes.js @@ -0,0 +1,99 @@ +const { check, param, query } = require('express-validator'); +const manualTestRunController = require('../controllers/manual-test-run.controller.js'); +const { runStates, caseResultStates } = require('../models/manual-test-run.js'); +const featureMiddleware = require('../utils/feature-middleware.js'); + +const platformValidators = [ + check('platforms').optional().isArray(), + check('platforms.*.platformName').optional().isString(), + check('platforms.*.platformVersion').optional().isString(), + check('platforms.*.browserName').optional().isString(), + check('platforms.*.browserVersion').optional().isString(), + check('platforms.*.deviceName').optional().isString(), + check('platforms.*.userAgent').optional().isString(), + check('platforms.*.screenHeight').optional().isNumeric(), + check('platforms.*.screenWidth').optional().isNumeric(), + check('platforms.*.pixelRatio').optional().isNumeric(), +]; + +module.exports = (app, path) => { + // Manual testing is an optional feature; when an admin has turned it off every + // route below responds 404, so the data is unreachable and not merely hidden in the UI. + app.use(`${path}/manual-test-run`, featureMiddleware.requireManualTesting); + app.post(`${path}/manual-test-run`, [ + check('name') + .exists({ checkFalsy: true }) + .isString() + .isLength({ max: 200 }) + .withMessage('Max length for a test run name is 200 characters'), + check('description').optional().isString().isLength({ max: 5000 }), + check('team').exists().isMongoId(), + check('component').optional().isMongoId(), + check('environment').exists().isString(), + check('phase').optional().isString(), + check('assignedTo').optional().isMongoId(), + check('testCaseIds').optional().isArray(), + check('testCaseIds.*').optional().isMongoId(), + ...platformValidators, + ], manualTestRunController.create); + + app.get(`${path}/manual-test-run`, [ + query('teamId').exists().isMongoId(), + query('status').optional().isString(), + query('assignedTo').optional().isMongoId(), + query('limit').optional().isNumeric(), + query('skip').optional().isNumeric(), + ], manualTestRunController.findAll); + + app.get(`${path}/manual-test-run/:runId`, [ + param('runId').exists().isMongoId(), + query('expand').optional().isBoolean(), + ], manualTestRunController.findOne); + + app.put(`${path}/manual-test-run/:runId`, [ + param('runId').exists().isMongoId(), + check('name').optional().isString().isLength({ min: 1, max: 200 }), + check('description').optional().isString().isLength({ max: 5000 }), + check('assignedTo').optional().isMongoId(), + check('testCaseIds').optional().isArray(), + check('testCaseIds.*').optional().isMongoId(), + ...platformValidators, + ], manualTestRunController.update); + + app.put(`${path}/manual-test-run/:runId/test-case/:caseId/result`, [ + param('runId').exists().isMongoId(), + param('caseId').exists().isMongoId(), + check('status') + .exists() + .isIn(caseResultStates) + .withMessage(`Status must be one of [${caseResultStates.join(', ')}]`), + check('notes').optional().isString().isLength({ max: 5000 }), + check('stepResults').optional().isArray(), + check('stepResults.*.stepId').optional().isMongoId(), + check('stepResults.*.status') + .optional() + .isIn(['NOT_RUN', 'PASS', 'FAIL', 'BLOCKED', 'SKIPPED']) + .withMessage('Step status must be one of [NOT_RUN, PASS, FAIL, BLOCKED, SKIPPED]'), + check('stepResults.*.actual').optional().isString().isLength({ max: 5000 }), + check('stepResults.*.notes').optional().isString().isLength({ max: 5000 }), + check('stepResults.*.attachments').optional().isArray(), + check('stepResults.*.attachments.*').optional().isMongoId(), + ], manualTestRunController.recordResult); + + app.put(`${path}/manual-test-run/:runId/test-case/:caseId/rebind`, [ + param('runId').exists().isMongoId(), + param('caseId').exists().isMongoId(), + ], manualTestRunController.rebind); + + app.put(`${path}/manual-test-run/:runId/status`, [ + param('runId').exists().isMongoId(), + check('status') + .exists() + .isIn(runStates) + .withMessage(`Status must be one of [${runStates.join(', ')}]`), + ], manualTestRunController.updateStatus); + + app.delete(`${path}/manual-test-run/:runId`, [ + param('runId').exists().isMongoId(), + ], manualTestRunController.delete); +}; diff --git a/app/routes/metrics.routes.js b/app/routes/metrics.routes.js index 2dc22a2..6a0231d 100644 --- a/app/routes/metrics.routes.js +++ b/app/routes/metrics.routes.js @@ -14,6 +14,10 @@ module.exports = (app, path) => { query('componentId') .optional() .isMongoId(), + query('executionType') + .optional() + .isIn(['automated', 'manual']) + .withMessage('executionType must be either automated or manual'), query('fromDate') .optional() .isISO8601() diff --git a/app/routes/settings.routes.js b/app/routes/settings.routes.js index 719be54..592c654 100644 --- a/app/routes/settings.routes.js +++ b/app/routes/settings.routes.js @@ -168,6 +168,17 @@ module.exports = (app, path) => { check('localAuthEnabled').optional().isBoolean().withMessage('localAuthEnabled must be a boolean.'), body('providers').optional().custom(validateProviders), ], authMiddleware.isAuthenticated, authMiddleware.authorizeAdmin, settings.updateAuthSettings); + + app.get( + `${path}/settings/features`, + authMiddleware.isAuthenticated, + authMiddleware.authorizeAdmin, + settings.getFeatureSettings, + ); + + app.put(`${path}/settings/features`, [ + check('manualTestingEnabled').optional().isBoolean().withMessage('manualTestingEnabled must be a boolean.'), + ], authMiddleware.isAuthenticated, authMiddleware.authorizeAdmin, settings.updateFeatureSettings); }; module.exports.validateProviders = validateProviders; diff --git a/app/routes/shared-step.routes.js b/app/routes/shared-step.routes.js new file mode 100644 index 0000000..b80636a --- /dev/null +++ b/app/routes/shared-step.routes.js @@ -0,0 +1,126 @@ +const { check, param, query } = require('express-validator'); +const sharedStepController = require('../controllers/shared-step.controller.js'); +const featureMiddleware = require('../utils/feature-middleware.js'); + +// Shared by create and update. A shared step's steps are literal, so `sharedStep` is not +// accepted on them - the controller rejects any that slip through with an explanatory 400. +const stepValidators = [ + check('description') + .optional() + .isString() + .isLength({ max: 5000 }) + .withMessage('Max length for the description is 5000 characters'), + check('steps') + .optional() + .isArray(), + check('steps.*.action') + .exists() + .isString() + .isLength({ max: 2000 }) + .withMessage('Each step requires an action (max 2000 characters)'), + check('steps.*.expected') + .optional() + .isString() + .isLength({ max: 2000 }) + .withMessage('Max length for a step expected result is 2000 characters'), + check('steps.*.data') + .optional() + .isString() + .isLength({ max: 2000 }) + .withMessage('Max length for step data is 2000 characters'), + check('steps.*.order') + .optional() + .isNumeric(), + // Optional free-text reason, recorded against the change history entry. + check('comment') + .optional() + .isString() + .isLength({ max: 500 }) + .withMessage('Max length for a change comment is 500 characters'), +]; + +module.exports = (app, path) => { + // Manual testing is an optional feature; when an admin has turned it off every + // route below responds 404, so the data is unreachable and not merely hidden in the UI. + app.use(`${path}/shared-step`, featureMiddleware.requireManualTesting); + app.post(`${path}/shared-step`, [ + check('team') + .exists() + .isMongoId(), + check('name') + .exists({ checkFalsy: true }) + .isString() + .isLength({ min: 1, max: 150 }) + .withMessage('Max length for a shared step name is 150 characters'), + check('steps') + .exists() + .custom((steps) => Array.isArray(steps) && steps.length > 0) + .withMessage('At least one step is required'), + ...stepValidators, + ], sharedStepController.create); + + app.get(`${path}/shared-step`, [ + query('teamId') + .exists() + .isMongoId(), + // Interpolated into a $regex in the controller (escaped there), so bound the length + // here rather than leaving it entirely unvalidated. + query('search') + .optional() + .isString() + .isLength({ max: 100 }), + query('limit') + .optional() + .isNumeric(), + query('skip') + .optional() + .isNumeric(), + ], sharedStepController.findAll); + + app.get(`${path}/shared-step/:sharedStepId`, [ + param('sharedStepId') + .exists() + .isMongoId(), + ], sharedStepController.findOne); + + app.get(`${path}/shared-step/:sharedStepId/usage`, [ + param('sharedStepId') + .exists() + .isMongoId(), + ], sharedStepController.findUsage); + + app.get(`${path}/shared-step/:sharedStepId/history`, [ + param('sharedStepId') + .exists() + .isMongoId(), + query('limit') + .optional() + .isNumeric(), + query('skip') + .optional() + .isNumeric(), + ], sharedStepController.findHistory); + + app.put(`${path}/shared-step/:sharedStepId`, [ + param('sharedStepId') + .exists() + .isMongoId(), + check('name') + .optional() + .isString() + .isLength({ min: 1, max: 150 }) + .withMessage('Max length for a shared step name is 150 characters'), + ...stepValidators, + ], sharedStepController.update); + + app.delete(`${path}/shared-step/:sharedStepId`, [ + param('sharedStepId') + .exists() + .isMongoId(), + check('comment') + .optional() + .isString() + .isLength({ max: 500 }) + .withMessage('Max length for a change comment is 500 characters'), + ], sharedStepController.delete); +}; diff --git a/app/utils/attachment-utils.js b/app/utils/attachment-utils.js new file mode 100644 index 0000000..1667324 --- /dev/null +++ b/app/utils/attachment-utils.js @@ -0,0 +1,136 @@ +const fs = require('fs'); +const path = require('path'); +const debug = require('debug'); +const { rimraf } = require('rimraf'); +const jimp = require('jimp'); +const Attachment = require('../models/attachment.js'); +const ManualTestCaseVersion = require('../models/manual-test-case-version.js'); +const { ATTACHMENT_ROOT } = require('./multer-config-attachments.js'); + +const log = debug('attachment:utils'); +const attachmentUtils = {}; + +attachmentUtils.ATTACHMENT_ROOT = ATTACHMENT_ROOT; + +const THUMBNAIL_MAX = 300; + +/* +Writes a .thumb.png beside the original and returns { thumbnail, width, height }. + +The thumbnail is a file rather than a base64 string on the document (which is what +Screenshot does): a test case with thirty attachments would otherwise carry thirty base64 +blobs in a single response, and the authoring UI lists them constantly. + +A file that cannot be read as an image still yields a usable attachment - the original is +served either way - so failure here is logged and the thumbnail left unset rather than +failing the upload. + */ +attachmentUtils.generateThumbnail = async (filePath) => { + try { + const image = await jimp.read(filePath); + const { width, height } = image.bitmap; + const thumbnailPath = `${filePath}.thumb.png`; + await image + .scaleToFit(THUMBNAIL_MAX, THUMBNAIL_MAX) + .quality(72) + .writeAsync(thumbnailPath); + return { thumbnail: thumbnailPath, width, height }; + } catch (error) { + log(`Could not generate a thumbnail for ${filePath}: ${error.message}`); + return {}; + } +}; + +/* +Counts the frozen versions whose steps reference this attachment. + +This is the check that makes an attachment immutable once a version has been written: a +historical execution has to render the image the tester actually saw, and the file is the +only copy of it. + */ +attachmentUtils.countVersionReferences = (attachmentId) => ManualTestCaseVersion + .countDocuments({ 'steps.attachments': attachmentId }) + .exec(); + +/* +Removes an attachment's files from disk. Missing files are not an error - the document is +the record of truth and a half-removed upload should still be collectable. + */ +attachmentUtils.removeFiles = async (attachment) => { + const targets = [attachment.path, attachment.thumbnail].filter(Boolean); + await Promise.all(targets.map(async (target) => { + const resolved = path.resolve(target); + // Never unlink outside the attachment root, whatever the stored path says. + if (!resolved.startsWith(ATTACHMENT_ROOT + path.sep)) { + log(`Refusing to remove a file outside the attachment root: ${resolved}`); + return; + } + try { + await fs.promises.unlink(resolved); + log(`Removed attachment file ${resolved}`); + } catch (error) { + if (error.code !== 'ENOENT') { + log(`Could not remove ${resolved}: ${error.message}`); + } + } + })); +}; + +/* +Removes an entity's attachment directory if it is empty. + +multer creates the directory in its `destination` handler, before the request has been +authorised or the owner confirmed to exist, so a rejected upload leaves an empty directory +behind. Only removed when empty, so this can never take a concurrent upload's files with +it. + */ +attachmentUtils.removeDirectoryIfEmpty = async (directory) => { + const resolved = path.resolve(directory); + if (!resolved.startsWith(ATTACHMENT_ROOT + path.sep)) { + return; + } + try { + const entries = await fs.promises.readdir(resolved); + if (entries.length === 0) { + await fs.promises.rmdir(resolved); + log(`Removed empty attachment directory ${resolved}`); + } + } catch (error) { + if (error.code !== 'ENOENT') { + log(`Could not tidy ${resolved}: ${error.message}`); + } + } +}; + +/* +Removes the whole attachment directory for an entity, mirroring +imageUtils.removeScreenshotDirectories. + +Used when a test case or shared step is deleted: its versions go first, so nothing +references the attachments by then. + */ +attachmentUtils.removeAttachmentDirectory = async (ownerId) => { + const directory = path.join(ATTACHMENT_ROOT, ownerId.toString()); + // This is a recursive delete, so never let a malformed id escape the attachment root. + if (!directory.startsWith(ATTACHMENT_ROOT + path.sep)) { + log(`Refusing to remove directory outside the attachment root: ${directory}`); + return; + } + await rimraf(directory); + log(`Removed attachment directory ${directory}`); +}; + +/* +Deletes every attachment belonging to an entity, files and documents alike. + +Called after the entity's frozen versions have been removed, so the immutability rule that +protects a referenced attachment no longer applies - the thing that referenced it is gone. + */ +attachmentUtils.removeAttachmentsForOwner = async (scope, ownerId) => { + const field = { testcase: 'testCase', sharedstep: 'sharedStep', execution: 'manualExecution' }[scope]; + const result = await Attachment.deleteMany({ [field]: ownerId }).exec(); + await attachmentUtils.removeAttachmentDirectory(ownerId); + return result.deletedCount || 0; +}; + +module.exports = attachmentUtils; diff --git a/app/utils/custom-field-utils.js b/app/utils/custom-field-utils.js new file mode 100644 index 0000000..fae9e99 --- /dev/null +++ b/app/utils/custom-field-utils.js @@ -0,0 +1,215 @@ +const mongoose = require('mongoose'); +const debug = require('debug'); +const CustomFieldDefinition = require('../models/custom-field-definition.js'); +const User = require('../models/user.js'); +const { InvalidRequestError } = require('../exceptions/errors.js'); + +const log = debug('custom-field:utils'); +const customFieldUtils = {}; + +const MONGO_ID_PATTERN = /^[a-f\d]{24}$/i; + +/* +Coerces one submitted value to the type its definition declares, returning either +{ value } or { error }. + +Coercion is deliberate rather than strict-typing: values arrive from JSON bodies and from +form inputs, so a number field legitimately receives "42" and a boolean receives "true". +What is not accepted is anything ambiguous - "" for a number, or an unparseable date - +because storing those silently would surface much later as a broken render. + */ +const coerceValue = (definition, value) => { + const { type, options } = definition; + + switch (type) { + case 'text': + case 'textarea': { + if (typeof value !== 'string') return { error: 'must be a string' }; + return { value }; + } + case 'number': { + if (typeof value === 'number') { + if (!Number.isFinite(value)) return { error: 'must be a finite number' }; + return { value }; + } + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return { value: parsed }; + } + return { error: 'must be a number' }; + } + case 'boolean': { + if (typeof value === 'boolean') return { value }; + if (value === 'true') return { value: true }; + if (value === 'false') return { value: false }; + return { error: 'must be a boolean' }; + } + case 'date': { + const parsed = new Date(value); + // An invalid Date still constructs; its time is NaN. + if (Number.isNaN(parsed.getTime())) return { error: 'must be a valid ISO-8601 date' }; + return { value: parsed }; + } + case 'select': { + if (typeof value !== 'string') return { error: 'must be a string' }; + if (!options.includes(value)) { + return { error: `must be one of: ${options.join(', ')}` }; + } + return { value }; + } + case 'multiselect': { + if (!Array.isArray(value)) return { error: 'must be an array' }; + const invalid = value.filter((entry) => !options.includes(entry)); + if (invalid.length > 0) { + return { error: `contains values that are not options: ${invalid.join(', ')}` }; + } + // Duplicates would render as repeated chips and mean nothing distinct. + return { value: [...new Set(value)] }; + } + case 'user': { + if (typeof value !== 'string' || !MONGO_ID_PATTERN.test(value)) { + return { error: 'must be a user id' }; + } + // Existence is checked separately - it needs a database round trip, which this + // synchronous coercion step deliberately does not do. + return { value, needsUserCheck: true }; + } + default: + return { error: `has an unknown field type "${type}"` }; + } +}; + +/* +Treats a submitted value as "not provided". Used for the required check and to decide +whether a default applies. `false` and `0` are real values, so only null, undefined, an +empty string and an empty array count as absent. + */ +const isEmpty = (value) => value === undefined + || value === null + || value === '' + || (Array.isArray(value) && value.length === 0); + +customFieldUtils.isEmpty = isEmpty; + +/* +Validates and coerces a customFields object against a team's field definitions. + +Every problem is collected and reported together rather than throwing on the first one: a +QA filling in six fields should see all six mistakes at once, not fix one 400 per save. + +`enforceRequired` is passed by the caller rather than derived here, because whether a +required field must be present depends on the status the test case is being saved with - +drafts are exempt so that authoring can be incremental. + +Returns a plain object suitable for assigning to a Mongoose Map. + */ +customFieldUtils.validateCustomFields = async (definitions, values, enforceRequired = false) => { + const errors = []; + const result = {}; + const submitted = values || {}; + + const byKey = new Map(definitions.map((definition) => [definition.key, definition])); + + // Unknown keys are rejected rather than dropped, so a typo in the admin UI or client + // surfaces immediately instead of silently discarding the value. + Object.keys(submitted).forEach((key) => { + if (!byKey.has(key)) { + errors.push(`"${key}" is not a configured custom field for this team`); + } + }); + + const userChecks = []; + + definitions.forEach((definition) => { + const { key } = definition; + const submittedValue = submitted[key]; + + if (isEmpty(submittedValue)) { + // An archived field never receives a default - it is on its way out, and applying + // one would quietly reintroduce it on every save. + if (!definition.archived && !isEmpty(definition.defaultValue)) { + const coerced = coerceValue(definition, definition.defaultValue); + if (!coerced.error) { + result[key] = coerced.value; + return; + } + // A default that does not satisfy its own definition is an admin configuration + // problem, not the author's. Log it and treat the field as empty rather than + // failing a save the author cannot fix. + log(`Default value for custom field "${key}" is invalid (${coerced.error}); ignoring it.`); + } + if (enforceRequired && definition.required && !definition.archived) { + errors.push(`"${definition.label}" is required`); + } + return; + } + + const coerced = coerceValue(definition, submittedValue); + if (coerced.error) { + errors.push(`"${definition.label}" ${coerced.error}`); + return; + } + if (coerced.needsUserCheck) { + userChecks.push({ key, label: definition.label, value: coerced.value }); + } + result[key] = coerced.value; + }); + + // One query for every user-typed field rather than one per field. + if (userChecks.length > 0) { + const ids = userChecks.map((check) => mongoose.Types.ObjectId(check.value)); + const found = await User.find({ _id: { $in: ids } }).select('_id').lean().exec(); + const foundIds = new Set(found.map((user) => user._id.toString())); + userChecks.forEach((check) => { + if (!foundIds.has(check.value)) { + errors.push(`"${check.label}" refers to a user that does not exist`); + delete result[check.key]; + } + }); + } + + if (errors.length > 0) { + throw new InvalidRequestError(`Invalid custom fields: ${errors.join('; ')}`); + } + + return result; +}; + +/* +Loads the field definitions that apply to a team's test cases. + +Archived fields are included: a test case may still carry a value for one, and that value +has to survive a round trip through validation rather than being rejected as an unknown +key. They are excluded from the required check and never receive defaults. + */ +customFieldUtils.getDefinitionsForTeam = (teamId) => CustomFieldDefinition + .find({ + team: teamId, + appliesTo: { $in: ['testcase', 'both'] }, + }) + .sort('order') + .lean() + .exec(); + +/* +Builds the fieldDefinitions snapshot stored on a frozen version, so an archived or +relabelled field still renders historical values with the label and type they had at the +time. Only the definitions the case actually holds a value for are recorded - snapshotting +the rest would bloat every version with fields the case never used. + */ +customFieldUtils.buildDefinitionSnapshot = (definitions, customFields) => { + if (!customFields) return []; + const keys = customFields instanceof Map + ? [...customFields.keys()] + : Object.keys(customFields); + return definitions + .filter((definition) => keys.includes(definition.key)) + .map((definition) => ({ + key: definition.key, + label: definition.label, + type: definition.type, + options: definition.options, + })); +}; + +module.exports = customFieldUtils; diff --git a/app/utils/domain-metrics.js b/app/utils/domain-metrics.js index 812cc25..b5e3f3f 100644 --- a/app/utils/domain-metrics.js +++ b/app/utils/domain-metrics.js @@ -56,9 +56,22 @@ const buildNameMap = (documents) => { */ const collectBuildMetrics = async (teamNames, environmentNames) => { const grouped = await Build.aggregate([ + // Defaulted rather than read straight off the document: builds written before manual + // runs existed have no executionType on disk, and would otherwise be labelled + // "unknown" instead of the automated runs they actually are. + { + $addFields: { + executionType: { $ifNull: ['$executionType', 'automated'] }, + }, + }, { $group: { - _id: { team: '$team', environment: '$environment', status: '$status' }, + _id: { + team: '$team', + environment: '$environment', + status: '$status', + executionType: '$executionType', + }, count: { $sum: 1 }, }, }, @@ -73,17 +86,22 @@ const collectBuildMetrics = async (teamNames, environmentNames) => { const byLabels = new Map(); let total = 0; + const byExecutionType = new Map(); grouped.forEach((group) => { - const { team, environment, status } = group._id; + const { + team, environment, status, executionType, + } = group._id; total += group.count; byStatus.set(status, (byStatus.get(status) || 0) + group.count); + byExecutionType.set(executionType, (byExecutionType.get(executionType) || 0) + group.count); const entry = { team: teamNames.get(team ? team.toString() : '') || 'unknown', environment: environmentNames.get(environment ? environment.toString() : '') || 'unknown', status: status || 'unknown', + executionType: executionType || 'automated', }; - const key = `${entry.team} ${entry.environment} ${entry.status}`; + const key = `${entry.team} ${entry.environment} ${entry.status} ${entry.executionType}`; const existing = byLabels.get(key); if (existing) { existing.count += group.count; @@ -92,7 +110,9 @@ const collectBuildMetrics = async (teamNames, environmentNames) => { } }); - return { total, byStatus, byTeam: Array.from(byLabels.values()) }; + return { + total, byStatus, byExecutionType, byTeam: Array.from(byLabels.values()), + }; }; /** @@ -102,15 +122,37 @@ const collectBuildMetrics = async (teamNames, environmentNames) => { */ const collectExecutionMetrics = async () => { const grouped = await Execution.aggregate([ - { $group: { _id: '$status', count: { $sum: 1 } } }, + { + $addFields: { + executionType: { $ifNull: ['$executionType', 'automated'] }, + }, + }, + { + $group: { + _id: { status: '$status', executionType: '$executionType' }, + count: { $sum: 1 }, + }, + }, ]); const byStatus = new Map(); + const byStatusAndType = new Map(); let total = 0; grouped.forEach((group) => { + const status = group._id.status || 'unknown'; + const executionType = group._id.executionType || 'automated'; total += group.count; - byStatus.set(group._id || 'unknown', group.count); + byStatus.set(status, (byStatus.get(status) || 0) + group.count); + // Keyed by the label pair so two id-groups collapsing onto one label set are summed + // rather than emitted twice - Prometheus rejects a scrape with duplicate series. + const key = `${status} ${executionType}`; + const existing = byStatusAndType.get(key); + if (existing) { + existing.count += group.count; + } else { + byStatusAndType.set(key, { status, executionType, count: group.count }); + } }); - return { total, byStatus }; + return { total, byStatus, byStatusAndType: Array.from(byStatusAndType.values()) }; }; /** diff --git a/app/utils/feature-middleware.js b/app/utils/feature-middleware.js new file mode 100644 index 0000000..50cb59f --- /dev/null +++ b/app/utils/feature-middleware.js @@ -0,0 +1,18 @@ +const featureConfig = require('../../config/feature.config.js'); + +/** + * Blocks a route group when its feature toggle is off. + * + * 404 rather than 403: a disabled feature does not exist on this instance, and saying + * "forbidden" would imply the caller could be granted access to it. It also matches how + * the UI treats the pages - they are gone, not locked. + * + * The toggle is read from the in-memory featureConfig, which the feature-settings service + * keeps current, so this costs no database round trip. + */ +exports.requireManualTesting = (req, res, next) => { + if (featureConfig.manualTestingEnabled === false) { + return res.status(404).json({ error: 'Manual test case management is not enabled on this instance.' }); + } + return next(); +}; diff --git a/app/utils/feature-settings-service.js b/app/utils/feature-settings-service.js new file mode 100644 index 0000000..65f85b7 --- /dev/null +++ b/app/utils/feature-settings-service.js @@ -0,0 +1,85 @@ +const debug = require('debug'); +const FeatureSettings = require('../models/feature-settings.js'); +const featureConfig = require('../../config/feature.config.js'); + +const log = debug('features:settings'); + +/** + * Reduces a settings document to the plain object returned by the API. There are no + * secrets here, but going through an explicit projection keeps mongoose internals + * (_id, __v, timestamps) out of the payload. + */ +const toPublic = (settings) => ({ + manualTestingEnabled: settings.manualTestingEnabled !== false, +}); + +/** + * Mirrors the persisted toggles onto the live in-memory featureConfig so request-time + * consumers (the route guard) read current values without a database round trip per + * request, and without a restart after an admin saves. + */ +const applyToRuntime = (settings) => { + featureConfig.manualTestingEnabled = settings.manualTestingEnabled !== false; +}; + +/** + * Loads the settings document, creating it on first run from the deployment-provided + * seeds (see config/feature.config.js) so an instance can ship with a feature already + * turned off. + * + * Seeding is create-if-missing, matching how the initial admin account is provisioned: + * once the document exists it is authoritative and the environment is not consulted + * again, so a restart never reverts a change an admin made in the UI. + */ +const loadDoc = async () => { + const settings = await FeatureSettings.findOne({ singleton: 'features' }); + if (settings) return settings; + + log('Created feature settings document from environment seeds.'); + return FeatureSettings.create({ + singleton: 'features', + manualTestingEnabled: featureConfig.seeds.manualTestingEnabled, + }); +}; + +/** + * Loads settings and mirrors them onto the live featureConfig. Called at startup. + */ +const loadFeatureSettings = async () => { + const settings = await loadDoc(); + applyToRuntime(settings); + return settings; +}; + +/** + * Returns the current toggles, seeding the document on first access. + */ +const getFeatureSettings = async () => { + const settings = await loadDoc(); + return toPublic(settings); +}; + +/** + * Persists the supplied toggles and mirrors them onto the live featureConfig. Only keys + * present in the payload are changed, so a partial update leaves other features alone. + */ +const updateFeatureSettings = async (payload = {}) => { + const settings = await loadDoc(); + + if (payload.manualTestingEnabled !== undefined) { + settings.manualTestingEnabled = payload.manualTestingEnabled; + } + + await settings.save(); + applyToRuntime(settings); + log('Feature settings updated.'); + return toPublic(settings); +}; + +module.exports = { + loadFeatureSettings, + getFeatureSettings, + updateFeatureSettings, + applyToRuntime, + toPublic, +}; diff --git a/app/utils/history-utils.js b/app/utils/history-utils.js new file mode 100644 index 0000000..97c8f86 --- /dev/null +++ b/app/utils/history-utils.js @@ -0,0 +1,223 @@ +const debug = require('debug'); +const ManualChangeHistory = require('../models/manual-change-history.js'); +const { normaliseValue } = require('./manual-test-case-utils.js'); + +const log = debug('history:utils'); +const historyUtils = {}; + +// Fields worth auditing per entity type. Deliberately explicit rather than "every key +// that differs": timestamps, version counters and audit fields change on every save and +// would bury the changes a reader actually cares about. +historyUtils.TRACKED_PATHS = { + testcase: [ + 'title', + 'description', + 'preconditions', + 'status', + 'priority', + 'tags', + 'steps', + 'customFields', + 'component', + 'folder', + ], + sharedstep: ['name', 'description', 'steps'], + customfield: [ + 'label', + 'type', + 'options', + 'required', + 'defaultValue', + 'order', + 'appliesTo', + 'archived', + ], +}; + +// A steps array or customFields map compared as one blob would report "steps changed" and +// nothing more. These get broken down into per-entry changes instead. +const STEP_FIELDS = ['action', 'expected', 'data', 'order']; + +/* +Summarises a step for display in a history entry. The full subdocument carries _ids and +attachment arrays that make a diff unreadable; what a reader wants is the text. + */ +const summariseStep = (step) => { + if (!step) return null; + const plain = typeof step.toObject === 'function' ? step.toObject() : step; + return STEP_FIELDS.reduce((accumulator, field) => { + if (plain[field] !== undefined && plain[field] !== null && plain[field] !== '') { + // eslint-disable-next-line no-param-reassign + accumulator[field] = plain[field]; + } + return accumulator; + }, {}); +}; + +/* +Diffs two steps arrays into one change per position, rather than a single opaque entry. + +Compared by position because that is what an author sees: moving a step is a change to +both positions, which is accurate - the tester now does something different at step 3. + */ +const diffSteps = (before, after) => { + const beforeSteps = before || []; + const afterSteps = after || []; + const changes = []; + const length = Math.max(beforeSteps.length, afterSteps.length); + + for (let index = 0; index < length; index += 1) { + const from = beforeSteps[index]; + const to = afterSteps[index]; + const fromNormalised = JSON.stringify(normaliseValue(from)); + const toNormalised = JSON.stringify(normaliseValue(to)); + if (fromNormalised !== toNormalised) { + changes.push({ + field: `steps[${index}]`, + from: summariseStep(from), + to: summariseStep(to), + }); + } + } + return changes; +}; + +/* +Diffs two customFields maps into one change per key, so "which field did they change?" is +answerable without eyeballing two JSON blobs. + */ +const diffCustomFields = (before, after) => { + const toPlain = (value) => { + if (!value) return {}; + if (value instanceof Map) return Object.fromEntries(value.entries()); + return typeof value.toObject === 'function' ? value.toObject() : value; + }; + const beforePlain = toPlain(before); + const afterPlain = toPlain(after); + const keys = Array.from(new Set([...Object.keys(beforePlain), ...Object.keys(afterPlain)])); + + return keys.reduce((changes, key) => { + const from = beforePlain[key]; + const to = afterPlain[key]; + if (JSON.stringify(normaliseValue(from)) !== JSON.stringify(normaliseValue(to))) { + changes.push({ field: `customFields.${key}`, from, to }); + } + return changes; + }, []); +}; + +/* +Returns the field-level changes between two states of an entity. + +Only paths present on `after` are compared, so a partial update does not report every +field the caller omitted as having been cleared. + */ +historyUtils.diffDocuments = (before, after, trackedPaths) => { + const changes = []; + const beforeDoc = before || {}; + const afterDoc = after || {}; + + trackedPaths.forEach((field) => { + const from = beforeDoc[field]; + const to = afterDoc[field]; + if (to === undefined && from === undefined) return; + + if (field === 'steps') { + changes.push(...diffSteps(from, to)); + return; + } + if (field === 'customFields') { + changes.push(...diffCustomFields(from, to)); + return; + } + if (JSON.stringify(normaliseValue(from)) !== JSON.stringify(normaliseValue(to))) { + changes.push({ field, from, to }); + } + }); + + return changes; +}; + +/* +Writes one history entry. + +Never awaited by the request path. History is an audit trail, not part of the write it +describes: the change has already been saved and committed by the time this runs, so +failing the user's request because the log could not be written would be strictly worse +than losing the log line. Failures are logged and swallowed. + */ +historyUtils.recordChange = ({ + entityType, + entityId, + team, + action, + version, + changes, + user, + comment, + causedBy, +}) => { + const entry = new ManualChangeHistory({ + entityType, + entityId, + team, + action, + version, + changes: changes || [], + changedBy: user, + comment, + causedBy, + }); + return entry.save() + .catch((error) => { + log(`Could not record ${action} history for ${entityType} ${entityId}: ${error.message}`); + }); +}; + +/* +Convenience wrapper: diffs the two states and records the entry in one call. + */ +historyUtils.recordDiff = ({ + entityType, + entityId, + team, + action, + version, + before, + after, + user, + comment, + causedBy, +}) => historyUtils.recordChange({ + entityType, + entityId, + team, + action, + version, + changes: historyUtils.diffDocuments(before, after, historyUtils.TRACKED_PATHS[entityType]), + user, + comment, + causedBy, +}); + +/* +Reads an entity's history, newest first. + +`changedBy` is populated to username only - the user document carries apiTokens, which +must never leave the server through an audit endpoint. + */ +historyUtils.findHistory = async (entityId, limit = 20, skip = 0) => { + const query = { entityId }; + const [entries, count] = await Promise.all([ + ManualChangeHistory.find(query, null, { limit, skip }) + .populate('changedBy', 'username') + .populate('causedBy', 'name') + .sort('-changedAt') + .lean() + .exec(), + ManualChangeHistory.countDocuments(query).exec(), + ]); + return { entries, count }; +}; + +module.exports = historyUtils; diff --git a/app/utils/manual-folder-utils.js b/app/utils/manual-folder-utils.js new file mode 100644 index 0000000..21e339b --- /dev/null +++ b/app/utils/manual-folder-utils.js @@ -0,0 +1,153 @@ +const debug = require('debug'); +const ManualFolder = require('../models/manual-folder.js'); +const ManualTestCase = require('../models/manual-test-case.js'); +const { NotFoundError, InvalidRequestError } = require('../exceptions/errors.js'); + +const log = debug('manual-folder:utils'); + +const manualFolderUtils = {}; + +// A guard rather than a design limit. Nesting is arbitrary by intent, but a cycle or a +// runaway client should not be able to build a path the tree view cannot render. +manualFolderUtils.MAX_DEPTH = 20; + +/* +Resolves a parent id to the path and depth a child of it would have. + +A null parent means a root folder: empty path, depth 0. + */ +manualFolderUtils.resolvePlacement = async (parentId, team) => { + if (!parentId) { + return { parent: null, path: [], depth: 0 }; + } + const parent = await ManualFolder.findById(parentId).lean().exec(); + if (!parent || parent.team.toString() !== team.toString()) { + throw new NotFoundError(`No folder found for this team with id ${parentId}`); + } + const path = [...(parent.path || []), parent._id]; + if (path.length > manualFolderUtils.MAX_DEPTH) { + throw new InvalidRequestError(`Folders cannot be nested more than ${manualFolderUtils.MAX_DEPTH} levels deep.`); + } + return { parent: parent._id, path, depth: path.length }; +}; + +/* +Every folder at or below the given one, the folder itself included. + +One indexed query rather than a walk per level - this is what the materialised path buys. + */ +manualFolderUtils.getSubtree = async (folder) => { + const descendants = await ManualFolder + .find({ team: folder.team, path: folder._id }) + .lean() + .exec(); + return [folder, ...descendants]; +}; + +/* +What a folder still holds, counting the whole subtree. + +Used by the delete guard: deleting is refused while anything is inside, and the counts are +what let the message say what is in the way rather than just refusing. + */ +manualFolderUtils.getContents = async (folder) => { + const subtree = await manualFolderUtils.getSubtree(folder); + const folderIds = subtree.map((entry) => entry._id); + const [testCaseCount, subFolderCount] = await Promise.all([ + ManualTestCase.countDocuments({ folder: { $in: folderIds } }).exec(), + Promise.resolve(subtree.length - 1), + ]); + return { testCaseCount, subFolderCount }; +}; + +/* +Rejects a move that would put a folder inside itself. + +Re-parenting a folder under its own descendant detaches the whole subtree from the tree: +it becomes a ring that no root can reach, invisible to every query that starts from the +top and impossible to fix through the UI. + */ +manualFolderUtils.assertMoveIsLegal = (folder, newParent) => { + if (!newParent) return; + if (newParent._id.toString() === folder._id.toString()) { + throw new InvalidRequestError('A folder cannot be moved inside itself.'); + } + const ancestors = (newParent.path || []).map((id) => id.toString()); + if (ancestors.includes(folder._id.toString())) { + throw new InvalidRequestError('A folder cannot be moved inside one of its own sub-folders.'); + } +}; + +/* +Rewrites the stored paths of a moved folder's descendants. + +The subtree keeps its shape - only the ancestors above the moved folder change - so each +descendant's path has the old prefix swapped for the new one. Done in bulk: a team can +have a lot of folders and this is a single round trip. + */ +manualFolderUtils.rewriteSubtreePaths = async (folder, oldPath, newPath) => { + const descendants = await ManualFolder + .find({ team: folder.team, path: folder._id }) + .exec(); + if (descendants.length === 0) return 0; + + // Everything from the moved folder down keeps its shape; only the prefix above it moves. + const rewritten = descendants.map((descendant) => { + const suffix = (descendant.path || []).slice(oldPath.length); + return { _id: descendant._id, path: [...newPath, ...suffix] }; + }); + + // Checked before anything is written, so a move that would nest too deeply leaves the + // tree exactly as it was. + const deepest = Math.max(...rewritten.map((entry) => entry.path.length)); + if (deepest > manualFolderUtils.MAX_DEPTH) { + throw new InvalidRequestError(`That move would nest folders more than ${manualFolderUtils.MAX_DEPTH} levels deep.`); + } + + const operations = rewritten.map((entry) => ({ + updateOne: { + filter: { _id: entry._id }, + update: { $set: { path: entry.path, depth: entry.path.length } }, + }, + })); + await ManualFolder.bulkWrite(operations); + log(`Rewrote paths for ${operations.length} descendant folder(s) of ${folder._id}`); + return operations.length; +}; + +/* +Builds the nested tree the UI renders from a flat list. + +Done here rather than in the browser so every consumer sees the same shape, and so the +counts can be attached in the same pass. + */ +manualFolderUtils.buildTree = (folders, countsByFolder = {}) => { + const byId = new Map(); + folders.forEach((folder) => { + byId.set(folder._id.toString(), { + ...folder, + testCaseCount: countsByFolder[folder._id.toString()] || 0, + children: [], + }); + }); + const roots = []; + byId.forEach((node) => { + const parentId = node.parent ? node.parent.toString() : null; + const parent = parentId ? byId.get(parentId) : undefined; + if (parent) { + parent.children.push(node); + } else { + // A folder whose parent is missing is surfaced at the root rather than dropped - + // losing it silently would hide every test case filed under it. + roots.push(node); + } + }); + const sortByName = (nodes) => { + nodes.sort((a, b) => a.name.localeCompare(b.name)); + nodes.forEach((node) => sortByName(node.children)); + }; + sortByName(roots); + return roots; +}; + +module.exports = manualFolderUtils; diff --git a/app/utils/manual-step-utils.js b/app/utils/manual-step-utils.js new file mode 100644 index 0000000..47f455d --- /dev/null +++ b/app/utils/manual-step-utils.js @@ -0,0 +1,94 @@ +const debug = require('debug'); +const SharedStep = require('../models/shared-step.js'); + +const log = debug('manual-step:utils'); +const manualStepUtils = {}; + +/* +Returns the ids of every shared step referenced by a list of steps. + */ +manualStepUtils.collectSharedStepIds = (steps) => { + const ids = (steps || []) + .filter((step) => step.sharedStep) + .map((step) => step.sharedStep.toString()); + return Array.from(new Set(ids)); +}; + +/* +Expands every shared-step placeholder in a list of steps into the steps it references, +flattening the result and renumbering `order` so it stays contiguous. + +Expansion is deliberately a *copy*. Each expanded step carries `sharedStepRef` and +`sharedStepVersion` for display attribution, but it is a literal step - not a reference +that could resolve to something else later. That is what lets a frozen version store the +expanded form and stay immune to later edits of the shared step. + +An expanded step keeps the shared step's own `_id`, so a recorded step result binds to a +stable identity across cases that include the same shared step at the same version. + +A placeholder whose shared step no longer exists is preserved as a literal step rather +than dropped: silently losing steps from a test case would be far worse than showing one +that is flagged as unresolved. `unresolvedSharedStep` marks it so the UI can say so. + */ +manualStepUtils.expandSteps = (steps, sharedStepsById) => { + const expanded = []; + + (steps || []).forEach((step) => { + // Mongoose subdocuments need converting before spreading, plain objects do not. + const plain = typeof step.toObject === 'function' ? step.toObject() : { ...step }; + + if (!plain.sharedStep) { + expanded.push(plain); + return; + } + + const sharedStep = sharedStepsById.get(plain.sharedStep.toString()); + if (!sharedStep) { + log(`Shared step ${plain.sharedStep} referenced by a test case no longer exists; keeping the placeholder.`); + expanded.push({ + ...plain, + sharedStep: undefined, + sharedStepRef: plain.sharedStep, + unresolvedSharedStep: true, + action: plain.action || 'Unresolved shared step', + }); + return; + } + + (sharedStep.steps || []).forEach((sharedStepStep) => { + const sharedPlain = typeof sharedStepStep.toObject === 'function' + ? sharedStepStep.toObject() + : { ...sharedStepStep }; + expanded.push({ + ...sharedPlain, + // The expanded step is literal: it must not itself be a reference. + sharedStep: undefined, + sharedStepRef: sharedStep._id, + sharedStepVersion: sharedStep.version, + }); + }); + }); + + // Renumber so order is contiguous regardless of how many steps each inclusion produced. + return expanded.map((step, index) => ({ ...step, order: index + 1 })); +}; + +/* +Loads the shared steps a list of steps references and expands them. + +Returns the steps unchanged when nothing references a shared step, which is the common +case and avoids a pointless query. + */ +manualStepUtils.expandStepsFor = async (steps) => { + const ids = manualStepUtils.collectSharedStepIds(steps); + if (ids.length === 0) { + return (steps || []).map((step) => ( + typeof step.toObject === 'function' ? step.toObject() : { ...step } + )); + } + const sharedSteps = await SharedStep.find({ _id: { $in: ids } }).lean().exec(); + const byId = new Map(sharedSteps.map((sharedStep) => [sharedStep._id.toString(), sharedStep])); + return manualStepUtils.expandSteps(steps, byId); +}; + +module.exports = manualStepUtils; diff --git a/app/utils/manual-test-case-utils.js b/app/utils/manual-test-case-utils.js new file mode 100644 index 0000000..b6bb8ad --- /dev/null +++ b/app/utils/manual-test-case-utils.js @@ -0,0 +1,191 @@ +const debug = require('debug'); +const ManualTestCaseVersion = require('../models/manual-test-case-version.js'); + +const log = debug('manual-test-case:utils'); +const manualTestCaseUtils = {}; + +// The fields that make up a test case's *content*. A change to any of them produces a new +// immutable version; a change to anything else does not. +// +// `status` is deliberately absent: moving a case from DRAFT to ACTIVE is workflow state, +// not content, and versioning it would burn a version without changing what a tester sees. +// Same for `component` and the audit fields. +manualTestCaseUtils.CONTENT_FIELDS = [ + 'title', + 'description', + 'preconditions', + 'priority', + 'tags', + 'steps', + 'customFields', +]; + +/* +Normalises a value for content comparison so that differences which do not change what a +tester sees do not burn a version. + +Mongoose Maps, subdocument arrays and plain objects all have to compare equal to the plain +JSON the client sends, which is why this cannot just be a === or a JSON.stringify of the +raw values. + */ +const normaliseValue = (value) => { + if (value === undefined || value === null) return null; + // Mongoose Map -> plain object + if (value instanceof Map) { + return Object.fromEntries([...value.entries()].sort(([a], [b]) => a.localeCompare(b))); + } + if (Array.isArray(value)) { + return value.map((entry) => normaliseValue(entry)); + } + if (typeof value === 'object') { + // Mongoose subdocuments expose toObject; plain request bodies do not. + const plain = typeof value.toObject === 'function' ? value.toObject() : value; + return Object.keys(plain) + .filter((key) => !['_id', '__v', 'id'].includes(key)) + .sort() + .reduce((accumulator, key) => { + const normalised = normaliseValue(plain[key]); + // Treat an absent key and an explicitly empty one as the same, so a client that + // omits `expected` and one that sends '' do not produce different versions. + // + // An empty array counts as empty for the same reason: a stored step carries + // `attachments: []` from the schema default, while a client re-sending that step + // omits the key entirely. Without this, re-sending unchanged steps compares + // unequal and burns a version for no change. + const isEmpty = normalised === null + || normalised === '' + || (Array.isArray(normalised) && normalised.length === 0); + if (!isEmpty) { + // eslint-disable-next-line no-param-reassign + accumulator[key] = normalised; + } + return accumulator; + }, {}); + } + return value; +}; + +manualTestCaseUtils.normaliseValue = normaliseValue; + +/* +Returns true when the incoming body changes any content field of the existing case. + +Only fields actually present on the body are compared, so a PUT that omits a field leaves +it untouched rather than being read as "clear this field" (and so cannot silently burn a +version by blanking content the caller never mentioned). + */ +manualTestCaseUtils.hasContentChanged = (existingCase, body) => manualTestCaseUtils + .CONTENT_FIELDS + .filter((field) => body[field] !== undefined) + .some((field) => { + const before = JSON.stringify(normaliseValue(existingCase[field])); + const after = JSON.stringify(normaliseValue(body[field])); + return before !== after; + }); + +/* +Builds (but does not save) the immutable version document for the given case. + +`fieldDefinitions` is passed in rather than looked up here: phase 2 owns the definitions, +and until then every version is written with an empty array. + */ +manualTestCaseUtils.buildVersionDocument = ( + testCase, + userId, + fieldDefinitions = [], + steps = undefined, +) => ( + new ManualTestCaseVersion({ + testCase: testCase._id, + version: testCase.version, + team: testCase.team, + title: testCase.title, + description: testCase.description, + preconditions: testCase.preconditions, + priority: testCase.priority, + tags: testCase.tags, + // Callers pass the expanded steps: a version never stores a shared-step placeholder, + // because a reference would resolve to whatever the shared step says later rather + // than what the tester saw. The head's own steps are only correct for a case that + // includes no shared steps at all. + steps: steps === undefined ? testCase.steps : steps, + customFields: testCase.customFields, + fieldDefinitions, + createdBy: userId, + }) +); + +/* +Writes the frozen version document for a case. + +The version is always inserted *before* the head is saved by the caller, so a crash +between the two leaves an orphan version rather than a head whose `version` points at a +document that does not exist. An orphan is harmless - nothing references a version until +an execution binds to it - whereas a dangling head pointer would make the case +unreadable at that version. + */ +/* +Reconciles the head's version number with what is actually on disk. + +The head and its versions are written in two steps, so a crash between them can leave a +version at a number the head never reached. Because { testCase, version } is unique, the +next edit would try that number again and fail with a duplicate key error - and keep +failing, wedging the case for good. Advancing the head past anything already written turns +that into a recoverable state rather than a permanent one. + +Returns the version number the caller should write. + */ +manualTestCaseUtils.reconcileVersion = async (testCase) => { + const latest = await ManualTestCaseVersion + .findOne({ testCase: testCase._id }) + .sort('-version') + .select('version') + .lean() + .exec(); + if (latest && latest.version >= testCase.version) { + log(`Manual test case ${testCase._id} head is at v${testCase.version} but v${latest.version} exists on disk; advancing the head.`); + // eslint-disable-next-line no-param-reassign + testCase.version = latest.version + 1; + } + return testCase.version; +}; + +manualTestCaseUtils.saveVersion = ( + testCase, + userId, + fieldDefinitions = [], + steps = undefined, +) => { + const version = manualTestCaseUtils + .buildVersionDocument(testCase, userId, fieldDefinitions, steps); + log(`Writing version ${testCase.version} for manual test case ${testCase._id}`); + return version.save(); +}; + +/* +Resolves a specific frozen version of a case. + +Falls back to the head only when the case has no version documents at all, which can only +happen for data written before this collection existed. The fallback is logged because it +means that read is *not* guaranteed immutable, and a deployment seeing it regularly has +un-migrated data. + */ +manualTestCaseUtils.getVersion = async (testCase, versionNumber) => { + const version = await ManualTestCaseVersion + .findOne({ testCase: testCase._id, version: versionNumber }) + .lean() + .exec(); + if (version) { + return version; + } + const versionCount = await ManualTestCaseVersion + .countDocuments({ testCase: testCase._id }) + .exec(); + if (versionCount === 0) { + log(`No version documents exist for manual test case ${testCase._id}; falling back to the head. This case predates version tracking.`); + return null; + } + return undefined; +}; + +module.exports = manualTestCaseUtils; diff --git a/app/utils/manual-test-run-utils.js b/app/utils/manual-test-run-utils.js new file mode 100644 index 0000000..f6dd3b8 --- /dev/null +++ b/app/utils/manual-test-run-utils.js @@ -0,0 +1,203 @@ +const debug = require('debug'); +const ManualTestCase = require('../models/manual-test-case.js'); +const ManualTestCaseVersion = require('../models/manual-test-case-version.js'); +const TestExecution = require('../models/execution.js'); +const { EXECUTION_STATUS_BY_CASE_STATUS } = require('../models/manual-test-run.js'); +const buildMetricsUtils = require('./build-utils.js'); +const { NotFoundError, InvalidRequestError } = require('../exceptions/errors.js'); + +const log = debug('manual-test-run:utils'); +const manualTestRunUtils = {}; + +manualTestRunUtils.EXECUTION_STATUS_BY_CASE_STATUS = EXECUTION_STATUS_BY_CASE_STATUS; + +/* +Resolves a list of test case ids to run entries bound to each case's *current* version. + +The binding is the point of the whole exercise: the run points at an immutable version +document, so editing the case afterwards cannot change what the tester is asked to do, and +the execution written at the end records exactly which content was run. + +Rejects a DEPRECATED case - deprecating is how a team retires a test, and letting one into +a new run silently would defeat the status. Also rejects a case from a different component +than the run's, because the backing build carries a single component and a mismatch would +make the component metrics wrong. + */ +manualTestRunUtils.bindTestCases = async (testCaseIds, team, component) => { + const uniqueIds = Array.from(new Set(testCaseIds.map((id) => id.toString()))); + const testCases = await ManualTestCase.find({ _id: { $in: uniqueIds }, team }).lean().exec(); + + const byId = new Map(testCases.map((testCase) => [testCase._id.toString(), testCase])); + const missing = uniqueIds.filter((id) => !byId.has(id)); + if (missing.length > 0) { + throw new NotFoundError(`No manual test case found for this team with id(s): ${missing.join(', ')}`); + } + + const deprecated = testCases.filter((testCase) => testCase.status === 'DEPRECATED'); + if (deprecated.length > 0) { + const titles = deprecated.map((testCase) => `"${testCase.title}"`).join(', '); + throw new InvalidRequestError(`Cannot add deprecated test case(s) to a run: ${titles}. Reactivate them first.`); + } + + if (component) { + const mismatched = testCases.filter((testCase) => testCase.component + && testCase.component.toString() !== component.toString()); + if (mismatched.length > 0) { + const titles = mismatched.map((testCase) => `"${testCase.title}"`).join(', '); + throw new InvalidRequestError(`Test case(s) ${titles} belong to a different component than this run.`); + } + } + + // One query for every version rather than one per case. + const versions = await ManualTestCaseVersion.find({ + $or: testCases.map((testCase) => ({ testCase: testCase._id, version: testCase.version })), + }).lean().exec(); + const versionByCase = new Map(versions.map((version) => [version.testCase.toString(), version])); + + return uniqueIds.map((id) => { + const testCase = byId.get(id); + const version = versionByCase.get(id); + if (!version) { + // Only reachable for a case written before version tracking existed. Binding to + // nothing would leave the run unable to say what was executed, so it is refused + // rather than silently falling back to the mutable head. + throw new NotFoundError(`Manual test case "${testCase.title}" has no frozen version ${testCase.version} to bind to.`); + } + return { + testCase: testCase._id, + testCaseVersion: version._id, + versionNumber: version.version, + snapshotTitle: version.title, + status: 'NOT_RUN', + stepResults: [], + }; + }); +}; + +/* +Builds the TestExecution actions array from a frozen version and the recorded step results. + +The automated model already has the right shape: an execution holds actions, each holding +steps with name/expected/actual/status. A manual case becomes a single action whose steps +are the case's steps, so nothing downstream has to know the difference. + */ +manualTestRunUtils.buildActions = (version, stepResults, recordedAt) => { + const resultByStepId = new Map( + (stepResults || []) + .filter((result) => result.stepId) + .map((result) => [result.stepId.toString(), result]), + ); + + const steps = (version.steps || []).map((step, index) => { + // Bound by the frozen step's _id so a later reorder cannot pull a result onto the + // wrong step; falls back to position for a client that sends results in order. + const result = resultByStepId.get(step._id.toString()) || (stepResults || [])[index] || {}; + return { + name: step.action, + expected: step.expected, + actual: result.actual, + info: result.notes, + status: manualTestRunUtils.stepStatusFor(result.status), + timestamp: result.timestamp || recordedAt, + attachments: result.attachments, + }; + }); + + return [{ + name: version.title, + steps, + start: recordedAt, + end: recordedAt, + }]; +}; + +/* +Maps a step result state to the Step status enum, which is a different (larger) set than +the execution states - it carries INFO and DEBUG for automated logging. + */ +manualTestRunUtils.stepStatusFor = (status) => { + switch (status) { + case 'PASS': return 'PASS'; + case 'FAIL': return 'FAIL'; + // A blocked or skipped step was not verified. INFO records it without contributing a + // failure, matching how the automated model treats non-assertive steps. + case 'BLOCKED': + case 'SKIPPED': + case 'NOT_RUN': + default: return 'INFO'; + } +}; + +/* +Creates or updates the TestExecution for one case of a run, then folds it into the backing +build. + +Deliberately routed through buildMetricsUtils.addExecutionToBuild rather than recomputing +the build here: that function owns the suite grouping, the result map and the +optimistic-concurrency retry loop, and manual results must land in the build exactly the +way automated ones do or the two would drift. + */ +manualTestRunUtils.recordExecution = async ({ + run, + runCase, + version, + status, + user, +}) => { + const recordedAt = new Date(); + const executionStatus = EXECUTION_STATUS_BY_CASE_STATUS[status] || 'SKIPPED'; + const actions = manualTestRunUtils.buildActions(version, runCase.stepResults, recordedAt); + + const payload = { + title: version.title, + // Groups every case of a run under one suite named for the run, mirroring how an + // automated framework groups a spec file's tests. + suite: run.name, + build: run.build, + start: runCase.start || recordedAt, + end: recordedAt, + actions, + platforms: run.platforms, + status: executionStatus, + executionType: 'manual', + manualTestCase: runCase.testCase, + manualTestCaseVersion: runCase.testCaseVersion, + versionNumber: runCase.versionNumber, + executedBy: user, + }; + + let execution; + if (runCase.execution) { + // Re-recording a result updates the existing execution rather than adding a second + // one, so the build's totals stay equal to the number of cases in the run. + execution = await TestExecution + .findByIdAndUpdate(runCase.execution, payload, { new: true }) + .exec(); + } + if (!execution) { + execution = await new TestExecution(payload).save(); + } + + log(`Recorded manual execution ${execution._id} (${executionStatus}) for run ${run._id}`); + await buildMetricsUtils.addExecutionToBuild(run.build, execution); + return execution; +}; + +/* +Derives a run's overall status from its cases, so the run list does not lie about progress. + +A run is only COMPLETED once every case has a terminal result; any case in flight makes it +IN_PROGRESS. CANCELLED is set explicitly and never derived. + */ +manualTestRunUtils.deriveStatus = (run) => { + if (run.status === 'CANCELLED') return 'CANCELLED'; + const cases = run.testCases || []; + if (cases.length === 0) return run.status; + const outstanding = cases.filter((runCase) => runCase.status === 'NOT_RUN' + || runCase.status === 'IN_PROGRESS'); + if (outstanding.length === 0) return 'COMPLETED'; + if (outstanding.length === cases.length) return run.status === 'PLANNED' ? 'PLANNED' : 'IN_PROGRESS'; + return 'IN_PROGRESS'; +}; + +module.exports = manualTestRunUtils; diff --git a/app/utils/multer-config-attachments.js b/app/utils/multer-config-attachments.js new file mode 100644 index 0000000..686b7aa --- /dev/null +++ b/app/utils/multer-config-attachments.js @@ -0,0 +1,71 @@ +const multer = require('multer'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +// Attachments are always written beneath this directory. Resolved from __dirname (not the +// process CWD) so it matches attachment-utils and stays correct regardless of where the +// process was started from. +const ATTACHMENT_ROOT = path.resolve(__dirname, '../../attachments'); + +// multer joins destination + filename with no sanitising of its own, and `originalname` +// is attacker-controlled, so both halves have to be constrained here. The owner id is +// also validated by express-validator on the route, but that runs *after* multer has +// already written the file to disk, so it cannot be relied on for this. +const MONGO_ID_PATTERN = /^[a-f\d]{24}$/i; + +// The upload is only accepted for mime types we can map to a known-safe extension; the +// client filename is discarded entirely rather than sanitised. +const EXTENSION_BY_MIME = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/bmp': '.bmp', + 'image/tiff': '.tiff', +}; + +// Files are grouped by the entity they belong to, so deleting that entity is a single +// recursive remove of one directory - the same shape as screenshots/. +const ownerIdFor = (body) => body.testCaseId || body.sharedStepId || body.executionId; + +const multerConfig = multer({ + limits: { fileSize: 10485760 }, + storage: multer.diskStorage({ + destination(req, file, next) { + const ownerId = ownerIdFor(req.body); + if (!MONGO_ID_PATTERN.test(ownerId || '')) { + return next(new Error('A valid testCaseId, sharedStepId or executionId is required to upload an attachment')); + } + const directory = path.join(ATTACHMENT_ROOT, ownerId); + // Defence in depth: even with the pattern above, never write outside the root. + if (directory !== ATTACHMENT_ROOT && !directory.startsWith(ATTACHMENT_ROOT + path.sep)) { + return next(new Error('A valid testCaseId, sharedStepId or executionId is required to upload an attachment')); + } + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + } + return next(null, directory); + }, + filename(req, file, next) { + const extension = EXTENSION_BY_MIME[file.mimetype]; + if (!extension) { + return next(new Error('Only image files are supported')); + } + const unique = crypto.randomBytes(8).toString('hex'); + return next(null, `${Date.now()}-${unique}${extension}`); + }, + }), + fileFilter(req, file, next) { + if (!file) { + return next(null, false); + } + if (Object.prototype.hasOwnProperty.call(EXTENSION_BY_MIME, file.mimetype)) { + return next(null, true); + } + return next(new Error('Only image files are supported')); + }, +}); + +module.exports = multerConfig; +module.exports.ATTACHMENT_ROOT = ATTACHMENT_ROOT; diff --git a/app/utils/shared-step-utils.js b/app/utils/shared-step-utils.js new file mode 100644 index 0000000..111eb44 --- /dev/null +++ b/app/utils/shared-step-utils.js @@ -0,0 +1,112 @@ +const debug = require('debug'); +const ManualTestCase = require('../models/manual-test-case.js'); +const SharedStep = require('../models/shared-step.js'); +const manualStepUtils = require('./manual-step-utils.js'); +const manualTestCaseUtils = require('./manual-test-case-utils.js'); +const customFieldUtils = require('./custom-field-utils.js'); +const historyUtils = require('./history-utils.js'); + +const log = debug('shared-step:utils'); +const sharedStepUtils = {}; + +/* +Finds every test case whose head includes the given shared step. + */ +sharedStepUtils.findReferencingCases = (sharedStepId, projection = null) => ManualTestCase + .find({ 'steps.sharedStep': sharedStepId }, projection) + .exec(); + +sharedStepUtils.countReferencingCases = (sharedStepId) => ManualTestCase + .countDocuments({ 'steps.sharedStep': sharedStepId }) + .exec(); + +/* +Cascades an edited shared step into every test case that includes it. + +Each referencing case gets its version incremented and a fresh frozen version written, +re-expanded against the new shared step content. The case's *head* is untouched apart +from the version counter - it still holds the placeholder, so it keeps tracking the +shared step - but every version from here on records the new expansion. + +This is what keeps history exact. A version stores expanded steps, so versions written +before this edit still show the old content; without the cascade the live case would show +new content while its latest version showed old, and the two would disagree about what +"current" means. + +Failures are collected rather than thrown: a shared step edit that has already been saved +must not be reported as failed because one of forty cases could not be versioned. The +caller logs and reports the count. + */ +sharedStepUtils.cascadeToTestCases = async (sharedStep, userId) => { + const cases = await sharedStepUtils.findReferencingCases(sharedStep._id); + if (cases.length === 0) { + return { versioned: 0, failed: [] }; + } + + const sharedStepsById = new Map([[sharedStep._id.toString(), sharedStep]]); + const failed = []; + let versioned = 0; + + // Sequential on purpose: each case is an independent read-modify-write against its own + // document, and firing forty of them at once would contend for the same connection pool + // for no benefit on what is already a rare admin-triggered operation. + /* eslint-disable no-await-in-loop, no-restricted-syntax */ + for (const testCase of cases) { + try { + // Other shared steps this case references also need resolving, so the expansion + // reflects the whole case rather than only the edited step. + const otherIds = manualStepUtils.collectSharedStepIds(testCase.steps) + .filter((id) => id !== sharedStep._id.toString()); + const resolved = new Map(sharedStepsById); + if (otherIds.length > 0) { + const others = await SharedStep.find({ _id: { $in: otherIds } }).lean().exec(); + others.forEach((other) => resolved.set(other._id.toString(), other)); + } + + const expandedSteps = manualStepUtils.expandSteps(testCase.steps, resolved); + const definitions = await customFieldUtils.getDefinitionsForTeam(testCase.team); + const snapshot = customFieldUtils + .buildDefinitionSnapshot(definitions, testCase.customFields); + + testCase.version += 1; + // Recovers a case whose head fell behind its versions - see reconcileVersion. + await manualTestCaseUtils.reconcileVersion(testCase); + testCase.updatedBy = userId; + // Validated before the version is written, so a case the cascade cannot save does + // not leave an orphan version behind that wedges every later edit. + await testCase.validate(); + // The version document carries the expanded steps; the head keeps its placeholders. + await manualTestCaseUtils.saveVersion( + testCase, + userId, + snapshot, + expandedSteps, + ); + await testCase.save(); + + // Records why a case changed when nobody edited it directly. Without this the + // history shows an unexplained version bump against every case that happens to + // include the shared step. + historyUtils.recordChange({ + entityType: 'testcase', + entityId: testCase._id, + team: testCase.team, + action: 'SHARED_STEP_UPDATE', + version: testCase.version, + user: userId, + causedBy: sharedStep._id, + comment: `Re-versioned because shared step "${sharedStep.name}" was updated to v${sharedStep.version}`, + }); + versioned += 1; + } catch (error) { + log(`Failed to cascade shared step ${sharedStep._id} into test case ${testCase._id}: ${error.message}`); + failed.push({ testCase: testCase._id, error: error.message }); + } + } + /* eslint-enable no-await-in-loop, no-restricted-syntax */ + + log(`Cascaded shared step ${sharedStep._id} into ${versioned} test case(s), ${failed.length} failure(s)`); + return { versioned, failed }; +}; + +module.exports = sharedStepUtils; diff --git a/config/feature.config.js b/config/feature.config.js new file mode 100644 index 0000000..14b6969 --- /dev/null +++ b/config/feature.config.js @@ -0,0 +1,22 @@ +// In-memory mirror of the feature toggles held in the database. These are only the +// defaults used before the persisted settings load at startup; the feature-settings +// service loads the database document and mutates this object in place, and again on +// every admin save, so route guards can read a toggle synchronously. +// +// Toggles are managed through the admin UI and stored in the database. The environment +// variables below are *seeds*, not overrides: they choose the value written when the +// settings document is first created, so a deployment can ship with a feature already +// off. Once the document exists the database is authoritative and the env var is +// ignored - otherwise a restart would silently revert an admin's choice. +// +// Anything other than the string 'false' leaves a feature enabled, so an unset, empty or +// misspelled value behaves exactly as the instance did before the toggle existed. +const seedEnabled = (value) => value !== 'false'; + +module.exports = { + manualTestingEnabled: true, + // The seed values, read once at startup and applied only on first run. + seeds: { + manualTestingEnabled: seedEnabled(process.env.ANGLES_MANUAL_TESTING_ENABLED), + }, +}; diff --git a/docs/grafana/angles-dashboard.json b/docs/grafana/angles-dashboard.json index 2d1514c..c1c797a 100644 --- a/docs/grafana/angles-dashboard.json +++ b/docs/grafana/angles-dashboard.json @@ -3854,6 +3854,162 @@ } } ] + }, + { + "type": "timeseries", + "title": "Builds by execution type", + "id": 42, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "description": "Automated versus manual runs. Builds recorded before manual runs existed report as automated.", + "gridPos": { + "h": 9, + "w": 12, + "x": 0, + "y": 80 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum by (execution_type) (angles_builds_by_execution_type)", + "legendFormat": "{{execution_type}}", + "range": true, + "instant": false, + "refId": "A" + } + ], + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "lineWidth": 2, + "fillOpacity": 18, + "gradientMode": "none", + "spanNulls": false, + "showPoints": "never", + "pointSize": 5, + "stacking": { + "mode": "normal", + "group": "A" + }, + "axisPlacement": "auto", + "axisLabel": "", + "axisColorMode": "text", + "axisBorderShow": false, + "scaleDistribution": { + "type": "linear" + }, + "axisCenteredZero": false, + "barAlignment": 0, + "lineInterpolation": "linear", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "text", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "PASS" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "green" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "FAIL" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "red" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "ERROR" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "orange" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "SKIPPED" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "text" + } + } + ] + } + ] + } } ], "preload": false, diff --git a/docs/manual-test-cases.md b/docs/manual-test-cases.md new file mode 100644 index 0000000..9f8f8ae --- /dev/null +++ b/docs/manual-test-cases.md @@ -0,0 +1,359 @@ +# Manual test case management + +Angles 3.0 adds manual test case management: QAs author versioned test cases per team, +execute them in runs, and see the results in the same dashboards as automated runs. + +This guide covers authoring, custom field configuration, permissions, and what a +deployment needs before the feature is usable. + +## Concepts + +**Test case** — an authored manual test: title, description, preconditions, tags, steps +with expected results, and any custom fields configured for the team. A case has a +`status` (`DRAFT`, `ACTIVE`, `DEPRECATED`) and a `priority` (`LOW`, `MEDIUM`, `HIGH`, +`CRITICAL`). + +**Version** — every test case exists twice: as a *mutable head* holding the current +content, and as one *frozen version document* per version that has ever existed. Version +documents are only ever inserted, never updated or deleted. + +**Shared step** — a named, re-usable sequence of steps that many cases can include. + +**Test run** — a set of cases executed together against an environment. A run creates a +real `Build` tagged `executionType: 'manual'`, so every existing dashboard and metric +picks it up. + +**Attachment** — an image uploaded against a case, shared step, or execution. + +### Why versions matter + +An execution must render exactly what the tester saw. When a run is created, each selected +case is resolved to its **current version document** and the run stores that `ObjectId`. +Because no code path updates or deletes version documents, an execution recorded against +version 3 can never start showing version 4's content — including when the change came +from a shared step edited months later. + +Two consequences worth knowing up front: + +- Editing a case that is part of an **in-flight run** does not shift the steps under the + tester. The run keeps executing the version it started against. To pick up the edit, + re-add the case to the run — that creates a second entry bound to the newer version, or + use `rebind` (below) on an entry that has not been executed yet. +- Editing a **shared step** re-versions every case that includes it, immediately. Check + `GET /shared-step/:id/usage` first to see how many cases that is. + +An edit that changes nothing does not burn a version, and neither does a status change — +workflow state is not content. + +## Authoring + +### Creating a case + +`POST /rest/api/v1.0/manual-test-case` with at least `team` and `title`. New cases start +at `DRAFT` and version 1. + +Required custom fields are **only enforced when the case leaves `DRAFT`**, so authoring +stays frictionless — save early, fill in the mandatory fields when the case is ready to +become `ACTIVE`. + +### Steps + +Each step has an `action` (required), an `expected` result, optional test `data`, and +optional `attachments`. Steps carry their own `_id`, which is preserved into every frozen +version, so a step result binds to a specific step of a specific version and survives a +later reorder or deletion. + +To include a shared step, set `sharedStep` on the step instead of writing `action` / +`expected`. The included step's own text is ignored — the shared step's steps are expanded +in its place. + +Expansion happens **when a version is written**, not when a case is read. The frozen +version stores the fully expanded, flattened step list, with `sharedStepRef` and +`sharedStepVersion` on each expanded step for display attribution only. + +### Referencing images in a step + +Upload the image (see [Attachments](#attachments)), then reference it from the step's +expected or actual result in markdown: + +``` +![login page](attachment:64f0c2a1e4b0a1b2c3d4e5f6) +``` + +The UI resolves `attachment:` to `GET /attachment/:id/file`. + +### Cloning + +`POST /manual-test-case/:caseId/clone` creates a new `DRAFT` starting at version 1 with +its own independent version history. It does not share versions with the original. + +## Custom fields + +Admins define extra fields per team. Each definition has: + +| Property | Notes | +| --- | --- | +| `key` | Stable storage key, `/^[a-z][a-z0-9_]{0,39}$/`. Unique per team. | +| `label` | Display name; safe to change without touching stored values. | +| `type` | `text`, `textarea`, `number`, `date`, `boolean`, `select`, `multiselect`, `user` | +| `options` | Required for `select` and `multiselect`. | +| `required` | Enforced only when a case leaves `DRAFT`. | +| `defaultValue` | Applied when the field is absent. | +| `order` | Display order. | +| `appliesTo` | `testcase`, `execution`, or `both`. | +| `archived` | Set instead of deleting when values exist. | + +Values are validated against the team's definitions on every write. **Unknown keys are +rejected rather than silently dropped**, and every offending key is reported at once. + +### Changing a definition later + +- Renaming a `label` is safe — stored values key off `key`, not the label. +- Changing `key` is not a rename; it is a different field. +- Deleting a field that has values on existing cases **archives it instead**, so history + stays readable. Archived fields keep rendering wherever a value already exists, but are + not offered for new input. + +Each frozen version stores the field definitions as they stood at that version, so an +archived or relabelled field still renders with its original label and type when you look +back at an old execution. + +## Test runs + +1. **Create** — `POST /manual-test-run` with the team, environment, and the case ids to + include. Each case is resolved to its current version and the run creates its backing + manual build. `DEPRECATED` cases are rejected, naming the case — reactivate them first. + A case that belongs to a *different* component than the run is also rejected, because + the backing build carries a single component and a mismatch would make the component + metrics wrong; a case with no component set is accepted into any run. If the request + omits `component`, the run falls back to the team's first component rather than + refusing. +2. **Execute** — `PUT /manual-test-run/:runId/test-case/:caseId/result` records per-step + actual results, statuses and attachments. This writes a `TestExecution` and then calls + the same `buildMetricsUtils.addExecutionToBuild` that automated runs use, so build + metrics recompute through one code path. +3. **Complete** — `PUT /manual-test-run/:runId/status` transitions the run + (`PLANNED`, `IN_PROGRESS`, `COMPLETED`, `CANCELLED`); completing stamps `end`. + +### Per-case result states + +`NOT_RUN`, `IN_PROGRESS`, `PASS`, `FAIL`, `ERROR`, `SKIPPED`, `BLOCKED`. + +`BLOCKED` is manual-only and **maps to `SKIPPED`** on the underlying `TestExecution`, +because the execution status enum is closed and the whole metrics layer depends on it. The +richer state is kept on the run document. + +`SKIPPED` rather than `ERROR` is deliberate: a blocked test was never executed, so nothing +was verified and no defect was found. Recording it as `ERROR` would inflate the failure +count on every dashboard and alert that counts errors. + +### Rebinding + +`PUT /manual-test-run/:runId/test-case/:caseId/rebind` moves a `NOT_RUN` entry onto the +case's latest version. It returns **409 for any entry that already has a result**, since +that would silently re-point a recorded execution at content it was never run against. + +### Platform and device metrics + +Runs carry the same `Platform` array as automated executions — `platformName`, +`platformVersion`, `browserName`, `browserVersion`, `deviceName`, `screenWidth`, +`screenHeight`, `pixelRatio` — so the platform metrics aggregations work unchanged. + +## Attachments + +Upload with `POST /attachment` as multipart form data, field name `attachment`, plus one +of `testCaseId`, `sharedStepId` or `executionId` to say what it belongs to. + +- **Allowed types**: PNG, JPEG, GIF, WebP, BMP, TIFF. The client-supplied filename is + discarded and a random one generated. +- **Size limit**: 10 MB, matching screenshots. +- A thumbnail is generated alongside the original, served from + `GET /attachment/:id/thumbnail`. + +### Attachments are immutable once a version references them + +Removing an image from a step in the editor drops it from the **head only**. The file and +its document stay, because frozen versions still reference them and a historical execution +must render the image the tester actually saw. + +`DELETE /attachment/:id` therefore returns **409** when any frozen version references it. +Only attachments referenced solely by the head are hard-deleted. Deleting the parent case +removes its versions first, so the reference check passes and the files are collected. + +There is currently no independent expiry — attachment retention is tied to the parent +entity's lifetime. + +## Change history + +Every mutation to a test case, shared step or custom field records who changed what. +Actions: `CREATE`, `UPDATE`, `DELETE`, `CLONE`, `STATUS_CHANGE`, `SHARED_STEP_UPDATE`, +`ARCHIVE`. + +``` +GET /manual-test-case/:caseId/history?limit=&skip= +GET /shared-step/:sharedStepId/history +``` + +A cascade from a shared step edit is recorded as `SHARED_STEP_UPDATE` naming the shared +step as the cause, so a case that changed without anyone editing it directly is +explainable. + +History writes never fail the user's request — a failure is logged instead. History is not +auto-pruned. + +## Permissions + +| Action | Required | +| --- | --- | +| Author, read, clone test cases | Team access | +| Delete a test case | Team lead | +| Create, edit, delete shared steps | Team lead | +| Read shared steps and usage | Team access | +| Create runs, record results, transition status | Team access | +| Delete a run | Team lead | +| Upload, read, delete attachments | Team access to the owning entity | +| Configure custom fields | Admin | +| Read custom field definitions | Team access | + +## Filtering manual vs automated + +Both list endpoints accept an optional `executionType` of `automated` or `manual`: + +``` +GET /build?teamId=&executionType=manual +GET /metrics/phase?teamId=&executionType=manual +``` + +**Omitting it returns both**, which is exactly what every pre-3.0 client saw. The filter +narrows; it never reshapes. + +The phase metrics response also gains a per-period +`executionTypeBreakdown: { automated, manual }` that accounts for every execution in the +period, so a stacked chart built from it matches `result.TOTAL`. + +Prometheus gauges gained an `execution_type` label rather than new metric names. **This +changes the label set of `angles_builds_by_team`** — a recording rule or dashboard query +that matched on an exact label set needs updating; one that matches by name does not. + +## Deployment + +### Turning the feature on and off + +Manual test case management is optional and **enabled by default**. An admin controls it +from **Admin → Settings → Feature Management**; the toggle covers test cases, test runs, +shared steps and folders as a single unit, because a run is a run *of* cases. + +Turning it off: + +- removes the Manual Testing menu from the navigation for every user, +- replaces the pages themselves with an explanation, so a bookmarked link says why it no + longer works, and +- makes `/manual-test-case`, `/manual-test-run`, `/manual-folder` and `/shared-step` + respond `404` for every method — the data is genuinely unreachable, not merely hidden. + +Nothing is deleted. Re-enabling restores every case, run and shared step exactly as it +was. Changes apply immediately; no restart is needed. + +To deploy an instance with the feature already off, set: + +``` +ANGLES_MANUAL_TESTING_ENABLED=false +``` + +Any value other than the string `false` leaves the feature enabled, so an unset or +misspelled value behaves as it always did. + +This variable is a **seed, not an override**. It supplies the value written when the +feature settings are first created — the same create-if-missing pattern as +`ANGLES_ADMIN_PASSWORD`. Once those settings exist the database is authoritative and the +variable is ignored, so a restart can never silently revert a change an admin made in the +UI. To change the toggle on a running instance, use the admin UI. + +### Attachment storage + +Attachments are written to `/app/attachments`, grouped into one directory per owning +entity. This needs to be a persistent volume, or attachments vanish on restart. + +Already wired up: + +- `Dockerfile` — `VOLUME /app/attachments` +- `setup/docker-compose.yml` — `angles_attachments` volume +- `setup/kubernetes/04-angles-pod.yaml` — mounted from the existing PVC + +`attachments/` is in both `.gitignore` and `.dockerignore`. + +If you are upgrading an existing deployment with its own manifests, add the mount before +enabling the feature. + +### Backfilling executionType + +`executionType` defaults to `automated` in the schema, so documents written before 3.0 +**already read back correctly** with no migration. What they lack is the field on disk, +which means the `{ team, executionType, start }` index cannot cover them and a filtered +query falls back to a broader index. + +On a large deployment, run the backfill before building the new index: + +``` +MONGO_URL=... node scripts/backfill-execution-type.js +``` + +It only touches documents where the field is genuinely absent, so it is safe to run +repeatedly. + +### Storage cost + +A case edited 50 times stores 50 full copies. Test case documents are small — text and a +handful of refs, with attachments stored by reference rather than copied — so this is +measured in kilobytes per case, and is the right trade for auditability. + +Versions are prunable by age *for cases with no executions bound to them*, but that is +deliberately not built. + +## Client libraries + +**angles-javascript-client** exposes the full surface: `manualTestCases`, `sharedSteps`, +`customFields`, `attachments` and `manualTestRuns` on the reporter, plus the optional +`executionType` filter on the build and metrics calls. This is what angles-ui uses. + +**angles-java-client** and **angles-python-client** are reporting clients for automated +frameworks and carry no manual test management surface. They can *read* `executionType`, +the manual execution refs and step `attachments`, and pass an `executionType` filter to +list calls. + +Neither can **write** `executionType`. It is what distinguishes a manual run from an +automated one, and a reporting client setting it to `manual` would put a build on the +dashboard that no manual run exists to explain. It stays server-assigned — `POST /build` +from those clients gets the schema default. + +## API reference + +Full request and response schemas are in `swagger/swagger.json`, browsable at +**`/api-docs`** — note that is served from the server root, not under `/rest/api/v1.0`. + +``` +POST /manual-test-case GET /manual-test-case +GET /manual-test-case/:caseId PUT /manual-test-case/:caseId +DELETE /manual-test-case/:caseId POST /manual-test-case/:caseId/clone +GET /manual-test-case/:caseId/version GET /manual-test-case/:caseId/version/:version +GET /manual-test-case/:caseId/history + +POST /shared-step GET /shared-step +GET /shared-step/:sharedStepId PUT /shared-step/:sharedStepId +DELETE /shared-step/:sharedStepId GET /shared-step/:sharedStepId/usage +GET /shared-step/:sharedStepId/history + +POST /custom-field GET /custom-field +GET /custom-field/:fieldId PUT /custom-field/:fieldId +DELETE /custom-field/:fieldId + +POST /attachment GET /attachment?testCaseId=|sharedStepId= +GET /attachment/:attachmentId DELETE /attachment/:attachmentId +GET /attachment/:attachmentId/file GET /attachment/:attachmentId/thumbnail + +POST /manual-test-run GET /manual-test-run +GET /manual-test-run/:runId PUT /manual-test-run/:runId +DELETE /manual-test-run/:runId PUT /manual-test-run/:runId/status +PUT /manual-test-run/:runId/test-case/:caseId/result +PUT /manual-test-run/:runId/test-case/:caseId/rebind +``` diff --git a/docs/plans/manual-test-case-management.md b/docs/plans/manual-test-case-management.md new file mode 100644 index 0000000..b105183 --- /dev/null +++ b/docs/plans/manual-test-case-management.md @@ -0,0 +1,659 @@ +# Manual Test Case Management — Implementation Plan + +## Goal + +Add manual test case management to Angles so QAs can author, version, organise and +execute manual test cases per team, with the results appearing alongside automated runs +in the existing dashboards. + +## Design decisions + +These were settled before planning and constrain everything below: + +| Decision | Choice | +| --- | --- | +| Manual run ↔ Build relationship | Manual runs create a real `Build` document tagged `executionType: 'manual'`, plus dedicated `manualtestcases` / `manualtestruns` collections | +| Step attachments | A new dedicated `Attachment` model and collection, independent of `Screenshot` | +| Permissions | Admin configures custom fields; team leads manage shared steps; any team member authors and executes | +| Scope | Angles API + angles-ui + client libraries, delivered in phases. The JavaScript client gets the full manual surface (it backs angles-ui); the Java and Python clients only track the automated-reporting fields — see phase 9 | +| Execution ↔ case versioning | Every version is written to an append-only `manualtestcaseversions` collection; executions bind to a version document, never to the mutable case | + +### Why executions bind to an immutable version document + +An execution must render exactly what the tester saw. Storing a version *number* on the +execution is not enough — resolving `case + version` back to content requires the content +to still exist somewhere, and pointing at the live case means any later edit rewrites +history retroactively. + +So the case is split into a mutable head (current content) and an append-only version +collection (every version that ever existed, fully frozen — including expanded shared steps +and the custom field definitions in force at the time). An execution holds an `ObjectId` to +a version document. Because nothing in the system ever updates or deletes those documents, +there is no code path by which a historical execution can start showing newer content. + +### Why manual runs reuse `Build` + +Every dashboard and metrics aggregation in the API is anchored on `Build` and +`TestExecution` — `metrics.controller.js` matches on `{ build: { $in: buildIds } }`, and +`build.controller.js:findAll` drives the dashboard table. Writing manual results as +`Build` + `TestExecution` documents carrying `executionType: 'manual'` means: + +- the dashboard, phase metrics, and platform metrics work with a single added `$match` + field rather than a parallel aggregation pipeline; +- `buildMetricsUtils.calculateBuildMetrics` computes manual result maps for free; +- filtering manual vs automated is one query parameter everywhere. + +`ManualTestRun` sits *beside* the build and holds the things a build has no concept of: +assignee, in-progress state, per-case execution status, and the link back to the test case +at the version it was executed against. + +### Why attachments are a separate model + +`Screenshot` is tightly bound to a build (`build` is `required`), and carries phash, +platformId, view and baseline-comparison semantics that manual attachments do not need. +Rather than loosening `Screenshot.build` to optional — which weakens an invariant every +screenshot query and the whole image-engine relies on — manual attachments get their own +small model with their own upload path and lifecycle. + +--- + +## Phase 1 — Core models and test case CRUD + +**New models** + +`app/models/manual-test-case.js` +``` +ManualTestCase { + team: ObjectId ref Team (required, indexed) + component: ObjectId (optional — matches Build.component) + title: String (required, max 200) + description: String + preconditions: String + status: enum ['DRAFT','ACTIVE','DEPRECATED'] default 'DRAFT' + priority: enum ['LOW','MEDIUM','HIGH','CRITICAL'] default 'MEDIUM' + tags: [String] (lowercase, trimmed) + steps: [ManualStep] + customFields: Map (validated against the project's field defs) + version: Number default 1 (bumped on every content change) + createdBy: ObjectId ref User + updatedBy: ObjectId ref User + timestamps: true +} + +ManualStep (subdocument, _id: true — needed so executions can reference a step) +{ + order: Number (required) + action: String (required) + expected: String + sharedStep: ObjectId ref SharedStep (optional — see Phase 3) + attachments: [ObjectId ref Attachment] + data: String (test data for the step) +} +``` + +Indexes: `{ team: 1, status: 1 }`, `{ team: 1, createdAt: -1 }`, `{ tags: 1 }`, +and a text index on `title`/`description` for search. + +### Immutable versions + +`ManualTestCase` above is the *mutable head* — it always reflects the latest content. Every +version that has ever existed is also written to its own immutable collection, and this is +what executions bind to. + +`app/models/manual-test-case-version.js` +``` +ManualTestCaseVersion { + testCase: ObjectId ref ManualTestCase (required, indexed) + version: Number (required) + team: ObjectId ref Team + + // Full frozen content — every authored field, not just the steps + title: String + description: String + preconditions: String + priority: String + tags: [String] + steps: [ManualStep] (shared steps already expanded — see Phase 3) + customFields: Map + + // Field definitions as they stood at this version, so an archived or relabelled + // custom field still renders with its original label and type + fieldDefinitions: [CustomFieldDefinitionSnapshot] + + createdBy: ObjectId ref User + createdAt: Date +} +``` +Unique compound index `{ testCase: 1, version: 1 }`. + +Documents in this collection are **never updated or deleted** — the controllers only ever +`insert` here. That is the property the whole feature rests on: an execution that points at +version 3 can never be made to render version 4's content, because version 3's document is +not reachable by any write path. + +**Write flow**: `manual-test-case.controller.create` writes head + version 1 together. +`update` compares the incoming body against the head, and when any *content* field differs +(title, description, preconditions, priority, tags, steps, customFields — not `status`, +which is workflow state) it increments `head.version` and inserts a new version document. +An edit that changes nothing is a no-op and does not burn a version. + +Both writes go through a `session`-less two-step with the version inserted **first**, so a +crash between the two leaves an orphan version rather than a head pointing at a version +that does not exist. `manual-test-case-utils.getVersion(caseId, version)` falls back to the +head only when no version document exists at all (pre-migration data), and logs when it +does. + +**Read routes** +``` +GET /manual-test-case/:caseId/version -> list of versions (metadata only) +GET /manual-test-case/:caseId/version/:version -> full frozen content +``` + +**Files to add** +- `app/models/manual-test-case.js` +- `app/controllers/manual-test-case.controller.js` — `create`, `findAll`, `findOne`, + `update`, `delete`, `clone` +- `app/routes/manual-test-case.routes.js` +- register in `server.js` next to the other route requires + +**Route surface** (all under `/rest/api/v1.0`) +``` +POST /manual-test-case hasTeamAccess +GET /manual-test-case?teamId=&status=&tags=&search=&limit=&skip= +GET /manual-test-case/:caseId +PUT /manual-test-case/:caseId hasTeamAccess +POST /manual-test-case/:caseId/clone hasTeamAccess +DELETE /manual-test-case/:caseId hasTeamLeadAccess +``` + +Follow the existing controller idiom exactly: `validationResult` guard returning 422, +promise chains, `NotFoundError` / `ForbiddenError` from `app/exceptions/errors.js`, +`handleError(err, res)` in the `.catch`, and a `debug` logger named +`manual-test-case:controller`. + +**Tests**: `test/manual-test-case.tests.js` following `test/team.tests.js` — positive CRUD, +422 on empty body, 403 for a user without team access, 404 for unknown team. + +--- + +## Phase 2 — Configurable custom fields + +Admins define extra fields; each field definition is scoped to a team (the "project"). + +**New model** `app/models/custom-field-definition.js` +``` +CustomFieldDefinition { + team: ObjectId ref Team (required, indexed) + key: String (required, /^[a-z][a-z0-9_]{0,39}$/ — stable storage key) + label: String (required) + type: enum ['text','textarea','number','date','boolean','select','multiselect','user'] + options: [String] (required when type is select/multiselect) + required: Boolean default false + defaultValue: Mixed + order: Number + appliesTo: enum ['testcase','execution','both'] default 'testcase' + archived: Boolean default false +} +``` +Unique compound index `{ team: 1, key: 1 }`. + +**Validation util** `app/utils/custom-field-utils.js` +- `validateCustomFields(definitions, values)` — coerces and type-checks a `customFields` + map against the team's definitions; throws `InvalidRequestError` listing every offending + key at once (matching how `settings.routes.js` rejects unknown config keys rather than + ignoring them). +- Unknown keys are rejected, not silently dropped. +- `required` is enforced only when the case leaves `DRAFT`, so authoring stays frictionless. + +**Routes** — admin-only for writes, readable by anyone with team access: +``` +POST /custom-field authorizeAdmin +GET /custom-field?teamId= +PUT /custom-field/:fieldId authorizeAdmin +DELETE /custom-field/:fieldId authorizeAdmin (soft-archive if in use) +``` + +Deleting a field that has values on existing cases archives it instead of removing it, so +history stays readable. This is checked in the controller before deletion. + +**Wire into Phase 1**: `manual-test-case.controller.create/update` loads the team's +definitions and runs `validateCustomFields` before saving. + +--- + +## Phase 3 — Re-usable shared steps + +**New model** `app/models/shared-step.js` +``` +SharedStep { + team: ObjectId ref Team (required, indexed) + name: String (required, max 150) + description: String + steps: [ManualStep] (same subdocument shape as a test case's steps) + version: Number default 1 + createdBy / updatedBy: ObjectId ref User + timestamps: true +} +``` +Unique compound index `{ team: 1, name: 1 }`. + +**Referencing model**: a test case step with `sharedStep` set is an *inclusion* — its own +`action`/`expected` are ignored and the shared step's steps are expanded in their place at +read time. Expansion happens in a util (`app/utils/manual-step-utils.js:expandSteps`) used +by the case read endpoint (when `?expand=true`) and, critically, when a +`ManualTestCaseVersion` document is written — the version stores steps **already expanded +and flattened**, carrying `sharedStepRef` / `sharedStepVersion` for display attribution only. + +That placement matters. A shared step included by twelve cases is edited constantly; if +versions stored the reference unexpanded, every historical execution of all twelve cases +would silently re-render with the new shared content. Expanding at version-write time means +editing a shared step affects only cases versioned *after* the edit. + +**Consequence**: editing a shared step must version every test case that includes it. +`shared-step.controller.update` bumps the shared step, then inserts a new +`ManualTestCaseVersion` for each referencing case (re-expanded, `head.version` incremented), +with a `SHARED_STEP_UPDATE` history entry naming the shared step as the cause. This is +batched and logged; the `usage` endpoint below tells the author how many cases that will be +before they commit. + +**Routes** +``` +POST /shared-step hasTeamLeadAccess +GET /shared-step?teamId=&search= +GET /shared-step/:sharedStepId +GET /shared-step/:sharedStepId/usage -> test cases referencing it +PUT /shared-step/:sharedStepId hasTeamLeadAccess +DELETE /shared-step/:sharedStepId hasTeamLeadAccess (409 if referenced) +``` + +Editing a shared step bumps its `version` and writes a history entry (Phase 5), and the +`usage` endpoint lets the UI warn the author how many cases they are about to affect. + +--- + +## Phase 4 — Attachments + +**New model** `app/models/attachment.js` +``` +Attachment { + team: ObjectId ref Team (required) + scope: enum ['testcase','sharedstep','execution'] (required) + testCase / sharedStep / manualExecution: ObjectId (whichever matches scope) + filename: String (server-generated) + originalName: String (sanitised, display only) + mimeType: String + size: Number + path: String (on-disk path) + thumbnail: String + uploadedBy: ObjectId ref User + timestamps: true +} +``` + +**Storage** `app/utils/multer-config-attachments.js`, modelled directly on +`multer-config-screenshots.js`: +- root `ATTACHMENT_ROOT = path.resolve(__dirname, '../../attachments')` +- destination keyed on a validated Mongo id, with the same + `startsWith(ROOT + path.sep)` defence-in-depth check +- `EXTENSION_BY_MIME` allowlist; client filename discarded, `crypto.randomBytes` filename +- 10 MB limit, same as screenshots + +**Thumbnailing** reuses `jimp` the way `image-utils` does; a small +`attachment-utils.generateThumbnail` writes a `.thumb.png` beside the original. + +**Routes** +``` +POST /attachment (multipart, field `attachment`) hasTeamAccess +GET /attachment/:id hasTeamAccess +GET /attachment/:id/file hasTeamAccess +GET /attachment/:id/thumbnail hasTeamAccess +DELETE /attachment/:id hasTeamAccess +``` + +Deleting a test case or execution cascades to its attachments (files + documents), +mirroring `imageUtils.removeScreenshotDirectories`. Add `attachments/` to `.gitignore` and +`.dockerignore`, and mount it as a volume in the Dockerfile/k8s manifests the way +`screenshots/` is. + +**Attachments are immutable once referenced by a version.** Removing an image from a step +in the editor drops it from the *head* only — the file and its `Attachment` document stay, +because frozen versions still reference them and a historical execution must render the +image the tester actually saw. `attachment.controller.delete` therefore checks +`ManualTestCaseVersion` for references and returns 409 when any exist; only attachments +referenced solely by the head are hard-deleted. Cascade-on-case-delete removes the versions +first, so the reference check passes and the files are collected. + +Attachments referenced from a step's *expected result* are addressed in markdown as +`![alt](attachment:)`; the UI resolves those to `/attachment/:id/file`. + +--- + +## Phase 5 — Change history + +Every mutation to a test case or shared step records who changed what. + +**New model** `app/models/manual-change-history.js` +``` +ManualChangeHistory { + entityType: enum ['testcase','sharedstep','customfield'] + entityId: ObjectId (indexed) + team: ObjectId ref Team + action: enum ['CREATE','UPDATE','DELETE','CLONE','STATUS_CHANGE','SHARED_STEP_UPDATE'] + version: Number (the resulting version) + changes: [{ field, from, to }] + changedBy: ObjectId ref User (required) + changedAt: Date default now + comment: String (optional, author-supplied) +} +``` +Indexes: `{ entityId: 1, changedAt: -1 }`, `{ team: 1, changedAt: -1 }`. + +**Util** `app/utils/history-utils.js` +- `diffDocuments(before, after, trackedPaths)` returns the `changes` array, deep-comparing + the `steps` array and the `customFields` map rather than reporting them as one opaque + change. +- `recordChange({ entityType, entityId, action, before, after, user, comment })` — called + from the controllers after a successful save, never inside the same await as the save, + so a history write failure logs but does not fail the user's request. + +**Routes** +``` +GET /manual-test-case/:caseId/history?limit=&skip= +GET /shared-step/:sharedStepId/history +``` +Responses populate `changedBy` to `{ _id, username }` only — never the whole user +document, which carries `apiTokens`. + +Retention: history is never auto-pruned in this phase; the `cleanup/` scripts get a +follow-up task if volume becomes a problem. + +--- + +## Phase 6 — Manual test runs and executions + +This is where manual results join the automated model. + +**Change to `app/models/build.js`** +``` +executionType: { type: String, enum: ['automated','manual'], default: 'automated', required: true } +``` +plus index `{ team: 1, executionType: 1, start: -1 }`. + +Defaulting to `'automated'` means every existing build document reads back correctly with +no migration; a backfill script is still provided in `scripts/` so the new index is dense. + +**Change to `app/models/execution.js`**: the same `executionType` field, plus +``` +manualTestCase: ObjectId ref ManualTestCase (optional) +manualTestCaseVersion: ObjectId ref ManualTestCaseVersion (optional) +versionNumber: Number (optional, denormalised) +executedBy: ObjectId ref User (optional) +``` +The version ref is carried on the execution as well as on the run entry deliberately: an +execution is reachable from the dashboard and from +`test-execution-history` without ever loading the run, and it must be able to render the +content it was executed against on its own. +`TestExecution.actions[].steps[]` already carries `name`/`expected`/`actual`/`status`/ +`screenshot`, which is very close to what a manual step result needs. Add an optional +`attachments: [ObjectId ref Attachment]` to the existing `Step` subdocument so a manual +step result can reference uploaded images without a parallel structure. + +**New model** `app/models/manual-test-run.js` +``` +ManualTestRun { + name: String (required) + team: ObjectId ref Team (required, indexed) + component: ObjectId + environment: ObjectId ref Environment (required) + phase: ObjectId ref Phase + build: ObjectId ref Build (the tagged manual build this run writes into) + status: enum ['PLANNED','IN_PROGRESS','COMPLETED','CANCELLED'] + assignedTo: ObjectId ref User + platforms: [Platform] (reuses app/models/platform.js verbatim) + testCases: [{ + testCase: ObjectId ref ManualTestCase (the head — for "show me all + runs of this case" queries) + testCaseVersion: ObjectId ref ManualTestCaseVersion (the frozen content actually + executed — required) + versionNumber: Number (denormalised for display without a join) + status: enum ['NOT_RUN','PASS','FAIL','ERROR','SKIPPED','BLOCKED','IN_PROGRESS'] + execution: ObjectId ref TestExecution + executedBy: ObjectId ref User + start / end: Date + }] + createdBy: ObjectId ref User + timestamps: true +} +``` + +Reusing `Platform` unchanged is what satisfies requirement 5 — manual runs capture exactly +the same platform/device metrics as automated ones (`platformName`, `platformVersion`, +`browserName`, `browserVersion`, `deviceName`, `screenWidth/Height`, `pixelRatio`), so the +platform metrics aggregations need no new fields. + +**Flow** +1. `POST /manual-test-run` — resolves each selected case to its **current version + document** and stores that `ObjectId` on the run entry, then creates the backing `Build` + with `executionType: 'manual'`, `result: defaultResultMap`. The run never copies content; + it points at the immutable version. +2. `PUT /manual-test-run/:runId/test-case/:caseId/result` — records the per-step + actual/status/attachments, creates or updates the `TestExecution` + (`executionType: 'manual'`, `manualTestCase`, `manualTestCaseVersion`, `executedBy`), + then calls the existing `buildMetricsUtils.addExecutionToBuild` so the build's result map + and status recompute through the same code path as automated runs — including its + optimistic-concurrency retry loop. +3. `PUT /manual-test-run/:runId/status` — transitions the run; completing it stamps `end`. + +**Version binding is fixed at run creation, not at execution time.** If a QA edits a case +while a run is in progress, the in-flight run keeps executing the version it started +against — the steps do not shift under the tester mid-run. Picking up the edit requires +explicitly re-adding the case to the run, which creates a second entry bound to the newer +version. + +`PUT /manual-test-run/:runId/test-case/:caseId/rebind` moves a `NOT_RUN` entry onto the +latest version. It is rejected with a 409 for any entry that already has a result, since +that would silently re-point a recorded execution at content it was never run against. + +**Step results reference the frozen step `_id`.** `ManualStep` carries `_id: true` and those +ids are preserved into the version document, so a step result binds to a specific step of a +specific version. A reordered or deleted step in a later version cannot pull a historical +result onto the wrong step. + +Note `BLOCKED` is a manual-only per-case state. It maps to `SKIPPED` when written to the +`TestExecution`, because `executionStates` in `build.js`/`execution.js` is a closed enum +that the whole metrics layer depends on — the richer state is kept on the run document. + +`SKIPPED` rather than `ERROR` (which this plan originally proposed): a blocked test was +never executed, so nothing was verified and no defect was found. Recording it as `ERROR` +would inflate the failure count on every dashboard and alert that counts errors, and +conflate "could not run" with "ran and errored". + +**Routes** +``` +POST /manual-test-run hasTeamAccess +GET /manual-test-run?teamId=&status=&assignedTo=&limit=&skip= +GET /manual-test-run/:runId +PUT /manual-test-run/:runId hasTeamAccess +PUT /manual-test-run/:runId/test-case/:caseId/result hasTeamAccess +PUT /manual-test-run/:runId/status hasTeamAccess +DELETE /manual-test-run/:runId hasTeamLeadAccess +``` + +**Tests**: `test/manual-test-run.tests.js` — creating a run creates a tagged build; +recording a result updates the build's result map; a completed run reports the right +status; `BLOCKED` maps to `ERROR` on the execution. + +--- + +## Phase 7 — Dashboard and metrics filtering + +**API changes** — all additive and backwards compatible: + +- `build.controller.js:findAll` — accept `executionType` query param, add + `query.executionType = executionType` when present. Absent means both, preserving + today's behaviour for existing clients. +- `build.routes.js` — `query('executionType').optional().isIn(['automated','manual'])`. +- `metrics.controller.js:retrieveMetricsPerPhase` — same param, applied to `buildQuery` + before the `Build.find`, so the executions aggregation narrows automatically. +- `metrics.routes.js` — same validator. +- Phase metrics response gains a per-period `executionTypeBreakdown: { automated, manual }` + so the UI can stack the two without a second request. + +**Prometheus** (`app/controllers/prometheus.controller.js`, `app/utils/domain-metrics.js`): +add `execution_type` as a label on the existing build/execution gauges rather than new +metric names, so existing dashboards keep working and the Grafana boards in `docs/grafana` +gain a filter. Validate the output with the dockerised promtool flow before committing. + +--- + +## Phase 8 — angles-ui + +New pages under `src/app/`, each with a matching component folder under +`src/components/pages/` per the existing convention: + +| Route | Purpose | +| --- | --- | +| `manual-test-cases` | Team-scoped list with search, tag/status/priority filters | +| `manual-test-cases/[caseId]` | Case detail — steps, expected results, attachments, custom fields, history tab, version selector | +| `manual-test-cases/[caseId]/version/[version]` | Read-only view of a frozen version, with a diff against any other version | +| `shared-steps` | Shared step library + usage view | +| `manual-test-runs` | Run list and run creation wizard | +| `manual-test-runs/[runId]` | Execution view — step-by-step, pass/fail per step, attachment upload, platform capture. Always renders the **bound version**, with a "v3 · 2 versions behind latest" badge linking to the diff | +| `admin/custom-fields` | Admin field configuration (alongside `admin/settings`) | + +**Shared components** under `src/components/features/`: +- `manual-step-editor` — drag-ordered step list with expected results and inline images +- `custom-field-form` — renders inputs from field definitions +- `change-history` — timeline of `ManualChangeHistory` entries with the author +- `attachment-upload` — drop zone, thumbnail preview, markdown reference insertion +- `version-badge` — "v3 · 2 behind latest", shown anywhere an execution is displayed +- `version-diff` — side-by-side comparison of two frozen versions, reusing the + `diffDocuments` output shape from Phase 5 so the API does the field-level diffing + +**Dashboard filtering** — **done**: an execution-type toggle (All / Automated / Manual) on +`DashboardPage.js` and `MetricsPage.js`, sharing one sentinel-to-parameter mapping in +`GeneralUtilities`. Defaulting to All preserves the pre-3.0 behaviour exactly. + +Deferred: stacking the two series in the charts when "All" is selected. The phase metrics +response carries `executionTypeBreakdown` per period for exactly this, so it is additive +work on the existing chart components rather than anything blocked. + +All UI work follows the design-system rules in the angles-ui `CLAUDE.md`/`.agents/AGENTS.md`, +and the Next.js version notes there — read `node_modules/next/dist/docs/` before writing +page code rather than assuming conventions. + +--- + +## Phase 9 — Client libraries and docs + +The three clients need very different amounts of work, because they do very different +jobs. Only the JavaScript client drives angles-ui; the Java and Python clients exist to +*report* automated results from test frameworks and have no manual-testing caller. + +### angles-javascript-client — full manual test management surface + +This is the client `angles-ui` consumes, so it needs every endpoint phase 8 will call: + +- `ManualTestCaseRequests.ts` — CRUD, clone, version list, single version, history +- `SharedStepRequests.ts` — CRUD, usage, history +- `CustomFieldRequests.ts` — CRUD (admin writes, team-access reads) +- `AttachmentRequests.ts` — multipart upload, metadata, file, thumbnail, delete +- `ManualTestRunRequests.ts` — create, list, get (with `expand`), update, record result, + rebind, status transition, delete +- Models under `src/lib/models/` mirroring the schemas, request shapes under + `models/requests/`, and enums (`ManualTestCaseStates`, `ManualTestCasePriorities`, + `ManualRunStates`, `ManualCaseResultStates`, `CustomFieldTypes`) under `models/enum/` +- Register all five on `AnglesReporter.ts` + +`BuildRequests` and `MetricRequests` also gain the optional `executionType` filter added +in phase 7, which is what backs the dashboard's automated/manual toggle. + +### angles-java-client and angles-python-client — automated reporting fields only + +These only need to keep pace with what phases 6 and 7 added to the *automated* API. No +manual test management surface at all. + +Both deserialize leniently — Gson ignores unknown JSON fields, and the Python client +returns raw dicts from `requests.py` rather than deserializing into its dataclasses — so +neither is *broken* by the new fields today. What they lack is the ability to read them +back, which is a real gap for a caller that wants to know whether a build it fetched was +automated or manual. + +Required changes, both clients: + +- `Build`: add `executionType` (`"automated"` / `"manual"`). +- `Execution`: add `executionType`, plus the manual-only `manualTestCase`, + `manualTestCaseVersion`, `versionNumber` and `executedBy` — populated only on manual + executions, and read-only from these clients' point of view. +- `Step`: add `attachments` (list of ids), alongside the existing `screenshot`. +- Build/metrics list calls: accept an optional `executionType` filter parameter. + +Neither client should be able to *write* `executionType` on a build or execution it +creates. The value is what distinguishes a manual run from an automated one, and a +reporting client setting it to `"manual"` would put a build on the dashboard that no +manual run exists to explain. It stays server-assigned: `POST /build` from these clients +gets the schema default. + +Python note: the dataclasses in `models/` are request-shaping and documentation only — +nothing deserializes into them — so adding the fields is about making the intended shape +discoverable, not about fixing a runtime failure. + +### Docs + +- `swagger/swagger.json` is already current: every phase documented its own endpoints as + it landed, rather than deferring to this phase. +- `docs/manual-test-cases.md` — **done**: concepts and the versioning rationale, authoring, + custom field configuration, runs and result states, attachments, change history, the + permissions matrix, the automated/manual filter, deployment (attachment volume and the + `scripts/backfill-execution-type.js` step), client library scope, and a route reference. + +--- + +## Delivery order and risk + +Phases 1–5 are independently shippable and touch no existing code paths, so they carry +almost no regression risk. The risk concentrates in: + +- **Phase 6**, which modifies `Build` and `TestExecution` — both defaulted and additive, + but they are the two hottest collections in the system. The backfill script should run + before the new index is built on a large deployment. +- **Phase 7**, which edits live dashboard and metrics queries. Every change is an optional + narrowing filter; the existing test suites (`test/build.tests.js`, + `test/execution.tests.js`, `test/prometheus.tests.js`) must pass unchanged, which is the + main guard that omitting the param preserves today's behaviour. + +Run the API test suite with `PORT=3999` and a `MONGO_URL` set — port 3000 is taken by +docker locally. + +**The version-immutability tests are the ones that matter most**, since a regression there +is silent — nothing errors, history just quietly starts lying. `test/manual-versioning.tests.js` +must cover: + +1. Execute a case at v1, edit the case to v2, re-read the execution → still renders v1 + content (title, steps, expected results, custom field values, and labels). +2. Edit a shared step included by a case → historical executions of that case are unchanged; + only cases versioned after the edit see the new content. +3. Delete an attachment referenced by a frozen version → 409, and the historical execution + still renders the image. +4. Reorder and delete steps in v2 → v1's step results stay bound to the right steps. +5. An update that changes nothing does not create a new version. +6. `rebind` on an entry with a recorded result → 409. + +Storage cost is worth stating plainly: a case edited 50 times stores 50 full copies. Test +case documents are small (text and a handful of refs — attachments are stored by reference, +not copied), so this is measured in kilobytes per case and is the right trade for +auditability. If it ever matters, versions are prunable by age *for cases with no executions +bound to them* — but that is deliberately not built now. + +## Open items to confirm during implementation + +1. ~~Whether a manual run should span multiple components~~ — settled: single component, + matching `Build.component`; a case from another component is rejected (phase 6). +2. ~~Whether `DEPRECATED` test cases should be selectable in a new run~~ — settled: no, + rejected with a 400 naming the case (phase 6). +3. Attachment retention — currently tied to the parent entity's lifetime, with no + independent expiry. +4. ~~Whether editing a shared step should version every referencing case immediately~~ — + settled: immediate cascade (phase 3). +5. Whether `status` transitions (`DRAFT` → `ACTIVE`) should burn a version. The plan says no + — workflow state is not content — so an execution's bound version has no `status` field. diff --git a/docs/prometheus-metrics.md b/docs/prometheus-metrics.md index 63a60a7..70bec72 100644 --- a/docs/prometheus-metrics.md +++ b/docs/prometheus-metrics.md @@ -70,11 +70,19 @@ A ready-to-import dashboard covering everything below lives in ### Angles application data +> **Changed in 3.0**: `angles_builds_by_team` gained an `execution_type` label so manual +> and automated runs can be told apart. Queries that aggregate (`sum by (team) (...)`, +> which is what the bundled dashboard uses) are unaffected. A query pinning the *full* +> label set needs `execution_type` adding, or an aggregation wrapped around it. Builds and +> executions written before manual runs existed report as `automated`. + | Metric | Type | Labels | Notes | | --- | --- | --- | --- | | `angles_builds` | gauge | `status` | | -| `angles_builds_by_team` | gauge | `team`, `environment`, `status` | Subject to `ANGLES_METRICS_MAX_SERIES`. | +| `angles_builds_by_execution_type` | gauge | `execution_type` (`automated`/`manual`) | | +| `angles_builds_by_team` | gauge | `team`, `environment`, `status`, `execution_type` | Subject to `ANGLES_METRICS_MAX_SERIES`. | | `angles_executions` | gauge | `status` | | +| `angles_executions_by_execution_type` | gauge | `status`, `execution_type` | | | `angles_screenshots` | gauge | | | | `angles_screenshots_with_phash` | gauge | | Image-engine coverage of the stored screenshots. | | `angles_baselines` | gauge | | | diff --git a/package-lock.json b/package-lock.json index 8520e11..194b98f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "angles", - "version": "2.0.31", + "version": "3.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "angles", - "version": "2.0.31", + "version": "3.0.0", "license": "Apache-2.0", "dependencies": { "@node-saml/passport-saml": "^5.1.0", diff --git a/package.json b/package.json index 7a20e33..5a43326 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "angles", - "version": "2.0.31", + "version": "3.0.0", "description": "angles test dashboard api", "main": "server.js", "scripts": { diff --git a/scripts/backfill-execution-type.js b/scripts/backfill-execution-type.js new file mode 100644 index 0000000..85766b5 --- /dev/null +++ b/scripts/backfill-execution-type.js @@ -0,0 +1,51 @@ +/** + * Backfills `executionType` on existing builds and executions. + * + * The field defaults to 'automated' in the schema, so documents written before it existed + * already *read back* correctly without this script. What they lack is the field on disk, + * which means the { team, executionType, start } index cannot cover them and a query + * filtering on executionType has to fall back to a broader index and discard most of what + * it reads. + * + * Safe to run repeatedly: it only touches documents where the field is genuinely absent. + * + * MONGO_URL=... node scripts/backfill-execution-type.js + */ +require('dotenv').config(); +const mongoose = require('mongoose'); +const Build = require('../app/models/build.js'); +const TestExecution = require('../app/models/execution.js'); +const dbConfig = require('../config/database.config.js'); + +const mongoURL = process.env.MONGO_URL || dbConfig.url; + +mongoose.set('strictQuery', false); +mongoose.connect(mongoURL, { + useNewUrlParser: true, + useUnifiedTopology: true, +}).then(async () => { + console.log('Connected to database.'); + + const buildResult = await Build.updateMany( + { executionType: { $exists: false } }, + { $set: { executionType: 'automated' } }, + ).exec(); + // modifiedCount on the modern driver, nModified on older ones. + const modified = (result) => (result.modifiedCount === undefined + ? result.nModified || 0 + : result.modifiedCount); + console.log(`Builds updated: ${modified(buildResult)}`); + + const executionResult = await TestExecution.updateMany( + { executionType: { $exists: false } }, + { $set: { executionType: 'automated' } }, + ).exec(); + console.log(`Executions updated: ${modified(executionResult)}`); + + await mongoose.disconnect(); + console.log('Done.'); + process.exit(0); +}).catch((error) => { + console.error('Backfill failed:', error.message); + process.exit(1); +}); diff --git a/server.js b/server.js index b32c2ce..c9f4d90 100644 --- a/server.js +++ b/server.js @@ -16,6 +16,7 @@ const authConfig = require('./config/auth.config.js'); // provider strategy registry, (re)configured after the DB settings load. const { configureProviders } = require('./app/utils/passport-setup.js'); const authSettingsService = require('./app/utils/auth-settings-service.js'); +const featureSettingsService = require('./app/utils/feature-settings-service.js'); const adminSeedService = require('./app/utils/admin-seed-service.js'); // mongo db config const dbConfig = require('./config/database.config.js'); @@ -114,6 +115,15 @@ mongoose.connect(mongoURL, { } catch (err) { logger.error('Could not load auth settings', err); } + // Load the persisted feature toggles onto the in-memory config the route guards read. + // A failure here leaves the defaults in place (every feature on), which is the same + // behaviour the instance had before toggles existed. + try { + const features = await featureSettingsService.loadFeatureSettings(); + logger.info('Feature settings loaded (manual testing %s)', features.manualTestingEnabled !== false ? 'enabled' : 'disabled'); + } catch (err) { + logger.error('Could not load feature settings', err); + } }).catch((err) => { logger.error('Could not connect to the database. Exiting now...', err); process.exit(); @@ -186,6 +196,12 @@ require('./app/routes/execution.routes.js')(app, '/rest/api/v1.0'); require('./app/routes/screenshot.routes.js')(app, '/rest/api/v1.0'); require('./app/routes/baseline.routes.js')(app, '/rest/api/v1.0'); require('./app/routes/metrics.routes.js')(app, '/rest/api/v1.0'); +require('./app/routes/attachment.routes.js')(app, '/rest/api/v1.0'); +require('./app/routes/custom-field.routes.js')(app, '/rest/api/v1.0'); +require('./app/routes/shared-step.routes.js')(app, '/rest/api/v1.0'); +require('./app/routes/manual-folder.routes.js')(app, '/rest/api/v1.0'); +require('./app/routes/manual-test-case.routes.js')(app, '/rest/api/v1.0'); +require('./app/routes/manual-test-run.routes.js')(app, '/rest/api/v1.0'); require('./app/routes/angles.routes.js')(app, '/rest/api/v1.0'); // listen for requests diff --git a/setup/docker-compose.yml b/setup/docker-compose.yml index ee0cc9f..5035f3b 100644 --- a/setup/docker-compose.yml +++ b/setup/docker-compose.yml @@ -31,6 +31,11 @@ services: - SWAGGER_SCHEMES=http - PORT=3000 - BUILD_CLEAN_UP_AGE_IN_DAYS=90 + # Seeds the manual test case management feature the first time the instance + # starts. Set to 'false' to ship with it turned off. It is only a seed: once + # the settings exist, admins control it from Admin -> Settings and this is + # ignored, so a restart never reverts their choice. + - ANGLES_MANUAL_TESTING_ENABLED=${ANGLES_MANUAL_TESTING_ENABLED:-true} - MONGO_URL=mongodb://angleshq:%40nglesPassword@mongodb:27017/angles # Public origin of this instance. Each SSO provider's redirect/callback URL is # derived from it, and must match what is registered with the identity provider, @@ -47,6 +52,7 @@ services: volumes: - angles_screenshots:/app/screenshots - angles_screenshot_compares:/app/compares + - angles_attachments:/app/attachments frontend: container_name: "angles-ui" @@ -70,3 +76,5 @@ volumes: name: angles_screenshots angles_screenshot_compares: name: angels_screenshot_compares + angles_attachments: + name: angles_attachments diff --git a/setup/kubernetes/02-angles-config.yaml b/setup/kubernetes/02-angles-config.yaml index 9852f44..0c2dc72 100644 --- a/setup/kubernetes/02-angles-config.yaml +++ b/setup/kubernetes/02-angles-config.yaml @@ -8,6 +8,11 @@ data: SWAGGER_SCHEMES: "http" PORT: "3002" BUILD_CLEAN_UP_AGE_IN_DAYS: "90" + # Seeds manual test case management the first time this instance starts. Set to + # "false" to deploy with the feature turned off. It is only a seed: once the + # settings document exists, admins control the toggle from Admin -> Settings and + # this value is ignored, so a restart never reverts their choice. + ANGLES_MANUAL_TESTING_ENABLED: "true" # change these credentials MONGO_ANGLES_USERNAME: "angleshq" MONGO_ANGLES_PASSWORD: "Password123" @@ -63,6 +68,8 @@ data: db.createCollection('environments'); db.createCollection('baselines'); db.createCollection('screenshots'); + db.createCollection('attachments'); + db.createCollection('manualtestruns'); db.testexecutions.createIndex({ build: 1 }); db.testexecutions.createIndex({ suite: 1, title: 1 }); @@ -72,6 +79,10 @@ data: db.environment.createIndex({ name: 1 }); db.screenshots.createIndex({ build: 1 }); db.screenshots.createIndex({ view: 1 }); + db.attachments.createIndex({ testCase: 1 }); + db.attachments.createIndex({ sharedStep: 1 }); + db.manualtestruns.createIndex({ team: 1, createdAt: -1 }); + db.builds.createIndex({ team: 1, executionType: 1, start: -1 }); EOF diff --git a/setup/kubernetes/04-angles-pod.yaml b/setup/kubernetes/04-angles-pod.yaml index c1e834e..35aaa2f 100644 --- a/setup/kubernetes/04-angles-pod.yaml +++ b/setup/kubernetes/04-angles-pod.yaml @@ -34,6 +34,9 @@ spec: - mountPath: "/app/compares" subPath: "./compares" name: angles-persistent-volume + - mountPath: "/app/attachments" + subPath: "./attachments" + name: angles-persistent-volume volumes: - name: angles-persistent-volume persistentVolumeClaim: diff --git a/setup/mongo-init.js b/setup/mongo-init.js index a9ce99b..09540bb 100644 --- a/setup/mongo-init.js +++ b/setup/mongo-init.js @@ -11,6 +11,8 @@ db.createCollection('phase'); db.createCollection('environments'); db.createCollection('baselines'); db.createCollection('screenshots'); +db.createCollection('attachments'); +db.createCollection('manualtestruns'); db.createCollection('users'); // The initial admin user is seeded by the backend at startup from the @@ -26,3 +28,7 @@ db.phase.createIndex({ name: 1 }); db.environment.createIndex({ name: 1 }); db.screenshots.createIndex({ build: 1 }); db.screenshots.createIndex({ view: 1 }); +db.attachments.createIndex({ testCase: 1 }); +db.attachments.createIndex({ sharedStep: 1 }); +db.manualtestruns.createIndex({ team: 1, createdAt: -1 }); +db.builds.createIndex({ team: 1, executionType: 1, start: -1 }); diff --git a/swagger/swagger.json b/swagger/swagger.json index cc1b762..b2351cd 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -45,6 +45,30 @@ { "name": "Monitoring", "description": "Prometheus scrape endpoint for resource and application metrics" + }, + { + "name": "Manual Folder", + "description": "Folder tree used to organise manual test cases. Nests to any depth." + }, + { + "name": "Manual Test Case", + "description": "API for managing manual test cases and their version history" + }, + { + "name": "Custom Field", + "description": "API for configuring the custom fields available to a team's manual test cases" + }, + { + "name": "Shared Step", + "description": "API for managing re-usable step sequences shared by manual test cases" + }, + { + "name": "Attachment", + "description": "API for images attached to manual test steps" + }, + { + "name": "Manual Test Run", + "description": "API for planning and executing manual test runs" } ], "security": [ @@ -847,6 +871,19 @@ "type": "string" } }, + { + "name": "executionType", + "in": "query", + "required": false, + "description": "Narrow to automated or manual results. Omit for both, which is the behaviour every client saw before manual runs existed.", + "schema": { + "type": "string", + "enum": [ + "automated", + "manual" + ] + } + }, { "name": "returnExecutionDetails", "in": "query", @@ -3174,6 +3211,19 @@ "type": "string" } }, + { + "name": "executionType", + "in": "query", + "required": false, + "description": "Narrow to automated or manual results. Omit for both, which is the behaviour every client saw before manual runs existed.", + "schema": { + "type": "string", + "enum": [ + "automated", + "manual" + ] + } + }, { "name": "fromDate", "in": "query", @@ -4134,7 +4184,7 @@ "providers": { "type": "array", "items": { - "$ref": "#/definitions/AuthProviderPublic" + "$ref": "#/components/schemas/AuthProviderPublic" } } } @@ -4169,7 +4219,7 @@ "providers": { "type": "array", "items": { - "$ref": "#/definitions/AuthProvider" + "$ref": "#/components/schemas/AuthProvider" } } } @@ -4193,7 +4243,7 @@ "providers": { "type": "array", "items": { - "$ref": "#/definitions/AuthProviderPublic" + "$ref": "#/components/schemas/AuthProviderPublic" } } } @@ -4233,317 +4283,4472 @@ } } } - } - }, - "servers": [ - { - "url": "http://127.0.0.1:3000/rest/api/v1.0" - } - ], - "components": { - "securitySchemes": { - "ApiKeyAuth": { - "type": "apiKey", - "in": "header", - "name": "x-api-key", - "description": "API token issued via 'POST /users/{userId}/tokens'. Paste the raw token value (only shown once, at creation time) to authorise the 'Try it out' requests." - }, - "MetricsBearerAuth": { - "type": "http", - "scheme": "bearer", - "description": "Value of the ANGLES_METRICS_TOKEN environment variable, used only by the Prometheus scrape endpoint." - } }, - "schemas": { - "ImageFindMatch": { - "type": "object", - "properties": { - "x": { - "type": "integer", - "description": "Left edge of the matched region, in original screenshot pixels" - }, - "y": { - "type": "integer", - "description": "Top edge of the matched region, in original screenshot pixels" - }, - "width": { - "type": "integer" - }, - "height": { - "type": "integer" - }, - "confidence": { - "type": "number", - "description": "Normalized cross-correlation score (0-1)" - }, - "scale": { - "type": "number", - "description": "Template scale at which the match was found" + "/manual-test-case": { + "post": { + "tags": [ + "Manual Test Case" + ], + "summary": "Create a manual test case", + "description": "Creates a test case and freezes its first version. The caller must have access to the team.", + "requestBody": { + "description": "The manual test case to create.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualTestCase" + } + } } - } - }, - "ImageFindResponse": { - "type": "object", - "properties": { - "matches": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ImageFindMatch" + }, + "responses": { + "201": { + "description": "Manual test case created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredManualTestCase" + } + } } }, - "bestMatch": { - "$ref": "#/components/schemas/ImageFindMatch", - "nullable": true + "400": { + "description": "One or more custom field values are invalid; the message lists every problem found. Also returned when a required custom field is missing on a case being saved with a status other than DRAFT.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } }, - "imageDimensions": { - "type": "object", - "properties": { - "width": { - "type": "integer" - }, - "height": { - "type": "integer" + "403": { + "description": "No access to the team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } } } }, - "templateDimensions": { - "type": "object", - "properties": { - "width": { - "type": "integer" - }, - "height": { - "type": "integer" + "404": { + "description": "No team, component, referenced shared step, or referenced attachment found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } } } }, - "analysisTime": { - "type": "integer", - "description": "Search duration in milliseconds" - } - } - }, - "DefaultResponse": { - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string", - "uniqueItems": true - } - } - }, - "ExpressValidatorError": { - "properties": { - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "msg": { - "type": "string" - }, - "param": { - "type": "string" - }, - "location": { - "type": "string" + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" } } } } } }, - "Environment": { - "required": [ - "name" + "get": { + "tags": [ + "Manual Test Case" ], - "properties": { - "name": { - "type": "string", - "uniqueItems": true - } - } - }, - "StoredEnvironment": { - "allOf": [ + "summary": "Search the manual test cases for a team", + "parameters": [ { - "$ref": "#/components/schemas/Environment" + "name": "teamId", + "in": "query", + "required": true, + "description": "ID of the team to retrieve test cases for.", + "schema": { + "type": "string" + } }, { - "properties": { - "_id": { - "type": "string", - "uniqueItems": true - } + "name": "status", + "in": "query", + "required": false, + "description": "Filter by status, comma separated (DRAFT, ACTIVE, DEPRECATED).", + "schema": { + "type": "string" } - } - ] - }, - "Team": { - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "uniqueItems": true }, - "components": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "StoredTeam": { - "allOf": [ { - "$ref": "#/components/schemas/Team" + "name": "priority", + "in": "query", + "required": false, + "description": "Filter by priority, comma separated (LOW, MEDIUM, HIGH, CRITICAL).", + "schema": { + "type": "string" + } }, { - "properties": { - "_id": { - "type": "string", - "uniqueItems": true - } + "name": "tags", + "in": "query", + "required": false, + "description": "Filter by tags, comma separated. A test case must carry every tag listed.", + "schema": { + "type": "string" } - } - ] + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the title and description.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of test cases to return (defaults to 25).", + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "in": "query", + "required": false, + "description": "Number of test cases to skip (defaults to 0).", + "schema": { + "type": "number" + } + }, + { + "in": "query", + "name": "folder", + "required": false, + "schema": { + "type": "string" + }, + "description": "Narrow to a folder id, or \"none\" for test cases that are not filed in any folder." + }, + { + "in": "query", + "name": "includeSubFolders", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "When true and `folder` is an id, also returns cases in every sub-folder beneath it." + } + ], + "responses": { + "200": { + "description": "Matching manual test cases", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualTestCasesResponse" + } + } + } + }, + "403": { + "description": "No access to the team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No team found with the given id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/manual-test-case/{caseId}": { + "parameters": [ + { + "name": "caseId", + "in": "path", + "required": true, + "description": "ID of the manual test case.", + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Manual Test Case" + ], + "summary": "Get the current version of a manual test case", + "responses": { + "200": { + "description": "Manual test case found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredManualTestCase" + } + } + } + }, + "403": { + "description": "No access to this test case", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No manual test case found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + }, + "description": "Returns the mutable head. Its steps carry shared-step placeholders as stored; pass `expand=true` to resolve them into the steps a tester would follow.", + "parameters": [ + { + "name": "expand", + "in": "query", + "required": false, + "description": "Expand shared-step inclusions into their steps, renumbering order contiguously. The response carries `expanded: true`.", + "schema": { + "type": "boolean" + } + } + ] + }, + "put": { + "tags": [ + "Manual Test Case" + ], + "summary": "Update a manual test case", + "description": "Only the fields present in the body are updated. Changing content (title, description, preconditions, priority, tags, steps or custom field values) increments the version and freezes a new one; a status-only change, or an update that changes nothing, leaves the version untouched.", + "requestBody": { + "description": "The fields to update.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualTestCase" + } + } + } + }, + "responses": { + "200": { + "description": "Manual test case updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredManualTestCase" + } + } + } + }, + "400": { + "description": "One or more custom field values are invalid; the message lists every problem found. Also returned when a required custom field is missing on a case being saved with a status other than DRAFT.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "403": { + "description": "No access to this test case", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No team, component, referenced shared step, or referenced attachment found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Manual Test Case" + ], + "summary": "Delete a manual test case and its version history", + "description": "Requires team lead access. Removes the test case together with every frozen version of it.", + "responses": { + "200": { + "description": "Manual test case deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "403": { + "description": "Team lead access is required to delete a test case", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No manual test case found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/manual-test-case/{caseId}/clone": { + "parameters": [ + { + "name": "caseId", + "in": "path", + "required": true, + "description": "ID of the manual test case.", + "schema": { + "type": "string" + } + } + ], + "post": { + "tags": [ + "Manual Test Case" + ], + "summary": "Clone a manual test case", + "description": "Copies the test case into a new one that starts its own version history at 1 and always begins as a DRAFT.", + "requestBody": { + "description": "Optional title for the clone. Defaults to the original title with \" (copy)\" appended.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 200 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Manual test case cloned", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredManualTestCase" + } + } + } + }, + "403": { + "description": "No access to this test case", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No manual test case found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/manual-test-case/{caseId}/version": { + "parameters": [ + { + "name": "caseId", + "in": "path", + "required": true, + "description": "ID of the manual test case.", + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Manual Test Case" + ], + "summary": "List the version history of a manual test case", + "description": "Returns version metadata only, newest first.", + "responses": { + "200": { + "description": "Version history", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualTestCaseVersionsResponse" + } + } + } + }, + "403": { + "description": "No access to this test case", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No manual test case found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/manual-test-case/{caseId}/version/{version}": { + "parameters": [ + { + "name": "caseId", + "in": "path", + "required": true, + "description": "ID of the manual test case.", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "The version number to retrieve.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "get": { + "tags": [ + "Manual Test Case" + ], + "summary": "Get the frozen content of one version", + "description": "Returns the test case exactly as it stood at this version. This is what an execution recorded against that version renders, and it is unaffected by any later edit to the test case.", + "responses": { + "200": { + "description": "Frozen version content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualTestCaseVersion" + } + } + } + }, + "403": { + "description": "No access to this test case", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No manual test case, or no such version, found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/custom-field": { + "post": { + "tags": [ + "Custom Field" + ], + "summary": "Configure a custom field for a team", + "description": "Admin only. The key must be unique within the team, including against archived fields.", + "requestBody": { + "description": "The custom field to configure.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomFieldDefinition" + } + } + } + }, + "responses": { + "201": { + "description": "Custom field created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredCustomFieldDefinition" + } + } + } + }, + "400": { + "description": "Options are missing for a select field, or supplied for a type that cannot have them", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "403": { + "description": "Admin access is required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No team found with the given id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "409": { + "description": "A field with this key already exists for the team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + }, + "get": { + "tags": [ + "Custom Field" + ], + "summary": "List the custom fields configured for a team", + "description": "Readable by anyone with team access - the authoring UI needs the definitions to render its form.", + "parameters": [ + { + "name": "teamId", + "in": "query", + "required": true, + "description": "ID of the team to list custom fields for.", + "schema": { + "type": "string" + } + }, + { + "name": "includeArchived", + "in": "query", + "required": false, + "description": "Include archived fields. Defaults to false.", + "schema": { + "type": "boolean" + } + }, + { + "name": "appliesTo", + "in": "query", + "required": false, + "description": "Only return fields applying to this entity. Fields set to \"both\" always match.", + "schema": { + "type": "string", + "enum": [ + "testcase", + "execution", + "both" + ] + } + } + ], + "responses": { + "200": { + "description": "The team's custom fields, in display order", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomFieldsResponse" + } + } + } + }, + "403": { + "description": "No access to the team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No team found with the given id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/custom-field/{fieldId}": { + "parameters": [ + { + "name": "fieldId", + "in": "path", + "required": true, + "description": "ID of the custom field definition.", + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Custom Field" + ], + "summary": "Get a custom field definition", + "responses": { + "200": { + "description": "Custom field found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredCustomFieldDefinition" + } + } + } + }, + "403": { + "description": "No access to this custom field", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No custom field found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Custom Field" + ], + "summary": "Update a custom field definition", + "description": "Admin only. The key can never be changed. The type cannot be changed while any test case holds a value for the field, and an option cannot be removed while a test case still uses it - both would leave existing values unrenderable.", + "requestBody": { + "description": "The fields to update.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomFieldDefinition" + } + } + } + }, + "responses": { + "200": { + "description": "Custom field updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredCustomFieldDefinition" + } + } + } + }, + "400": { + "description": "An attempt to change the key, or an invalid option configuration", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "403": { + "description": "Admin access is required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No custom field found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "409": { + "description": "The type or options cannot change because existing test cases depend on them", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Custom Field" + ], + "summary": "Delete or archive a custom field definition", + "description": "Admin only. A field whose values appear on any test case or in any frozen version is archived rather than removed, so historical values keep the label, type and options needed to render them. Only a field that has never been used is hard-deleted. Check the `archived` flag on the response to see which happened.", + "responses": { + "200": { + "description": "Custom field deleted or archived", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CustomFieldDeleteResponse" + } + } + } + }, + "403": { + "description": "Admin access is required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No custom field found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/shared-step": { + "post": { + "tags": [ + "Shared Step" + ], + "summary": "Create a re-usable shared step", + "description": "Requires team lead access. The name must be unique within the team, and the steps must all be literal.", + "requestBody": { + "description": "The shared step to create.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedStep" + } + } + } + }, + "responses": { + "201": { + "description": "Shared step created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredSharedStep" + } + } + } + }, + "400": { + "description": "A step tried to include another shared step", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "403": { + "description": "Team lead access is required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No team found with the given id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "409": { + "description": "A shared step with this name already exists for the team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + }, + "get": { + "tags": [ + "Shared Step" + ], + "summary": "Search a team's shared step library", + "description": "Readable by any team member - authors browse the library to include a step.", + "parameters": [ + { + "name": "teamId", + "in": "query", + "required": true, + "description": "ID of the team to list shared steps for.", + "schema": { + "type": "string" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the name and description.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number to return (defaults to 25).", + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "in": "query", + "required": false, + "description": "Number to skip (defaults to 0).", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "Matching shared steps", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedStepsResponse" + } + } + } + }, + "403": { + "description": "No access to the team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No team found with the given id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/shared-step/{sharedStepId}": { + "parameters": [ + { + "name": "sharedStepId", + "in": "path", + "required": true, + "description": "ID of the shared step.", + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Shared Step" + ], + "summary": "Get a shared step", + "responses": { + "200": { + "description": "Shared step found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredSharedStep" + } + } + } + }, + "403": { + "description": "No access to this shared step", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No shared step found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Shared Step" + ], + "summary": "Update a shared step, cascading into every referencing test case", + "description": "Requires team lead access. Changing the steps bumps the shared step version and writes a new frozen version for every test case that includes it, re-expanded with the new content - so previously written versions keep showing what their tester saw. Renaming or re-describing the shared step changes no test case content and cascades to nothing. Check `cascade.testCasesVersioned` on the response, and call the usage endpoint first to see how many cases an edit will affect.", + "requestBody": { + "description": "The fields to update.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedStep" + } + } + } + }, + "responses": { + "200": { + "description": "Shared step updated, with the cascade outcome", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedStepUpdateResponse" + } + } + } + }, + "400": { + "description": "A step tried to include another shared step", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "403": { + "description": "Team lead access is required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No shared step found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "409": { + "description": "A shared step with this name already exists for the team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Shared Step" + ], + "summary": "Delete a shared step", + "description": "Requires team lead access. A shared step that any test case still includes cannot be deleted - the referencing cases would expand to an unresolved placeholder and silently lose steps. Remove the inclusions first; the usage endpoint lists them.", + "responses": { + "200": { + "description": "Shared step deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "403": { + "description": "Team lead access is required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No shared step found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "409": { + "description": "The shared step is still included by one or more test cases", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/shared-step/{sharedStepId}/usage": { + "parameters": [ + { + "name": "sharedStepId", + "in": "path", + "required": true, + "description": "ID of the shared step.", + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Shared Step" + ], + "summary": "List the test cases that include this shared step", + "description": "Tells an author how many test cases an edit is about to version, and which ones block a delete.", + "responses": { + "200": { + "description": "The referencing test cases", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedStepUsageResponse" + } + } + } + }, + "403": { + "description": "No access to this shared step", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No shared step found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/attachment": { + "post": { + "tags": [ + "Attachment" + ], + "summary": "Upload an image and attach it to a manual test step", + "description": "Multipart upload. Exactly one owner id must be supplied. Only image mime types are accepted and the file is capped at 10MB; the client filename is discarded and a name generated server-side. A thumbnail is produced on upload and served separately.", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "attachment" + ], + "properties": { + "attachment": { + "type": "string", + "format": "binary", + "description": "The image file." + }, + "testCaseId": { + "type": "string", + "description": "Attach to a manual test case." + }, + "sharedStepId": { + "type": "string", + "description": "Attach to a shared step." + }, + "executionId": { + "type": "string", + "description": "Attach to a manual execution. Accepted by the upload path but not yet usable." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Attachment stored", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredAttachment" + } + } + } + }, + "400": { + "description": "No file supplied, an unsupported file type, or no valid owner id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "403": { + "description": "No access to the owning team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No test case or shared step found with the given id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + }, + "get": { + "tags": [ + "Attachment" + ], + "summary": "List the attachments belonging to a test case or shared step", + "parameters": [ + { + "name": "testCaseId", + "in": "query", + "required": false, + "description": "List the attachments of this manual test case.", + "schema": { + "type": "string" + } + }, + { + "name": "sharedStepId", + "in": "query", + "required": false, + "description": "List the attachments of this shared step.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The entity's attachments", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttachmentsResponse" + } + } + } + }, + "403": { + "description": "No access to the owning team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No test case or shared step found with the given id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/attachment/{attachmentId}": { + "parameters": [ + { + "name": "attachmentId", + "in": "path", + "required": true, + "description": "ID of the attachment.", + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Attachment" + ], + "summary": "Get an attachment's metadata", + "responses": { + "200": { + "description": "Attachment found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredAttachment" + } + } + } + }, + "403": { + "description": "No access to this attachment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No attachment found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Attachment" + ], + "summary": "Delete an attachment", + "description": "An attachment referenced by any frozen test case version is never deleted - the file is the only copy of the image that version's tester saw, so a historical execution would lose it. Remove the image from the test case instead: that takes it off the head while leaving it available to the executions that used it. Deleting the whole test case removes its versions first, which does release the attachments. A successful delete also clears the reference from any head step, so nothing renders as a broken image.", + "responses": { + "200": { + "description": "Attachment deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "403": { + "description": "No access to this attachment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No attachment found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "409": { + "description": "A frozen test case version still references this attachment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/attachment/{attachmentId}/file": { + "parameters": [ + { + "name": "attachmentId", + "in": "path", + "required": true, + "description": "ID of the attachment.", + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Attachment" + ], + "summary": "Download the original image", + "responses": { + "200": { + "description": "The image file", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "403": { + "description": "No access to this attachment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No attachment found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/attachment/{attachmentId}/thumbnail": { + "parameters": [ + { + "name": "attachmentId", + "in": "path", + "required": true, + "description": "ID of the attachment.", + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Attachment" + ], + "summary": "Download the thumbnail", + "description": "Falls back to the original when no thumbnail could be generated, so this never 404s for an attachment that exists.", + "responses": { + "200": { + "description": "The thumbnail image", + "content": { + "image/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "403": { + "description": "No access to this attachment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No attachment found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/manual-test-case/{caseId}/history": { + "parameters": [ + { + "name": "caseId", + "in": "path", + "required": true, + "description": "ID of the manual test case.", + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Manual Test Case" + ], + "summary": "Read a test case's change history", + "description": "Every mutation to the case, newest first, including status changes that burn no version and re-versions caused by a shared step edit.", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum entries to return (defaults to 20).", + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "in": "query", + "required": false, + "description": "Entries to skip (defaults to 0).", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "The case's change history", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualChangeHistoryResponse" + } + } + } + }, + "403": { + "description": "No access to this test case", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No manual test case found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/shared-step/{sharedStepId}/history": { + "parameters": [ + { + "name": "sharedStepId", + "in": "path", + "required": true, + "description": "ID of the shared step.", + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Shared Step" + ], + "summary": "Read a shared step's change history", + "description": "Every mutation to the shared step itself, newest first. The entries recording the knock-on re-versioning of each referencing test case live on those cases, not here.", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum entries to return (defaults to 20).", + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "in": "query", + "required": false, + "description": "Entries to skip (defaults to 0).", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "The shared step's change history", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualChangeHistoryResponse" + } + } + } + }, + "403": { + "description": "No access to this shared step", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No shared step found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/manual-test-run": { + "post": { + "tags": [ + "Manual Test Run" + ], + "summary": "Plan a manual test run", + "description": "Creates the run and the manual-tagged Build its results will be written into, and binds every selected test case to its current frozen version. If the run cannot be created the build is removed again, so no empty manual build is left on the dashboard.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualTestRun" + } + } + } + }, + "responses": { + "201": { + "description": "Test run created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredManualTestRun" + } + } + } + }, + "400": { + "description": "A deprecated test case, or a case from a different component", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "403": { + "description": "No access to the team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No team, environment, phase, component or test case found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + }, + "get": { + "tags": [ + "Manual Test Run" + ], + "summary": "List a team's manual test runs", + "parameters": [ + { + "name": "teamId", + "in": "query", + "required": true, + "description": "ID of the team.", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "Filter by run status, comma separated.", + "schema": { + "type": "string" + } + }, + { + "name": "assignedTo", + "in": "query", + "required": false, + "description": "Filter by the user a run is assigned to.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum runs to return (defaults to 25).", + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "in": "query", + "required": false, + "description": "Runs to skip (defaults to 0).", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "Matching test runs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualTestRunsResponse" + } + } + } + }, + "403": { + "description": "No access to the team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No team found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/manual-test-run/{runId}": { + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "ID of the manual test run.", + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Manual Test Run" + ], + "summary": "Get a manual test run", + "description": "Pass expand=true to include each case's frozen version document - the steps the tester should follow, read from the bound version rather than the current test case.", + "parameters": [ + { + "name": "expand", + "in": "query", + "required": false, + "description": "Include each case's bound frozen version.", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Test run found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredManualTestRun" + } + } + } + }, + "403": { + "description": "No access to this test run", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No test run found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Manual Test Run" + ], + "summary": "Update a run, or add test cases to it", + "description": "Cases named in testCaseIds that are not already in the run are appended, each bound to its current version at that moment. Cases already present are ignored rather than duplicated.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualTestRun" + } + } + } + }, + "responses": { + "200": { + "description": "Test run updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredManualTestRun" + } + } + } + }, + "400": { + "description": "A deprecated test case, or a case from a different component", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "403": { + "description": "No access to this test run", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No test run or test case found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "Manual Test Run" + ], + "summary": "Delete a manual test run", + "description": "Requires team lead access. The backing build and its executions exist only to represent this run on the dashboard, so they are removed with it.", + "responses": { + "200": { + "description": "Test run deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "403": { + "description": "Team lead access is required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No test run found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/manual-test-run/{runId}/test-case/{caseId}/result": { + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "ID of the manual test run.", + "schema": { + "type": "string" + } + }, + { + "name": "caseId", + "in": "path", + "required": true, + "description": "ID of the manual test case within the run.", + "schema": { + "type": "string" + } + } + ], + "put": { + "tags": [ + "Manual Test Run" + ], + "summary": "Record the result of one test case in a run", + "description": "Writes a TestExecution tagged executionType \"manual\" and folds it into the run's build through the same code path automated results take, so the result map and status are computed identically for both. Re-recording updates the existing execution rather than adding a second one. The run's own status is re-derived, and completing the last outstanding case stamps its end date.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "NOT_RUN", + "IN_PROGRESS", + "PASS", + "FAIL", + "ERROR", + "SKIPPED", + "BLOCKED" + ] + }, + "notes": { + "type": "string", + "maxLength": 5000 + }, + "stepResults": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManualStepResult" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Result recorded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredManualTestRun" + } + } + } + }, + "403": { + "description": "No access to this test run", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No test run, or the case is not part of it", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "409": { + "description": "The test run has been cancelled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/manual-test-run/{runId}/test-case/{caseId}/rebind": { + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "ID of the manual test run.", + "schema": { + "type": "string" + } + }, + { + "name": "caseId", + "in": "path", + "required": true, + "description": "ID of the manual test case within the run.", + "schema": { + "type": "string" + } + } + ], + "put": { + "tags": [ + "Manual Test Run" + ], + "summary": "Move a not-yet-run case onto the latest version of its test case", + "description": "Refused once a result has been recorded: re-pointing an execution at content it was never run against is exactly the silent history rewrite the version binding exists to prevent. Add the case to a new run instead. Any step results recorded against the old version are cleared, since they are keyed by that version's step ids.", + "responses": { + "200": { + "description": "Case rebound to the latest version", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredManualTestRun" + } + } + } + }, + "403": { + "description": "No access to this test run", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No test run, test case, or the case is not part of the run", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "409": { + "description": "The case already has a recorded result, or is already on the latest version", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/manual-test-run/{runId}/status": { + "parameters": [ + { + "name": "runId", + "in": "path", + "required": true, + "description": "ID of the manual test run.", + "schema": { + "type": "string" + } + } + ], + "put": { + "tags": [ + "Manual Test Run" + ], + "summary": "Transition a run's status", + "description": "Completing or cancelling stamps the end date. A cancelled run refuses further results.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "PLANNED", + "IN_PROGRESS", + "COMPLETED", + "CANCELLED" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Status updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredManualTestRun" + } + } + } + }, + "403": { + "description": "No access to this test run", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "No test run found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpressValidatorError" + } + } + } + } + } + } + }, + "/manual-folder": { + "post": { + "tags": [ + "Manual Folder" + ], + "summary": "Create a folder", + "description": "Creates a folder in the team tree. Omit `parent` (or send null) for a root folder. Folder names must be unique among their siblings.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualFolder" + } + } + } + }, + "responses": { + "201": { + "description": "Folder created.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredManualFolder" + } + } + } + }, + "403": { + "description": "No access to the team." + }, + "404": { + "description": "Team or parent folder not found." + }, + "409": { + "description": "A folder with that name already exists under the same parent." + }, + "422": { + "description": "Validation failed." + } + } + }, + "get": { + "tags": [ + "Manual Folder" + ], + "summary": "Get the team folder tree", + "description": "Returns the whole tree nested, with a test case count on each folder and a count of unfiled cases.", + "parameters": [ + { + "in": "query", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The folder tree.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualFoldersResponse" + } + } + } + }, + "403": { + "description": "No access to the team." + }, + "404": { + "description": "Team not found." + } + } + } + }, + "/manual-folder/move": { + "put": { + "tags": [ + "Manual Folder" + ], + "summary": "File test cases into a folder", + "description": "Moves one or more test cases into a folder, or to the team root when `folder` is null. Filing is organisation rather than content: it does not create a new test case version, but it is recorded in the change history as a MOVE.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveManualTestCases" + } + } + } + }, + "responses": { + "200": { + "description": "Test cases moved." + }, + "400": { + "description": "Test cases from more than one team." + }, + "403": { + "description": "No access to the team." + }, + "404": { + "description": "A test case or the target folder was not found." + }, + "422": { + "description": "Validation failed." + } + } + } + }, + "/manual-folder/{folderId}": { + "get": { + "tags": [ + "Manual Folder" + ], + "summary": "Get a folder", + "description": "Returns one folder with its ancestors populated and the counts of what it contains.", + "parameters": [ + { + "in": "path", + "name": "folderId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredManualFolder" + } + } + } + }, + "403": { + "description": "No access to the folder." + }, + "404": { + "description": "Folder not found." + } + } + }, + "put": { + "tags": [ + "Manual Folder" + ], + "summary": "Rename or move a folder", + "description": "Renames a folder and/or re-parents it. Moving takes the whole subtree with it. A folder cannot be moved inside itself or inside one of its own sub-folders. Send `parent: null` to move it to the root.", + "parameters": [ + { + "in": "path", + "name": "folderId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ManualFolder" + } + } + } + }, + "responses": { + "200": { + "description": "Folder updated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StoredManualFolder" + } + } + } + }, + "400": { + "description": "The move would place the folder inside its own subtree, or nest too deeply." + }, + "403": { + "description": "No access to the folder." + }, + "404": { + "description": "Folder or new parent not found." + }, + "409": { + "description": "A folder with that name already exists under the same parent." + }, + "422": { + "description": "Validation failed." + } + } + }, + "delete": { + "tags": [ + "Manual Folder" + ], + "summary": "Delete an empty folder", + "description": "Deletes a folder. Refused with a 409 while it still contains test cases or sub-folders, so nothing is removed or re-parented by accident. Requires team lead access.", + "parameters": [ + { + "in": "path", + "name": "folderId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Folder deleted." + }, + "403": { + "description": "Team lead access required." + }, + "404": { + "description": "Folder not found." + }, + "409": { + "description": "The folder still contains test cases or sub-folders." + } + } + } + } + }, + "servers": [ + { + "url": "http://127.0.0.1:3000/rest/api/v1.0" + } + ], + "components": { + "securitySchemes": { + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "x-api-key", + "description": "API token issued via 'POST /users/{userId}/tokens'. Paste the raw token value (only shown once, at creation time) to authorise the 'Try it out' requests." + }, + "MetricsBearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Value of the ANGLES_METRICS_TOKEN environment variable, used only by the Prometheus scrape endpoint." + } + }, + "schemas": { + "ImageFindMatch": { + "type": "object", + "properties": { + "x": { + "type": "integer", + "description": "Left edge of the matched region, in original screenshot pixels" + }, + "y": { + "type": "integer", + "description": "Top edge of the matched region, in original screenshot pixels" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "confidence": { + "type": "number", + "description": "Normalized cross-correlation score (0-1)" + }, + "scale": { + "type": "number", + "description": "Template scale at which the match was found" + } + } + }, + "ImageFindResponse": { + "type": "object", + "properties": { + "matches": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageFindMatch" + } + }, + "bestMatch": { + "$ref": "#/components/schemas/ImageFindMatch", + "nullable": true + }, + "imageDimensions": { + "type": "object", + "properties": { + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + }, + "templateDimensions": { + "type": "object", + "properties": { + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + }, + "analysisTime": { + "type": "integer", + "description": "Search duration in milliseconds" + } + } + }, + "DefaultResponse": { + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "uniqueItems": true + } + } + }, + "ExpressValidatorError": { + "properties": { + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "msg": { + "type": "string" + }, + "param": { + "type": "string" + }, + "location": { + "type": "string" + } + } + } + } + } + }, + "Environment": { + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "uniqueItems": true + } + } + }, + "StoredEnvironment": { + "allOf": [ + { + "$ref": "#/components/schemas/Environment" + }, + { + "properties": { + "_id": { + "type": "string", + "uniqueItems": true + } + } + } + ] + }, + "Team": { + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "uniqueItems": true + }, + "components": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + }, + "StoredTeam": { + "allOf": [ + { + "$ref": "#/components/schemas/Team" + }, + { + "properties": { + "_id": { + "type": "string", + "uniqueItems": true + } + } + } + ] + }, + "Phase": { + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "uniqueItems": true + }, + "orderNumber": { + "type": "number" + } + } + }, + "StoredPhase": { + "allOf": [ + { + "$ref": "#/components/schemas/Phase" + }, + { + "properties": { + "_id": { + "type": "string", + "uniqueItems": true + } + } + } + ] + }, + "Build": { + "required": [ + "name", + "environment", + "component", + "team", + "start" + ], + "properties": { + "name": { + "type": "string" + }, + "environment": { + "type": "string" + }, + "component": { + "type": "string" + }, + "team": { + "type": "string" + }, + "phase": { + "type": "string" + }, + "artifacts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Artifact" + } + }, + "start": { + "type": "string", + "format": "date-time" + }, + "executions": { + "type": "array", + "description": "Optional test executions to store with the build, so the whole build can be created in a single call. Executions are grouped into suites by their 'suite' name and the build metrics are calculated from them. Omit this to add executions individually afterwards via POST /execution.", + "items": { + "$ref": "#/components/schemas/BuildExecution" + } + }, + "executionType": { + "type": "string", + "enum": [ + "automated", + "manual" + ], + "default": "automated", + "description": "Whether the results came from an automated run or a manual test run. Defaults to \"automated\", so documents written before manual runs existed read back unchanged." + } + } + }, + "BuildExecution": { + "description": "An execution supplied as part of a POST /build request. Identical to Execution but without 'build', which is the build being created.", + "required": [ + "title", + "suite" + ], + "properties": { + "title": { + "type": "string" + }, + "suite": { + "type": "string" + }, + "feature": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Action" + } + }, + "platforms": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Platform" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "meta": { + "type": "object" + }, + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + } + } + }, + "StoredBuild": { + "allOf": [ + { + "$ref": "#/components/schemas/Build" + }, + { + "properties": { + "_id": { + "type": "string", + "uniqueItems": true + }, + "suites": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Suite" + } + }, + "executionType": { + "type": "string", + "enum": [ + "automated", + "manual" + ], + "default": "automated", + "description": "Whether the results came from an automated run or a manual test run. Defaults to \"automated\", so documents written before manual runs existed read back unchanged." + } + } + } + ] + }, + "Artifact": { + "required": [ + "artifactId", + "groupId", + "version" + ], + "properties": { + "artifactId": { + "type": "string" + }, + "groupId": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "Platform": { + "required": [ + "platformName" + ], + "properties": { + "platformName": { + "type": "string" + }, + "platformVersion": { + "type": "string" + }, + "browserName": { + "type": "string" + }, + "browserVersion": { + "type": "string" + }, + "deviceName": { + "type": "string" + }, + "userAgent": { + "type": "string" + }, + "screenHeight": { + "type": "number" + }, + "screenWidth": { + "type": "number" + }, + "pixelRatio": { + "type": "number" + } + } + }, + "Step": { + "required": [ + "name", + "status", + "timestamp" + ], + "properties": { + "name": { + "type": "string" + }, + "expected": { + "type": "object" + }, + "actual": { + "type": "object" + }, + "info": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "INFO", + "DEBUG", + "PASS", + "ERROR", + "FAIL" + ] + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "screenshot": { + "type": "string" + }, + "attachments": { + "type": "array", + "description": "Images a QA attached while recording a manual step result. Automated runs use `screenshot` instead.", + "items": { + "type": "string" + } + } + } + }, + "Screenshot": { + "properties": { + "_id": { + "type": "string", + "uniqueItems": true + }, + "build": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + } + } + }, + "ScreenshotUpdate": { + "properties": { + "platform": { + "$ref": "#/components/schemas/Platform" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Action": { + "required": [ + "name", + "steps" + ], + "properties": { + "name": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Step" + } + } + } + }, + "StoredAction": { + "allOf": [ + { + "$ref": "#/components/schemas/Action" + }, + { + "properties": { + "_id": { + "type": "string", + "uniqueItems": true + }, + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "SKIPPED", + "PASS", + "ERROR", + "FAIL" + ] + } + } + } + ] + }, + "Execution": { + "required": [ + "title", + "suite", + "build" + ], + "properties": { + "title": { + "type": "string" + }, + "suite": { + "type": "string" + }, + "build": { + "type": "string", + "uniqueItems": true + }, + "feature": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Action" + } + }, + "platforms": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Platform" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "meta": { + "type": "object" + }, + "executionType": { + "type": "string", + "enum": [ + "automated", + "manual" + ], + "default": "automated", + "description": "Whether the results came from an automated run or a manual test run. Defaults to \"automated\", so documents written before manual runs existed read back unchanged." + }, + "manualTestCase": { + "type": "string", + "description": "On a manual execution, the test case it came from." + }, + "manualTestCaseVersion": { + "type": "string", + "description": "The frozen version this execution was run against. Carried here as well as on the run entry, because an execution is reachable from the dashboard and the execution history without ever loading its run, and has to render the content it was executed against on its own." + }, + "versionNumber": { + "type": "integer", + "description": "Denormalised version number, for display without a join." + }, + "executedBy": { + "type": "string", + "description": "The user who recorded a manual result." + } + } + }, + "StoredExecution": { + "allOf": [ + { + "$ref": "#/components/schemas/Execution" + }, + { + "properties": { + "_id": { + "type": "string", + "uniqueItems": true + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredAction" + } + }, + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "SKIPPED", + "PASS", + "ERROR", + "FAIL" + ] + }, + "executionType": { + "type": "string", + "enum": [ + "automated", + "manual" + ], + "default": "automated", + "description": "Whether the results came from an automated run or a manual test run. Defaults to \"automated\", so documents written before manual runs existed read back unchanged." + } + } + } + ] + }, + "Suite": { + "required": [ + "name", + "executions" + ], + "properties": { + "name": { + "type": "string" + }, + "executions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredExecution" + } + }, + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "enum": [ + "SKIPPED", + "PASS", + "ERROR", + "FAIL" + ] + }, + "result": {} + } + }, + "IgnoreBox": { + "required": [ + "left", + "top", + "right", + "bottom" + ], + "properties": { + "left": { + "type": "number" + }, + "top": { + "type": "number" + }, + "right": { + "type": "number" + }, + "bottom": { + "type": "number" + } + } + }, + "Baseline": { + "required": [ + "view", + "deviceName" + ], + "properties": { + "view": { + "type": "string" + }, + "platform": { + "$ref": "#/components/schemas/BaselinePlatform" + }, + "ignoreBoxes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IgnoreBox" + } + } + } + }, + "BaselinePlatform": { + "required": [ + "platformName" + ], + "properties": { + "platformName": { + "type": "string" + }, + "browserName": { + "type": "string" + }, + "deviceName": { + "type": "string" + }, + "screenHeight": { + "type": "number" + }, + "screenWidth": { + "type": "number" + }, + "pixelRatio": { + "type": "number" + } + } + }, + "CreateBaseline": { + "allOf": [ + { + "$ref": "#/components/schemas/Baseline" + }, + { + "required": [ + "screenshotId" + ], + "properties": { + "screenshotId": { + "type": "string" + } + } + } + ] + }, + "StoredBaseline": { + "allOf": [ + { + "$ref": "#/components/schemas/Baseline" + }, + { + "properties": { + "_id": { + "type": "string", + "uniqueItems": true + }, + "height": { + "type": "number" + }, + "width": { + "type": "number" + }, + "screenshot": { + "$ref": "#/components/schemas/Screenshot" + } + } + } + ] + }, + "User": { + "required": [ + "username" + ], + "properties": { + "username": { + "type": "string", + "minLength": 2, + "maxLength": 50 + }, + "password": { + "type": "string", + "minLength": 8, + "maxLength": 100 + }, + "role": { + "type": "string", + "enum": [ + "admin", + "user", + "team_lead" + ], + "default": "user" + }, + "authProvider": { + "type": "string", + "enum": [ + "local", + "okta" + ], + "default": "local" + }, + "teams": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "StoredUser": { + "allOf": [ + { + "$ref": "#/components/schemas/User" + }, + { + "properties": { + "_id": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + } + ] + }, + "ApiToken": { + "required": [ + "name", + "expiresInDays" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "expiresInDays": { + "type": "integer", + "minimum": 1, + "maximum": 365 + } + } + }, + "StoredApiToken": { + "properties": { + "_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "NewApiTokenResponse": { + "properties": { + "token": { + "type": "string", + "description": "Plain-text token \u2014 only returned once at creation time" + }, + "name": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "AuthLogin": { + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string", + "minLength": 2, + "maxLength": 50 + }, + "password": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + } + }, + "AuthMe": { + "properties": { + "_id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "admin", + "user", + "team_lead" + ] + }, + "teams": { + "type": "array", + "items": { + "type": "string" + } + }, + "authProvider": { + "type": "string", + "enum": [ + "local", + "okta" + ] + } + } + }, + "ManualStep": { + "required": [ + "action" + ], + "properties": { + "order": { + "type": "integer", + "description": "Position of the step within the test case. Assigned from the array order when omitted." + }, + "action": { + "type": "string", + "maxLength": 2000, + "description": "What the tester should do." + }, + "expected": { + "type": "string", + "maxLength": 2000, + "description": "The expected result of performing the action." + }, + "data": { + "type": "string", + "maxLength": 2000, + "description": "Test data needed for this step (accounts, payloads, fixture references)." + }, + "sharedStep": { + "type": "string", + "description": "ID of a re-usable shared step this step includes. On a test case head this marks a placeholder: its own action and expected are ignored and the shared step is expanded in its place. A frozen version never carries this field - it stores the expanded steps instead, so a later edit to the shared step cannot rewrite history." + }, + "attachments": { + "type": "array", + "description": "IDs of images attached to this step. Reference one from the expected result as `![alt](attachment:)`; the UI resolves that to /attachment//file. Validated on write - an id that does not exist, or belongs to another team, is rejected.", + "items": { + "type": "string" + } + }, + "sharedStepRef": { + "type": "string", + "description": "Display attribution on an expanded step: the shared step it came from. Present on expanded output and on frozen versions, never on a head placeholder." + }, + "sharedStepVersion": { + "type": "integer", + "description": "Which revision of the shared step this step was expanded from." + }, + "unresolvedSharedStep": { + "type": "boolean", + "description": "True when the referenced shared step no longer exists. The placeholder is kept rather than dropped so the case does not silently lose steps." + } + } + }, + "StoredManualStep": { + "allOf": [ + { + "$ref": "#/components/schemas/ManualStep" + }, + { + "properties": { + "_id": { + "type": "string", + "uniqueItems": true, + "description": "Stable step id. Preserved into every frozen version so a recorded step result stays bound to the right step when later versions reorder or remove steps." + } + } + } + ] + }, + "ManualTestCase": { + "required": [ + "team", + "title" + ], + "properties": { + "team": { + "type": "string", + "description": "ID of the team that owns this test case." + }, + "component": { + "type": "string", + "description": "ID of a component belonging to the team." + }, + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": "string", + "maxLength": 5000 + }, + "preconditions": { + "type": "string", + "maxLength": 5000 + }, + "status": { + "type": "string", + "enum": [ + "DRAFT", + "ACTIVE", + "DEPRECATED" + ], + "default": "DRAFT", + "description": "Workflow state. Changing it does not create a new version." + }, + "priority": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH", + "CRITICAL" + ], + "default": "MEDIUM" + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 50 + } + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManualStep" + } + }, + "customFields": { + "type": "object", + "description": "Values for the custom fields configured for the team, keyed by field key. Validated and coerced against those definitions: an unrecognised key is rejected rather than dropped, and required fields are enforced for any status other than DRAFT.", + "additionalProperties": true + }, + "comment": { + "type": "string", + "maxLength": 500, + "description": "Optional reason for the change, recorded on the change history entry rather than stored on the entity." + }, + "folder": { + "type": "string", + "nullable": true, + "description": "Folder this case is filed under, or null for the team root. Filing is organisation, not content: changing it does not create a new version." + } + } + }, + "StoredManualTestCase": { + "allOf": [ + { + "$ref": "#/components/schemas/ManualTestCase" + }, + { + "properties": { + "_id": { + "type": "string", + "uniqueItems": true + }, + "version": { + "type": "integer", + "description": "Current content version. Incremented only when content changes; always resolves to a document in the version history." + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredManualStep" + } + }, + "createdBy": { + "type": "object" + }, + "updatedBy": { + "type": "object" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + } + ] + }, + "ManualTestCasesResponse": { + "properties": { + "testCases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredManualTestCase" + } + }, + "metrics": { + "type": "object", + "properties": { + "totalTestCases": { + "type": "integer", + "description": "Total matching the query, ignoring limit and skip." + } + } + } + } + }, + "ManualTestCaseVersion": { + "description": "An immutable copy of one version of a manual test case. These documents are only ever inserted - never updated or deleted - so an execution bound to a version always renders the content it was run against.", + "properties": { + "_id": { + "type": "string" + }, + "testCase": { + "type": "string", + "description": "ID of the test case this is a version of." + }, + "version": { + "type": "integer" + }, + "team": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "preconditions": { + "type": "string" + }, + "priority": { + "type": "string", + "enum": [ + "LOW", + "MEDIUM", + "HIGH", + "CRITICAL" + ] + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredManualStep" + } + }, + "customFields": { + "type": "object", + "additionalProperties": true + }, + "fieldDefinitions": { + "type": "array", + "description": "The custom field definitions in force when this version was written, so an archived or relabelled field still renders with its original label and type. Empty until custom fields are configurable.", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "type": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "createdBy": { + "type": "object" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "unversioned": { + "type": "boolean", + "description": "Present and true only when the case predates version tracking and the mutable head was returned instead of a frozen version. Such a response is not guaranteed immutable." + } + } }, - "Phase": { + "ManualTestCaseVersionsResponse": { + "properties": { + "versions": { + "type": "array", + "description": "Version metadata, newest first. Fetch a single version for its full frozen content.", + "items": { + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "version": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "createdBy": { + "type": "object" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "CustomFieldDefinition": { "required": [ - "name" + "team", + "key", + "label", + "type" ], "properties": { - "name": { + "team": { "type": "string", - "uniqueItems": true + "description": "ID of the team the field is configured for." }, - "orderNumber": { - "type": "number" + "key": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,39}$", + "description": "Stable storage key. A test case's customFields object is keyed by it, so it cannot be changed once created - create a new field instead." + }, + "label": { + "type": "string", + "maxLength": 100, + "description": "Display name. Can be changed freely; every frozen version keeps the label that was in force when it was written." + }, + "type": { + "type": "string", + "enum": [ + "text", + "textarea", + "number", + "date", + "boolean", + "select", + "multiselect", + "user" + ], + "description": "Value type. Cannot be changed once any test case holds a value for the field." + }, + "options": { + "type": "array", + "description": "Allowed values. Required for select and multiselect, rejected for every other type. An option still held by a test case cannot be removed.", + "items": { + "type": "string", + "maxLength": 100 + } + }, + "required": { + "type": "boolean", + "default": false, + "description": "Enforced only when a test case is saved with a status other than DRAFT, so an incomplete draft can always be saved." + }, + "defaultValue": { + "description": "Applied when a test case supplies no value. Never applied to an archived field." + }, + "order": { + "type": "number", + "description": "Display order within the team's field list." + }, + "appliesTo": { + "type": "string", + "enum": [ + "testcase", + "execution", + "both" + ], + "default": "testcase" + }, + "archived": { + "type": "boolean", + "default": false, + "description": "Archived fields are hidden from authoring and exempt from the required check, but existing values still validate and render." } } }, - "StoredPhase": { + "StoredCustomFieldDefinition": { "allOf": [ { - "$ref": "#/components/schemas/Phase" + "$ref": "#/components/schemas/CustomFieldDefinition" }, { "properties": { "_id": { "type": "string", "uniqueItems": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" } } } ] }, - "Build": { + "CustomFieldsResponse": { + "properties": { + "customFields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredCustomFieldDefinition" + } + } + } + }, + "CustomFieldDeleteResponse": { + "properties": { + "message": { + "type": "string" + }, + "archived": { + "type": "boolean", + "description": "True when the field was in use and has been archived instead of removed. False when it was unused and hard-deleted." + }, + "customField": { + "$ref": "#/components/schemas/StoredCustomFieldDefinition" + } + } + }, + "AuthProvider": { + "type": "object", "required": [ + "id", "name", - "environment", - "component", - "team", - "start" + "type" ], "properties": { - "name": { - "type": "string" - }, - "environment": { - "type": "string" + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$", + "description": "Stable, URL-safe handle. Forms part of the login and callback URLs, so changing it changes what must be registered with the identity provider." }, - "component": { - "type": "string" + "name": { + "type": "string", + "description": "Label shown on the login button." }, - "team": { - "type": "string" + "type": { + "type": "string", + "enum": [ + "oidc", + "saml", + "ldap" + ] }, - "phase": { - "type": "string" + "enabled": { + "type": "boolean" }, - "artifacts": { + "roleMappings": { "type": "array", "items": { - "$ref": "#/components/schemas/Artifact" + "type": "object", + "required": [ + "value", + "role" + ], + "properties": { + "value": { + "type": "string", + "description": "Group name, group id, or attribute value as supplied by the provider. Matched case-insensitively." + }, + "role": { + "type": "string", + "enum": [ + "admin", + "team_lead", + "user" + ] + } + } } }, - "start": { + "defaultRole": { "type": "string", - "format": "date-time" + "enum": [ + "", + "admin", + "team_lead", + "user" + ], + "description": "Role granted when no mapping matches. Empty denies access." }, - "executions": { - "type": "array", - "description": "Optional test executions to store with the build, so the whole build can be created in a single call. Executions are grouped into suites by their 'suite' name and the build metrics are calculated from them. Omit this to add executions individually afterwards via POST /execution.", - "items": { - "$ref": "#/components/schemas/BuildExecution" + "oidc": { + "type": "object", + "properties": { + "issuer": { + "type": "string", + "description": "Issuer URL; endpoints and the JWKS come from its .well-known/openid-configuration." + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string", + "description": "Write-only. Never returned; send a non-empty value to change it, or omit/blank to keep the stored one." + }, + "scopes": { + "type": "string", + "description": "Space-separated. openid is added automatically. Note Google rejects the groups scope, while Entra emits groups without one." + }, + "groupsClaim": { + "type": "string", + "description": "Claim holding group membership - groups for Okta, often roles for Keycloak, a namespaced URI for Auth0." + }, + "usernameClaim": { + "type": "string", + "description": "Claim used as the username. Falls back to email, then preferred_username, then sub." + } + } + }, + "saml": { + "type": "object", + "properties": { + "entryPoint": { + "type": "string", + "description": "IdP SSO URL." + }, + "idpCert": { + "type": "string", + "description": "IdP signing certificate. Accepted with or without PEM armour. Required: assertions are only trustworthy because they verify against it." + }, + "issuer": { + "type": "string", + "description": "This application's SP entity id, as registered with the IdP." + }, + "privateKey": { + "type": "string", + "description": "Write-only. Only needed when the IdP requires signed AuthnRequests." + }, + "signatureAlgorithm": { + "type": "string", + "enum": [ + "sha256", + "sha512" + ] + }, + "identifierFormat": { + "type": "string" + }, + "groupsAttribute": { + "type": "string" + }, + "usernameAttribute": { + "type": "string", + "description": "Attribute used as the username. Falls back to conventional email/name attributes, then the NameID." + }, + "wantAuthnResponseSigned": { + "type": "boolean", + "description": "Require a signature over the whole Response, not just the assertion. Defaults to true; relax it for IdPs that sign only the assertion. The assertion signature is always required." + }, + "allowUnsolicited": { + "type": "boolean", + "description": "Permit IdP-initiated login. Defaults to false: an unsolicited assertion has no AuthnRequest to correlate against, losing that replay protection." + } + } + }, + "ldap": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Directory URL. Must be ldaps://, or ldap:// with startTLS enabled - this flow carries the user's password." + }, + "bindDN": { + "type": "string", + "description": "Service account for the user search. Leave empty for an anonymous search." + }, + "bindCredentials": { + "type": "string", + "description": "Write-only." + }, + "searchBase": { + "type": "string" + }, + "searchFilter": { + "type": "string", + "description": "{{username}} is substituted with the submitted username." + }, + "groupSearchBase": { + "type": "string", + "description": "Leave empty to read membership from the user entry's memberOf attribute instead." + }, + "groupSearchFilter": { + "type": "string", + "description": "{{dn}} is substituted with the user's DN." + }, + "groupNameAttribute": { + "type": "string" + }, + "usernameAttribute": { + "type": "string" + }, + "startTLS": { + "type": "boolean" + }, + "tlsRejectUnauthorized": { + "type": "boolean", + "description": "Defaults to true. Disabling it makes the connection interceptable, which here means intercepting passwords." + } } } } }, - "BuildExecution": { - "description": "An execution supplied as part of a POST /build request. Identical to Execution but without 'build', which is the build being created.", + "AuthProviderPublic": { + "allOf": [ + { + "$ref": "#/components/schemas/AuthProvider" + }, + { + "type": "object", + "properties": { + "clientSecretSet": { + "type": "boolean" + }, + "privateKeySet": { + "type": "boolean" + }, + "bindCredentialsSet": { + "type": "boolean" + }, + "loginUrl": { + "type": "string" + }, + "callbackUrl": { + "type": "string", + "description": "The redirect/ACS URL to register with the identity provider." + } + } + } + ] + }, + "SharedStep": { "required": [ - "title", - "suite" + "team", + "name", + "steps" ], - "properties": { - "title": { - "type": "string" - }, - "suite": { - "type": "string" - }, - "feature": { - "type": "string" + "properties": { + "team": { + "type": "string", + "description": "ID of the team that owns the shared step." }, - "actions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Action" - } + "name": { + "type": "string", + "maxLength": 150, + "description": "Unique within the team." }, - "platforms": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Platform" - } + "description": { + "type": "string", + "maxLength": 5000 }, - "tags": { + "steps": { "type": "array", + "description": "The steps to expand in place of an inclusion. Always literal - a shared step cannot include another shared step.", "items": { - "type": "string" + "$ref": "#/components/schemas/ManualStep" } }, - "meta": { - "type": "object" - }, - "start": { - "type": "string", - "format": "date-time" - }, - "end": { + "comment": { "type": "string", - "format": "date-time" + "maxLength": 500, + "description": "Optional reason for the change, recorded on the change history entry rather than stored on the entity." } } }, - "StoredBuild": { + "StoredSharedStep": { "allOf": [ { - "$ref": "#/components/schemas/Build" + "$ref": "#/components/schemas/SharedStep" }, { "properties": { @@ -4551,462 +8756,485 @@ "type": "string", "uniqueItems": true }, - "suites": { + "version": { + "type": "integer", + "description": "Incremented only when the steps change. Recorded on every expanded step so a frozen version shows which revision the tester saw." + }, + "steps": { "type": "array", "items": { - "$ref": "#/components/schemas/Suite" + "$ref": "#/components/schemas/StoredManualStep" } + }, + "createdBy": { + "type": "object" + }, + "updatedBy": { + "type": "object" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" } } } ] }, - "Artifact": { - "required": [ - "artifactId", - "groupId", - "version" - ], + "SharedStepsResponse": { "properties": { - "artifactId": { - "type": "string" + "sharedSteps": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredSharedStep" + } }, - "groupId": { - "type": "string" + "metrics": { + "type": "object", + "properties": { + "totalSharedSteps": { + "type": "integer" + } + } + } + } + }, + "SharedStepUsageResponse": { + "properties": { + "testCases": { + "type": "array", + "description": "The test cases whose head includes this shared step - the ones an edit would version.", + "items": { + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "string" + }, + "version": { + "type": "integer" + } + } + } }, - "version": { - "type": "string" + "metrics": { + "type": "object", + "properties": { + "totalTestCases": { + "type": "integer" + } + } } } }, - "Platform": { - "required": [ - "platformName" - ], + "SharedStepUpdateResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/StoredSharedStep" + }, + { + "properties": { + "cascade": { + "type": "object", + "description": "What the edit did to the referencing test cases.", + "properties": { + "testCasesVersioned": { + "type": "integer", + "description": "How many test cases were given a new frozen version. Zero when only the name or description changed." + }, + "failures": { + "type": "array", + "description": "Test cases that could not be versioned. The shared step edit itself still succeeded.", + "items": { + "type": "object", + "properties": { + "testCase": { + "type": "string" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + } + ] + }, + "StoredAttachment": { "properties": { - "platformName": { - "type": "string" + "_id": { + "type": "string", + "uniqueItems": true }, - "platformVersion": { - "type": "string" + "team": { + "type": "string", + "description": "Taken from the entity the attachment hangs off, never from the request." }, - "browserName": { - "type": "string" + "scope": { + "type": "string", + "enum": [ + "testcase", + "sharedstep", + "execution" + ] }, - "browserVersion": { + "testCase": { "type": "string" }, - "deviceName": { + "sharedStep": { "type": "string" }, - "userAgent": { + "manualExecution": { "type": "string" }, - "screenHeight": { - "type": "number" + "filename": { + "type": "string", + "description": "Server-generated. The client filename is never used to build a path." }, - "screenWidth": { - "type": "number" + "originalName": { + "type": "string", + "description": "The uploaded filename, kept for display only and stripped of any directory component." }, - "pixelRatio": { - "type": "number" - } - } - }, - "Step": { - "required": [ - "name", - "status", - "timestamp" - ], - "properties": { - "name": { + "mimeType": { "type": "string" }, - "expected": { - "type": "object" - }, - "actual": { - "type": "object" + "size": { + "type": "number", + "description": "Bytes. Uploads are capped at 10MB." }, - "info": { + "path": { "type": "string" }, - "status": { - "type": "string", - "enum": [ - "INFO", - "DEBUG", - "PASS", - "ERROR", - "FAIL" - ] - }, - "timestamp": { + "thumbnail": { "type": "string", - "format": "date-time" + "description": "Path to the generated thumbnail. Absent when the image could not be thumbnailed; the original is still served." }, - "screenshot": { - "type": "string" - } - } - }, - "Screenshot": { - "properties": { - "_id": { - "type": "string", - "uniqueItems": true + "width": { + "type": "number" }, - "build": { - "type": "string" + "height": { + "type": "number" }, - "thumbnail": { - "type": "string" + "uploadedBy": { + "type": "object" }, - "timestamp": { + "createdAt": { "type": "string", "format": "date-time" }, - "path": { - "type": "string" + "updatedAt": { + "type": "string", + "format": "date-time" } } }, - "ScreenshotUpdate": { + "AttachmentsResponse": { "properties": { - "platform": { - "$ref": "#/components/schemas/Platform" - }, - "tags": { + "attachments": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/StoredAttachment" } } } }, - "Action": { - "required": [ - "name", - "steps" - ], + "ManualChangeHistoryEntry": { + "description": "One audited mutation. Distinct from a test case version: a version is the content a tester saw, while a history entry says who changed what and why. A status change and a shared-step cascade both write history without the version collection being able to express them.", "properties": { - "name": { + "_id": { "type": "string" }, - "steps": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Step" - } - } - } - }, - "StoredAction": { - "allOf": [ - { - "$ref": "#/components/schemas/Action" - }, - { - "properties": { - "_id": { - "type": "string", - "uniqueItems": true - }, - "start": { - "type": "string", - "format": "date-time" - }, - "end": { - "type": "string", - "format": "date-time" - }, - "status": { - "type": "string", - "enum": [ - "SKIPPED", - "PASS", - "ERROR", - "FAIL" - ] - } - } - } - ] - }, - "Execution": { - "required": [ - "title", - "suite", - "build" - ], - "properties": { - "title": { + "entityType": { + "type": "string", + "enum": [ + "testcase", + "sharedstep", + "customfield" + ] + }, + "entityId": { "type": "string" }, - "suite": { + "team": { "type": "string" }, - "build": { + "action": { "type": "string", - "uniqueItems": true + "enum": [ + "CREATE", + "UPDATE", + "DELETE", + "CLONE", + "STATUS_CHANGE", + "SHARED_STEP_UPDATE", + "ARCHIVE" + ], + "description": "STATUS_CHANGE is a transition that deliberately burns no version. SHARED_STEP_UPDATE marks a test case re-versioned because a shared step it includes was edited - nobody edited the case itself. ARCHIVE is a custom field that was in use and so could not be deleted." }, - "feature": { - "type": "string" + "version": { + "type": "integer", + "description": "The version the entity ended up on. Absent for custom fields, which are not versioned, and unchanged from the previous entry for an action that burns no version." }, - "actions": { + "changes": { "type": "array", + "description": "Field-level changes. A steps change is broken down per position (`steps[2]`) and a custom field per key (`customFields.owner`), rather than reported as one opaque blob.", "items": { - "$ref": "#/components/schemas/Action" + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "from": { + "description": "Previous value; type depends on the field." + }, + "to": { + "description": "New value; type depends on the field." + } + } } }, - "platforms": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Platform" + "changedBy": { + "type": "object", + "description": "Populated to _id and username only - the user document carries API tokens and must never leave the server through an audit endpoint.", + "properties": { + "_id": { + "type": "string" + }, + "username": { + "type": "string" + } } }, - "tags": { - "type": "array", - "items": { - "type": "string" - } + "changedAt": { + "type": "string", + "format": "date-time" }, - "meta": { - "type": "object" + "comment": { + "type": "string", + "description": "The author's reason, supplied as `comment` on the mutation, or a generated explanation for a cascade." + }, + "causedBy": { + "type": "object", + "description": "On a SHARED_STEP_UPDATE entry, the shared step whose edit caused this case to be re-versioned." } } }, - "StoredExecution": { - "allOf": [ - { - "$ref": "#/components/schemas/Execution" + "ManualChangeHistoryResponse": { + "properties": { + "history": { + "type": "array", + "description": "Entries newest first.", + "items": { + "$ref": "#/components/schemas/ManualChangeHistoryEntry" + } }, - { + "metrics": { + "type": "object", "properties": { - "_id": { - "type": "string", - "uniqueItems": true - }, - "actions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StoredAction" - } - }, - "start": { - "type": "string", - "format": "date-time" - }, - "end": { - "type": "string", - "format": "date-time" - }, - "status": { - "type": "string", - "enum": [ - "SKIPPED", - "PASS", - "ERROR", - "FAIL" - ] + "totalEntries": { + "type": "integer", + "description": "Total for the entity, ignoring limit and skip." } } } - ] + } }, - "Suite": { - "required": [ - "name", - "executions" - ], + "ManualStepResult": { "properties": { - "name": { + "stepId": { + "type": "string", + "description": "The _id of the step in the frozen version. Binding by id rather than position means a reordered or deleted step in a later version cannot pull this result onto the wrong step." + }, + "status": { + "type": "string", + "enum": [ + "NOT_RUN", + "PASS", + "FAIL", + "BLOCKED", + "SKIPPED" + ] + }, + "actual": { + "type": "string", + "description": "What actually happened." + }, + "notes": { "type": "string" }, - "executions": { + "attachments": { "type": "array", + "description": "Attachment ids captured against this step.", "items": { - "$ref": "#/components/schemas/StoredExecution" + "type": "string" } }, - "start": { + "timestamp": { "type": "string", "format": "date-time" + } + } + }, + "ManualRunTestCase": { + "properties": { + "testCase": { + "type": "string", + "description": "The mutable test case head." }, - "end": { + "testCaseVersion": { "type": "string", - "format": "date-time" + "description": "The frozen version being executed. Bound when the case is added to the run and never moved implicitly - editing the case mid-run must not shift the steps under the tester." + }, + "versionNumber": { + "type": "integer" + }, + "snapshotTitle": { + "type": "string" }, "status": { "type": "string", "enum": [ - "SKIPPED", + "NOT_RUN", + "IN_PROGRESS", "PASS", + "FAIL", "ERROR", - "FAIL" - ] + "SKIPPED", + "BLOCKED" + ], + "description": "Richer than the execution status enum. BLOCKED and IN_PROGRESS exist only here; BLOCKED is written to the TestExecution as SKIPPED, since a blocked test was never executed and must not inflate the failure count." }, - "result": {} - } - }, - "IgnoreBox": { - "required": [ - "left", - "top", - "right", - "bottom" - ], - "properties": { - "left": { - "type": "number" + "stepResults": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManualStepResult" + } }, - "top": { - "type": "number" + "execution": { + "type": "string", + "description": "The TestExecution written for this case." }, - "right": { - "type": "number" + "executedBy": { + "type": "object" }, - "bottom": { - "type": "number" - } - } - }, - "Baseline": { - "required": [ - "view", - "deviceName" - ], - "properties": { - "view": { + "notes": { "type": "string" }, - "platform": { - "$ref": "#/components/schemas/BaselinePlatform" + "start": { + "type": "string", + "format": "date-time" }, - "ignoreBoxes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/IgnoreBox" - } + "end": { + "type": "string", + "format": "date-time" } } }, - "BaselinePlatform": { + "ManualTestRun": { "required": [ - "platformName" + "name", + "team", + "environment" ], "properties": { - "platformName": { - "type": "string" + "name": { + "type": "string", + "maxLength": 200 }, - "browserName": { - "type": "string" + "description": { + "type": "string", + "maxLength": 5000 }, - "deviceName": { + "team": { "type": "string" }, - "screenHeight": { - "type": "number" - }, - "screenWidth": { - "type": "number" - }, - "pixelRatio": { - "type": "number" - } - } - }, - "CreateBaseline": { - "allOf": [ - { - "$ref": "#/components/schemas/Baseline" - }, - { - "required": [ - "screenshotId" - ], - "properties": { - "screenshotId": { - "type": "string" - } - } - } - ] - }, - "StoredBaseline": { - "allOf": [ - { - "$ref": "#/components/schemas/Baseline" - }, - { - "properties": { - "_id": { - "type": "string", - "uniqueItems": true - }, - "height": { - "type": "number" - }, - "width": { - "type": "number" - }, - "screenshot": { - "$ref": "#/components/schemas/Screenshot" - } - } - } - ] - }, - "User": { - "required": [ - "username" - ], - "properties": { - "username": { + "component": { "type": "string", - "minLength": 2, - "maxLength": 50 + "description": "One component per run, matching Build.component. Defaults to the team's first component when omitted. A test case belonging to a different component is rejected." }, - "password": { + "environment": { "type": "string", - "minLength": 8, - "maxLength": 100 + "description": "Environment name (not id), matching how POST /build takes it." }, - "role": { + "phase": { "type": "string", - "enum": [ - "admin", - "user", - "team_lead" - ], - "default": "user" + "description": "Phase name (not id)." + }, + "assignedTo": { + "type": "string" }, - "authProvider": { - "type": "string", - "enum": [ - "local", - "okta" - ], - "default": "local" + "platforms": { + "type": "array", + "description": "Reuses the automated Platform shape verbatim, so manual runs capture the same platform and device metrics and the platform aggregations need no new fields.", + "items": { + "$ref": "#/components/schemas/Platform" + } }, - "teams": { + "testCaseIds": { "type": "array", + "description": "Test cases to schedule. Each is bound to its current frozen version at the moment it is added. A DEPRECATED case is rejected.", "items": { "type": "string" } } } }, - "StoredUser": { + "StoredManualTestRun": { "allOf": [ { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/ManualTestRun" }, { "properties": { "_id": { - "type": "string" + "type": "string", + "uniqueItems": true + }, + "build": { + "type": "string", + "description": "The Build tagged executionType \"manual\" that this run's executions are written into, so the run appears on the existing dashboards." + }, + "status": { + "type": "string", + "enum": [ + "PLANNED", + "IN_PROGRESS", + "COMPLETED", + "CANCELLED" + ], + "description": "Derived from the cases as results are recorded: COMPLETED once every case has a terminal result. CANCELLED is only ever set explicitly." + }, + "testCases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ManualRunTestCase" + } + }, + "expanded": { + "type": "boolean", + "description": "Present when the run was read with expand=true; each case then carries its frozen `version` document." + }, + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + }, + "createdBy": { + "type": "object" }, "createdAt": { "type": "string", @@ -5020,319 +9248,163 @@ } ] }, - "ApiToken": { - "required": [ - "name", - "expiresInDays" - ], + "ManualTestRunsResponse": { "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 + "testRuns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredManualTestRun" + } }, - "expiresInDays": { - "type": "integer", - "minimum": 1, - "maximum": 365 + "metrics": { + "type": "object", + "properties": { + "totalTestRuns": { + "type": "integer" + } + } } } }, - "StoredApiToken": { + "ExecutionTypeBreakdown": { + "type": "object", + "description": "Executions in the period split by execution type. The two always sum to result.TOTAL, so a stacked chart built from it cannot under-report. Executions with no stored executionType count as automated.", "properties": { - "_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "expiresAt": { - "type": "string", - "format": "date-time" + "automated": { + "type": "integer" }, - "createdAt": { - "type": "string", - "format": "date-time" + "manual": { + "type": "integer" } } }, - "NewApiTokenResponse": { + "ManualFolder": { + "type": "object", + "required": [ + "team", + "name" + ], "properties": { - "token": { + "team": { "type": "string", - "description": "Plain-text token \u2014 only returned once at creation time" + "description": "Team the folder belongs to." }, "name": { - "type": "string" - }, - "expiresAt": { "type": "string", - "format": "date-time" - } - } - }, - "AuthLogin": { - "required": [ - "username", - "password" - ], - "properties": { - "username": { + "maxLength": 100 + }, + "description": { "type": "string", - "minLength": 2, - "maxLength": 50 + "maxLength": 500 }, - "password": { + "parent": { "type": "string", - "minLength": 1, - "maxLength": 100 + "nullable": true, + "description": "Parent folder id. Null or absent creates a root folder." } } }, - "AuthMe": { + "StoredManualFolder": { + "type": "object", "properties": { "_id": { "type": "string" }, - "username": { + "team": { "type": "string" }, - "role": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parent": { "type": "string", - "enum": [ - "admin", - "user", - "team_lead" - ] + "nullable": true }, - "teams": { + "path": { "type": "array", "items": { "type": "string" - } + }, + "description": "Ancestor folder ids, outermost first. Empty for a root folder." }, - "authProvider": { + "depth": { + "type": "integer", + "description": "0 for a root folder." + }, + "testCaseCount": { + "type": "integer", + "description": "Test cases filed directly in this folder." + }, + "children": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredManualFolder" + }, + "description": "Nested sub-folders, returned by GET /manual-folder." + }, + "createdBy": { + "type": "string" + }, + "updatedBy": { + "type": "string" + }, + "createdAt": { "type": "string", - "enum": [ - "local", - "okta" - ] + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" } } - } - } - }, - "definitions": { - "AuthProvider": { - "type": "object", - "required": [ - "id", - "name", - "type" - ], - "properties": { - "id": { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]{0,30}[a-z0-9]$", - "description": "Stable, URL-safe handle. Forms part of the login and callback URLs, so changing it changes what must be registered with the identity provider." - }, - "name": { - "type": "string", - "description": "Label shown on the login button." - }, - "type": { - "type": "string", - "enum": [ - "oidc", - "saml", - "ldap" - ] - }, - "enabled": { - "type": "boolean" - }, - "roleMappings": { - "type": "array", - "items": { + }, + "ManualFoldersResponse": { + "type": "object", + "properties": { + "folders": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredManualFolder" + }, + "description": "Root folders, each with nested children." + }, + "unfiledCount": { + "type": "integer", + "description": "Test cases not filed in any folder." + }, + "metrics": { "type": "object", - "required": [ - "value", - "role" - ], "properties": { - "value": { - "type": "string", - "description": "Group name, group id, or attribute value as supplied by the provider. Matched case-insensitively." - }, - "role": { - "type": "string", - "enum": [ - "admin", - "team_lead", - "user" - ] + "totalFolders": { + "type": "integer" } } } - }, - "defaultRole": { - "type": "string", - "enum": [ - "", - "admin", - "team_lead", - "user" - ], - "description": "Role granted when no mapping matches. Empty denies access." - }, - "oidc": { - "type": "object", - "properties": { - "issuer": { - "type": "string", - "description": "Issuer URL; endpoints and the JWKS come from its .well-known/openid-configuration." - }, - "clientId": { - "type": "string" - }, - "clientSecret": { - "type": "string", - "description": "Write-only. Never returned; send a non-empty value to change it, or omit/blank to keep the stored one." - }, - "scopes": { - "type": "string", - "description": "Space-separated. openid is added automatically. Note Google rejects the groups scope, while Entra emits groups without one." - }, - "groupsClaim": { - "type": "string", - "description": "Claim holding group membership - groups for Okta, often roles for Keycloak, a namespaced URI for Auth0." - }, - "usernameClaim": { - "type": "string", - "description": "Claim used as the username. Falls back to email, then preferred_username, then sub." - } - } - }, - "saml": { - "type": "object", - "properties": { - "entryPoint": { - "type": "string", - "description": "IdP SSO URL." - }, - "idpCert": { - "type": "string", - "description": "IdP signing certificate. Accepted with or without PEM armour. Required: assertions are only trustworthy because they verify against it." - }, - "issuer": { - "type": "string", - "description": "This application's SP entity id, as registered with the IdP." - }, - "privateKey": { - "type": "string", - "description": "Write-only. Only needed when the IdP requires signed AuthnRequests." - }, - "signatureAlgorithm": { - "type": "string", - "enum": [ - "sha256", - "sha512" - ] - }, - "identifierFormat": { - "type": "string" - }, - "groupsAttribute": { - "type": "string" - }, - "usernameAttribute": { - "type": "string", - "description": "Attribute used as the username. Falls back to conventional email/name attributes, then the NameID." - }, - "wantAuthnResponseSigned": { - "type": "boolean", - "description": "Require a signature over the whole Response, not just the assertion. Defaults to true; relax it for IdPs that sign only the assertion. The assertion signature is always required." - }, - "allowUnsolicited": { - "type": "boolean", - "description": "Permit IdP-initiated login. Defaults to false: an unsolicited assertion has no AuthnRequest to correlate against, losing that replay protection." - } - } - }, - "ldap": { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "Directory URL. Must be ldaps://, or ldap:// with startTLS enabled - this flow carries the user's password." - }, - "bindDN": { - "type": "string", - "description": "Service account for the user search. Leave empty for an anonymous search." - }, - "bindCredentials": { - "type": "string", - "description": "Write-only." - }, - "searchBase": { - "type": "string" - }, - "searchFilter": { - "type": "string", - "description": "{{username}} is substituted with the submitted username." - }, - "groupSearchBase": { - "type": "string", - "description": "Leave empty to read membership from the user entry's memberOf attribute instead." - }, - "groupSearchFilter": { - "type": "string", - "description": "{{dn}} is substituted with the user's DN." - }, - "groupNameAttribute": { - "type": "string" - }, - "usernameAttribute": { - "type": "string" - }, - "startTLS": { - "type": "boolean" - }, - "tlsRejectUnauthorized": { - "type": "boolean", - "description": "Defaults to true. Disabling it makes the connection interceptable, which here means intercepting passwords." - } - } } - } - }, - "AuthProviderPublic": { - "allOf": [ - { - "$ref": "#/definitions/AuthProvider" - }, - { - "type": "object", - "properties": { - "clientSecretSet": { - "type": "boolean" - }, - "privateKeySet": { - "type": "boolean" - }, - "bindCredentialsSet": { - "type": "boolean" - }, - "loginUrl": { + }, + "MoveManualTestCases": { + "type": "object", + "required": [ + "testCaseIds" + ], + "properties": { + "testCaseIds": { + "type": "array", + "items": { "type": "string" }, - "callbackUrl": { - "type": "string", - "description": "The redirect/ACS URL to register with the identity provider." - } + "minItems": 1 + }, + "folder": { + "type": "string", + "nullable": true, + "description": "Target folder id, or null to move the cases to the team root." } } - ] + } } } } diff --git a/test/attachment.tests.js b/test/attachment.tests.js new file mode 100644 index 0000000..8157ac4 --- /dev/null +++ b/test/attachment.tests.js @@ -0,0 +1,405 @@ +/** + * Tests for manual test step attachments. + * + * The load-bearing behaviour is the immutability rule: once a frozen version references an + * attachment, the file is the only copy of what that version's tester saw, so it cannot be + * deleted while the version stands. Deleting the whole test case removes the versions + * first, which releases the attachments. + */ +const fs = require('fs'); +const request = require('supertest'); +const should = require('should'); +const pino = require('pino'); +const bcrypt = require('bcryptjs'); +const app = require('../server.js'); +const testUtils = require('./test-utils.js'); +const User = require('../app/models/user.js'); +const Attachment = require('../app/models/attachment.js'); +const ManualTestCase = require('../app/models/manual-test-case.js'); +const ManualTestCaseVersion = require('../app/models/manual-test-case-version.js'); +const SharedStep = require('../app/models/shared-step.js'); +const { Team } = require('../app/models/team.js'); +const attachmentUtils = require('../app/utils/attachment-utils.js'); + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); +const baseUrl = '/rest/api/v1.0/'; +const IMAGE = './test/resources/angles_home_page.jpg'; + +const MEMBER_PASSWORD = 'unit-testing-AtMember1!'; +const LEAD_PASSWORD = 'unit-testing-AtLead1!'; +const OUTSIDER_PASSWORD = 'unit-testing-AtOutsider1!'; + +describe('Attachment API Tests', () => { + let memberAgent; + let leadAgent; + let outsiderAgent; + let team; + let otherTeam; + let testCase; + + const login = (username, password) => new Promise((resolve, reject) => { + const agent = request.agent(app); + agent + .post(`${baseUrl}auth/login`) + .send({ username, password }) + .end((err, res) => { + if (err) return reject(err); + if (res.status !== 200) return reject(new Error(`login failed for ${username}: ${res.status}`)); + return resolve(agent); + }); + }); + + const send = (agent, method, url, body) => new Promise((resolve, reject) => { + const req = agent[method](`${baseUrl}${url}`).set('Accept', 'application/json'); + if (body !== undefined) req.send(body); + req.end((err, res) => (err ? reject(err) : resolve(res))); + }); + + const upload = (agent, fields, file = IMAGE) => new Promise((resolve, reject) => { + const req = agent.post(`${baseUrl}attachment`); + Object.entries(fields).forEach(([key, value]) => req.field(key, value)); + if (file) req.attach('attachment', file); + req.end((err, res) => (err ? reject(err) : resolve(res))); + }); + + before(async () => { + await Promise.all([ + User.deleteMany({ username: /^unit-testing-at/ }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-at/ }).exec(), + Team.deleteMany({ name: /^unit-testing-at/ }).exec(), + ]); + logger.info('Cleared any lingering attachment fixtures'); + + team = await new Team({ name: 'unit-testing-at-team', components: [{ name: 'at-component' }] }).save(); + otherTeam = await new Team({ name: 'unit-testing-at-other', components: [{ name: 'at-other' }] }).save(); + + const [memberHash, leadHash, outsiderHash] = await Promise.all([ + bcrypt.hash(MEMBER_PASSWORD, 10), + bcrypt.hash(LEAD_PASSWORD, 10), + bcrypt.hash(OUTSIDER_PASSWORD, 10), + ]); + await User.create([ + { + username: 'unit-testing-at-member', password: memberHash, role: 'user', teams: [team._id], + }, + { + username: 'unit-testing-at-lead', password: leadHash, role: 'team_lead', teams: [team._id], + }, + { + username: 'unit-testing-at-outsider', password: outsiderHash, role: 'user', teams: [otherTeam._id], + }, + ]); + + [memberAgent, leadAgent, outsiderAgent] = await Promise.all([ + login('unit-testing-at-member', MEMBER_PASSWORD), + login('unit-testing-at-lead', LEAD_PASSWORD), + login('unit-testing-at-outsider', OUTSIDER_PASSWORD), + ]); + await testUtils.getAdminAgent(); + + const created = await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-at host case', + steps: [{ action: 'Do the thing', expected: 'It happened' }], + }); + testCase = created.body; + }); + + after(async () => { + const cases = await ManualTestCase.find({ title: /^unit-testing-at/ }).select('_id').lean(); + const caseIds = cases.map((c) => c._id); + // Remove the files as well as the documents - deleting the documents directly bypasses + // the controller's cleanup and would leave the uploads behind on disk. + const attachments = await Attachment + .find({ team: { $in: [team._id, otherTeam._id] } }) + .lean(); + await Promise.all(attachments.map((attachment) => attachmentUtils.removeFiles(attachment))); + const owners = new Set(attachments + .map((attachment) => (attachment.testCase || attachment.sharedStep || '').toString()) + .filter(Boolean)); + await Promise.all([...owners] + .map((ownerId) => attachmentUtils.removeAttachmentDirectory(ownerId))); + await Promise.all([ + ManualTestCaseVersion.deleteMany({ testCase: { $in: caseIds } }).exec(), + Attachment.deleteMany({ team: { $in: [team._id, otherTeam._id] } }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-at/ }).exec(), + SharedStep.deleteMany({ team: { $in: [team._id, otherTeam._id] } }).exec(), + User.deleteMany({ username: /^unit-testing-at/ }).exec(), + Team.deleteMany({ name: /^unit-testing-at/ }).exec(), + ]); + }); + + describe('POST /attachment', () => { + let uploaded; + + it('respond with 201 when uploading an image to a test case', async () => { + const res = await upload(memberAgent, { testCaseId: testCase._id }); + should(res.status).equal(201); + uploaded = res.body; + should(res.body.scope).equal('testcase'); + should(res.body.testCase).equal(testCase._id); + should(res.body.team).equal(team._id.toString()); + should(res.body.mimeType).equal('image/jpeg'); + should(res.body.size).be.above(0); + }); + + it('writes the file and a thumbnail to disk', () => { + should(fs.existsSync(uploaded.path)).equal(true); + should(uploaded.thumbnail).not.equal(undefined); + should(fs.existsSync(uploaded.thumbnail)).equal(true); + should(uploaded.width).be.above(0); + should(uploaded.height).be.above(0); + }); + + it('stores the file under the owning entity, with a generated name', () => { + should(uploaded.path).match(new RegExp(`attachments/${testCase._id}/`)); + // The client filename is never used to build the path. + should(uploaded.path).not.match(/angles_home_page/); + should(uploaded.originalName).equal('angles_home_page.jpg'); + }); + + it('serves the original file', (done) => { + memberAgent + .get(`${baseUrl}attachment/${uploaded._id}/file`) + .expect(200) + .expect('Content-Type', /image/, done); + }); + + it('serves the thumbnail', (done) => { + memberAgent + .get(`${baseUrl}attachment/${uploaded._id}/thumbnail`) + .expect(200) + .expect('Content-Type', /image/, done); + }); + + it('respond with 403 for a user outside the owning team', async () => { + const res = await send(outsiderAgent, 'get', `attachment/${uploaded._id}`); + should(res.status).equal(403); + }); + + it('respond with 403 when uploading to another team\'s test case', async () => { + const res = await upload(outsiderAgent, { testCaseId: testCase._id }); + should(res.status).equal(403); + }); + + it('respond with 404 when the test case does not exist', async () => { + const res = await upload(memberAgent, { testCaseId: '5f7e2b9e8f1b2c0017a1b2c3' }); + should(res.status).equal(404); + }); + + it('respond with 400 when no owner id is supplied', async () => { + const res = await upload(memberAgent, {}); + should(res.status).equal(400); + }); + + it('respond with 400 for a non-image upload', async () => { + const res = await upload(memberAgent, { testCaseId: testCase._id }, './package.json'); + should(res.status).equal(400); + should(res.body.error).match(/Only image files are supported/); + }); + + it('leaves no orphan file behind when the upload is rejected', async () => { + const filesBefore = fs.readdirSync(`attachments/${testCase._id}`).length; + const before = await Attachment.countDocuments({ team: team._id }); + + const res = await upload(outsiderAgent, { testCaseId: testCase._id }); + should(res.status).equal(403); + + // multer writes the file before the request is authorised, so the rejection has to + // clean it up - otherwise every denied upload silently fills the disk. + should(await Attachment.countDocuments({ team: team._id })).equal(before); + should(fs.readdirSync(`attachments/${testCase._id}`).length).equal(filesBefore); + }); + + it('leaves no empty directory behind for an owner that does not exist', async () => { + const unknownId = '5f7e2b9e8f1b2c0017a1b2c3'; + const res = await upload(memberAgent, { testCaseId: unknownId }); + should(res.status).equal(404); + should(fs.existsSync(`attachments/${unknownId}`)).equal(false); + }); + + it('uploads to a shared step', async () => { + const sharedStep = await send(leadAgent, 'post', 'shared-step', { + team: team._id.toString(), + name: 'unit-testing-at shared', + steps: [{ action: 'shared thing' }], + }); + should(sharedStep.status).equal(201); + + const res = await upload(leadAgent, { sharedStepId: sharedStep.body._id }); + should(res.status).equal(201); + should(res.body.scope).equal('sharedstep'); + should(res.body.sharedStep).equal(sharedStep.body._id); + }); + }); + + describe('GET /attachment', () => { + it('lists the attachments for a test case', async () => { + const res = await send(memberAgent, 'get', `attachment?testCaseId=${testCase._id}`); + should(res.status).equal(200); + should(res.body.attachments.length).be.above(0); + }); + + it('respond with 403 for a user outside the team', async () => { + const res = await send(outsiderAgent, 'get', `attachment?testCaseId=${testCase._id}`); + should(res.status).equal(403); + }); + }); + + describe('referencing an attachment from a step', () => { + let attachment; + let referencingCase; + + before(async () => { + const res = await upload(memberAgent, { testCaseId: testCase._id }); + attachment = res.body; + }); + + it('accepts a step that references the attachment', async () => { + const res = await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-at case referencing an image', + steps: [{ + action: 'Compare against the reference image', + expected: 'Matches ![reference](attachment:x)', + attachments: [attachment._id], + }], + }); + should(res.status).equal(201); + referencingCase = res.body; + should(res.body.steps[0].attachments).have.length(1); + }); + + it('freezes the reference into the version', async () => { + const version = await ManualTestCaseVersion + .findOne({ testCase: referencingCase._id, version: 1 }) + .lean(); + should(version.steps[0].attachments).have.length(1); + should(version.steps[0].attachments[0].toString()).equal(attachment._id); + }); + + it('respond with 404 for an attachment that does not exist', async () => { + const res = await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-at bad attachment reference', + steps: [{ action: 'x', attachments: ['5f7e2b9e8f1b2c0017a1b2c3'] }], + }); + should(res.status).equal(404); + should(res.body.message).match(/No attachment found/); + }); + + it('respond with 404 for another team\'s attachment', async () => { + const foreignCase = await send(outsiderAgent, 'post', 'manual-test-case', { + team: otherTeam._id.toString(), + title: 'unit-testing-at foreign case', + steps: [{ action: 'x' }], + }); + const foreign = await upload(outsiderAgent, { testCaseId: foreignCase.body._id }); + should(foreign.status).equal(201); + + const res = await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-at cross team attachment', + steps: [{ action: 'x', attachments: [foreign.body._id] }], + }); + should(res.status).equal(404); + }); + + it('refuses to delete an attachment a frozen version references', async () => { + const res = await send(memberAgent, 'delete', `attachment/${attachment._id}`); + should(res.status).equal(409); + should(res.body.message).match(/frozen test case version/); + // The file must survive - it is the only copy of what that version's tester saw. + should(fs.existsSync(attachment.path)).equal(true); + }); + + it('still refuses after the step is removed from the head', async () => { + const updated = await send(memberAgent, 'put', `manual-test-case/${referencingCase._id}`, { + steps: [{ action: 'Compare against the reference image', expected: 'no image now' }], + }); + should(updated.status).equal(200); + should(updated.body.version).equal(2); + + // v1 still references it, so the image a historical execution renders is protected. + const res = await send(memberAgent, 'delete', `attachment/${attachment._id}`); + should(res.status).equal(409); + should(fs.existsSync(attachment.path)).equal(true); + }); + }); + + describe('DELETE /attachment/:id', () => { + it('deletes an attachment nothing references, removing its files', async () => { + const res = await upload(memberAgent, { testCaseId: testCase._id }); + const attachment = res.body; + should(fs.existsSync(attachment.path)).equal(true); + + const deleted = await send(memberAgent, 'delete', `attachment/${attachment._id}`); + should(deleted.status).equal(200); + should(fs.existsSync(attachment.path)).equal(false); + should(fs.existsSync(attachment.thumbnail)).equal(false); + const gone = await Attachment.findById(attachment._id).lean(); + should(gone).equal(null); + }); + + it('clears the reference from a head step so nothing renders broken', async () => { + const uploadRes = await upload(memberAgent, { testCaseId: testCase._id }); + const attachment = uploadRes.body; + + const created = await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-at head reference cleanup', + steps: [{ action: 'x', attachments: [attachment._id] }], + }); + should(created.status).equal(201); + + // Remove the frozen reference so the delete is permitted, leaving only the head. + await ManualTestCaseVersion.deleteMany({ testCase: created.body._id }).exec(); + + const deleted = await send(memberAgent, 'delete', `attachment/${attachment._id}`); + should(deleted.status).equal(200); + + const head = await ManualTestCase.findById(created.body._id).lean(); + should(head.steps[0].attachments).have.length(0); + }); + + it('respond with 403 for a user outside the team', async () => { + const res = await upload(memberAgent, { testCaseId: testCase._id }); + const denied = await send(outsiderAgent, 'delete', `attachment/${res.body._id}`); + should(denied.status).equal(403); + }); + + it('respond with 404 for an unknown id', async () => { + const res = await send(memberAgent, 'delete', 'attachment/5f7e2b9e8f1b2c0017a1b2c3'); + should(res.status).equal(404); + }); + }); + + describe('deleting a test case releases its attachments', () => { + it('removes the attachment documents and files along with the case', async () => { + const created = await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-at doomed case', + steps: [{ action: 'x' }], + }); + const doomed = created.body; + + const uploadRes = await upload(leadAgent, { testCaseId: doomed._id }); + const attachment = uploadRes.body; + should(fs.existsSync(attachment.path)).equal(true); + + // Reference it from a step, so a frozen version holds it - the case delete has to + // remove the versions first for the attachment to be collectable. + const referenced = await send(leadAgent, 'put', `manual-test-case/${doomed._id}`, { + steps: [{ action: 'x', attachments: [attachment._id] }], + }); + should(referenced.status).equal(200); + + const deleted = await send(leadAgent, 'delete', `manual-test-case/${doomed._id}`); + should(deleted.status).equal(200); + + const gone = await Attachment.findById(attachment._id).lean(); + should(gone).equal(null); + should(fs.existsSync(attachment.path)).equal(false); + }); + }); +}); diff --git a/test/custom-field.tests.js b/test/custom-field.tests.js new file mode 100644 index 0000000..f07d250 --- /dev/null +++ b/test/custom-field.tests.js @@ -0,0 +1,627 @@ +/** + * Tests for admin-configurable custom fields and their enforcement on manual test cases. + * + * The interesting behaviour is at the boundaries: unknown keys are rejected rather than + * dropped, required fields are enforced only once a case leaves DRAFT, and a field that + * has been used anywhere is archived rather than deleted so historical values stay + * renderable. + */ +const request = require('supertest'); +const should = require('should'); +const pino = require('pino'); +const bcrypt = require('bcryptjs'); +const app = require('../server.js'); +const testUtils = require('./test-utils.js'); +const User = require('../app/models/user.js'); +const CustomFieldDefinition = require('../app/models/custom-field-definition.js'); +const ManualTestCase = require('../app/models/manual-test-case.js'); +const ManualTestCaseVersion = require('../app/models/manual-test-case-version.js'); +const { Team } = require('../app/models/team.js'); + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); +const baseUrl = '/rest/api/v1.0/'; + +const MEMBER_PASSWORD = 'unit-testing-CfMember1!'; + +describe('Custom Field API Tests', () => { + let adminAgent; + let memberAgent; + let team; + let memberUser; + let textField; + let selectField; + + const login = (username, password) => new Promise((resolve, reject) => { + const agent = request.agent(app); + agent + .post(`${baseUrl}auth/login`) + .send({ username, password }) + .end((err, res) => { + if (err) return reject(err); + if (res.status !== 200) return reject(new Error(`login failed for ${username}: ${res.status}`)); + return resolve(agent); + }); + }); + + const createCase = (agent, body) => new Promise((resolve, reject) => { + agent + .post(`${baseUrl}manual-test-case`) + .send({ team: team._id.toString(), title: 'unit-testing-cf case', ...body }) + .set('Accept', 'application/json') + .end((err, res) => (err ? reject(err) : resolve(res))); + }); + + before(async () => { + await Promise.all([ + User.deleteMany({ username: /^unit-testing-cf/ }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-cf/ }).exec(), + Team.deleteMany({ name: /^unit-testing-cf/ }).exec(), + ]); + logger.info('Cleared any lingering custom field fixtures'); + + team = await new Team({ + name: 'unit-testing-cf-team', + components: [{ name: 'cf-component' }], + }).save(); + await CustomFieldDefinition.deleteMany({ team: team._id }).exec(); + + const memberHash = await bcrypt.hash(MEMBER_PASSWORD, 10); + memberUser = await User.create({ + username: 'unit-testing-cf-member', password: memberHash, role: 'user', teams: [team._id], + }); + + [adminAgent, memberAgent] = await Promise.all([ + testUtils.getAdminAgent(), + login('unit-testing-cf-member', MEMBER_PASSWORD), + ]); + }); + + after(async () => { + const cases = await ManualTestCase.find({ title: /^unit-testing-cf/ }).select('_id').lean(); + await Promise.all([ + ManualTestCaseVersion.deleteMany({ testCase: { $in: cases.map((c) => c._id) } }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-cf/ }).exec(), + CustomFieldDefinition.deleteMany({ team: team._id }).exec(), + User.deleteMany({ username: /^unit-testing-cf/ }).exec(), + Team.deleteMany({ name: /^unit-testing-cf/ }).exec(), + ]); + }); + + describe('POST /custom-field', () => { + it('respond with 201 when an admin creates a text field', (done) => { + adminAgent + .post(`${baseUrl}custom-field`) + .send({ + team: team._id.toString(), + key: 'test_reference', + label: 'Test Reference', + type: 'text', + order: 1, + }) + .set('Accept', 'application/json') + .expect(201) + .end((err, res) => { + if (err) throw err; + textField = res.body; + should(res.body.key).equal('test_reference'); + should(res.body.archived).equal(false); + done(); + }); + }); + + it('respond with 201 when creating a select field with options', (done) => { + adminAgent + .post(`${baseUrl}custom-field`) + .send({ + team: team._id.toString(), + key: 'test_layer', + label: 'Test Layer', + type: 'select', + options: ['unit', 'integration', 'e2e'], + order: 2, + }) + .set('Accept', 'application/json') + .expect(201) + .end((err, res) => { + if (err) throw err; + selectField = res.body; + should(res.body.options).have.length(3); + done(); + }); + }); + + it('respond with 403 when a non-admin tries to create a field', (done) => { + memberAgent + .post(`${baseUrl}custom-field`) + .send({ + team: team._id.toString(), key: 'sneaky', label: 'Sneaky', type: 'text', + }) + .set('Accept', 'application/json') + .expect(403, done); + }); + + it('respond with 422 for a key that is not a valid storage key', (done) => { + adminAgent + .post(`${baseUrl}custom-field`) + .send({ + team: team._id.toString(), key: 'Not A Key', label: 'Bad', type: 'text', + }) + .set('Accept', 'application/json') + .expect(422, done); + }); + + it('respond with 400 when a select field has no options', (done) => { + adminAgent + .post(`${baseUrl}custom-field`) + .send({ + team: team._id.toString(), key: 'no_options', label: 'No Options', type: 'select', + }) + .set('Accept', 'application/json') + .expect(400, done); + }); + + it('respond with 400 when a text field is given options', (done) => { + adminAgent + .post(`${baseUrl}custom-field`) + .send({ + team: team._id.toString(), + key: 'text_with_options', + label: 'Text', + type: 'text', + options: ['a'], + }) + .set('Accept', 'application/json') + .expect(400, done); + }); + + it('respond with 409 for a duplicate key within the team', (done) => { + adminAgent + .post(`${baseUrl}custom-field`) + .send({ + team: team._id.toString(), key: 'test_reference', label: 'Duplicate', type: 'text', + }) + .set('Accept', 'application/json') + .expect(409, done); + }); + }); + + describe('GET /custom-field', () => { + it('returns the team\'s fields in order for a plain team member', (done) => { + memberAgent + .get(`${baseUrl}custom-field?teamId=${team._id}`) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) throw err; + should(res.body.customFields.length).be.aboveOrEqual(2); + should(res.body.customFields[0].order).be.belowOrEqual(res.body.customFields[1].order); + done(); + }); + }); + + it('respond with 422 when teamId is missing', (done) => { + memberAgent + .get(`${baseUrl}custom-field`) + .set('Accept', 'application/json') + .expect(422, done); + }); + }); + + describe('custom field values on a manual test case', () => { + it('stores a valid value', async () => { + const res = await createCase(memberAgent, { + title: 'unit-testing-cf case with fields', + customFields: { test_reference: 'JIRA-123', test_layer: 'e2e' }, + }); + should(res.status).equal(201); + should(res.body.customFields.test_reference).equal('JIRA-123'); + should(res.body.customFields.test_layer).equal('e2e'); + }); + + it('rejects an unknown field key rather than dropping it', async () => { + const res = await createCase(memberAgent, { + title: 'unit-testing-cf case unknown key', + customFields: { not_a_field: 'x' }, + }); + should(res.status).equal(400); + should(res.body.message).match(/not a configured custom field/); + }); + + it('rejects a select value that is not one of the options', async () => { + const res = await createCase(memberAgent, { + title: 'unit-testing-cf case bad option', + customFields: { test_layer: 'smoke' }, + }); + should(res.status).equal(400); + should(res.body.message).match(/must be one of/); + }); + + it('reports every invalid field at once', async () => { + const res = await createCase(memberAgent, { + title: 'unit-testing-cf case many errors', + customFields: { test_layer: 'smoke', nope: 1, also_nope: 2 }, + }); + should(res.status).equal(400); + should(res.body.message).match(/nope/); + should(res.body.message).match(/also_nope/); + should(res.body.message).match(/must be one of/); + }); + + it('coerces a numeric string to a number', async () => { + await new Promise((resolve, reject) => { + adminAgent + .post(`${baseUrl}custom-field`) + .send({ + team: team._id.toString(), key: 'estimated_minutes', label: 'Estimated Minutes', type: 'number', + }) + .expect(201) + .end((err) => (err ? reject(err) : resolve())); + }); + const res = await createCase(memberAgent, { + title: 'unit-testing-cf case number coercion', + customFields: { estimated_minutes: '45' }, + }); + should(res.status).equal(201); + should(res.body.customFields.estimated_minutes).equal(45); + }); + + it('rejects a non-numeric value for a number field', async () => { + const res = await createCase(memberAgent, { + title: 'unit-testing-cf case bad number', + customFields: { estimated_minutes: 'about an hour' }, + }); + should(res.status).equal(400); + should(res.body.message).match(/must be a number/); + }); + + it('validates that a user-typed field refers to a real user', async () => { + await new Promise((resolve, reject) => { + adminAgent + .post(`${baseUrl}custom-field`) + .send({ + team: team._id.toString(), key: 'reviewer', label: 'Reviewer', type: 'user', + }) + .expect(201) + .end((err) => (err ? reject(err) : resolve())); + }); + const good = await createCase(memberAgent, { + title: 'unit-testing-cf case good reviewer', + customFields: { reviewer: memberUser._id.toString() }, + }); + should(good.status).equal(201); + + const bad = await createCase(memberAgent, { + title: 'unit-testing-cf case bad reviewer', + customFields: { reviewer: '5f7e2b9e8f1b2c0017a1b2c3' }, + }); + should(bad.status).equal(400); + should(bad.body.message).match(/does not exist/); + }); + }); + + describe('required custom fields', () => { + let requiredField; + + before(async () => { + const res = await new Promise((resolve, reject) => { + adminAgent + .post(`${baseUrl}custom-field`) + .send({ + team: team._id.toString(), + key: 'sign_off', + label: 'Sign Off', + type: 'text', + required: true, + }) + .end((err, response) => (err ? reject(err) : resolve(response))); + }); + requiredField = res.body; + }); + + after(async () => { + await CustomFieldDefinition.findByIdAndRemove(requiredField._id).exec(); + }); + + it('allows a DRAFT to be saved without the required field', async () => { + const res = await createCase(memberAgent, { title: 'unit-testing-cf draft without required' }); + should(res.status).equal(201); + should(res.body.status).equal('DRAFT'); + }); + + it('rejects creating an ACTIVE case without the required field', async () => { + const res = await createCase(memberAgent, { + title: 'unit-testing-cf active without required', + status: 'ACTIVE', + }); + should(res.status).equal(400); + should(res.body.message).match(/Sign Off.*required/); + }); + + it('rejects publishing a draft that is missing the required field', async () => { + const created = await createCase(memberAgent, { title: 'unit-testing-cf publish me' }); + should(created.status).equal(201); + + await new Promise((resolve, reject) => { + memberAgent + .put(`${baseUrl}manual-test-case/${created.body._id}`) + .send({ status: 'ACTIVE' }) + .expect(400) + .end((err, res) => { + if (err) return reject(err); + should(res.body.message).match(/Sign Off.*required/); + return resolve(); + }); + }); + }); + + it('allows publishing once the required field is supplied', async () => { + const created = await createCase(memberAgent, { title: 'unit-testing-cf publish me too' }); + should(created.status).equal(201); + + await new Promise((resolve, reject) => { + memberAgent + .put(`${baseUrl}manual-test-case/${created.body._id}`) + .send({ status: 'ACTIVE', customFields: { sign_off: 'approved by QA lead' } }) + .expect(200) + .end((err) => (err ? reject(err) : resolve())); + }); + }); + }); + + describe('version snapshots', () => { + it('records the field definitions in force when the version was written', async () => { + const created = await createCase(memberAgent, { + title: 'unit-testing-cf snapshot case', + customFields: { test_reference: 'JIRA-999', test_layer: 'unit' }, + }); + should(created.status).equal(201); + + const version = await ManualTestCaseVersion + .findOne({ testCase: created.body._id, version: 1 }) + .lean(); + const keys = version.fieldDefinitions.map((definition) => definition.key); + should(keys).containEql('test_reference'); + should(keys).containEql('test_layer'); + const layer = version.fieldDefinitions.find((d) => d.key === 'test_layer'); + should(layer.label).equal('Test Layer'); + should(layer.options).have.length(3); + }); + + it('keeps the original label after the field is relabelled', async () => { + const created = await createCase(memberAgent, { + title: 'unit-testing-cf relabel case', + customFields: { test_reference: 'JIRA-1000' }, + }); + should(created.status).equal(201); + + await new Promise((resolve, reject) => { + adminAgent + .put(`${baseUrl}custom-field/${textField._id}`) + .send({ label: 'Ticket Reference' }) + .expect(200) + .end((err) => (err ? reject(err) : resolve())); + }); + + const version = await ManualTestCaseVersion + .findOne({ testCase: created.body._id, version: 1 }) + .lean(); + const reference = version.fieldDefinitions.find((d) => d.key === 'test_reference'); + should(reference.label).equal('Test Reference'); + + // restore for the remaining tests + await CustomFieldDefinition + .findByIdAndUpdate(textField._id, { label: 'Test Reference' }) + .exec(); + }); + + it('only snapshots definitions the case actually holds a value for', async () => { + const created = await createCase(memberAgent, { + title: 'unit-testing-cf partial snapshot', + customFields: { test_reference: 'JIRA-1001' }, + }); + should(created.status).equal(201); + + const version = await ManualTestCaseVersion + .findOne({ testCase: created.body._id, version: 1 }) + .lean(); + const keys = version.fieldDefinitions.map((d) => d.key); + should(keys).containEql('test_reference'); + should(keys).not.containEql('test_layer'); + }); + + it('does not burn a version when the same custom field values are re-sent', async () => { + const created = await createCase(memberAgent, { + title: 'unit-testing-cf no-op update', + customFields: { test_reference: 'JIRA-1002' }, + }); + should(created.status).equal(201); + + await new Promise((resolve, reject) => { + memberAgent + .put(`${baseUrl}manual-test-case/${created.body._id}`) + .send({ customFields: { test_reference: 'JIRA-1002' } }) + .expect(200) + .end((err, res) => { + if (err) return reject(err); + should(res.body.version).equal(1); + return resolve(); + }); + }); + }); + + it('burns a version when a custom field value changes', async () => { + const created = await createCase(memberAgent, { + title: 'unit-testing-cf changed value', + customFields: { test_reference: 'JIRA-1003' }, + }); + should(created.status).equal(201); + + await new Promise((resolve, reject) => { + memberAgent + .put(`${baseUrl}manual-test-case/${created.body._id}`) + .send({ customFields: { test_reference: 'JIRA-1004' } }) + .expect(200) + .end((err, res) => { + if (err) return reject(err); + should(res.body.version).equal(2); + return resolve(); + }); + }); + + const version1 = await ManualTestCaseVersion + .findOne({ testCase: created.body._id, version: 1 }) + .lean(); + should(version1.customFields.test_reference).equal('JIRA-1003'); + }); + }); + + describe('PUT /custom-field/:fieldId', () => { + it('respond with 400 when trying to change the storage key', (done) => { + adminAgent + .put(`${baseUrl}custom-field/${textField._id}`) + .send({ key: 'renamed_key' }) + .set('Accept', 'application/json') + .expect(400) + .end((err, res) => { + if (err) throw err; + should(res.body.message).match(/cannot be changed/); + done(); + }); + }); + + it('respond with 409 when changing the type of a field already in use', (done) => { + adminAgent + .put(`${baseUrl}custom-field/${textField._id}`) + .send({ type: 'number' }) + .set('Accept', 'application/json') + .expect(409, done); + }); + + it('respond with 409 when removing an option still used by a test case', (done) => { + adminAgent + .put(`${baseUrl}custom-field/${selectField._id}`) + .send({ options: ['unit', 'integration'] }) + .set('Accept', 'application/json') + .expect(409) + .end((err, res) => { + if (err) throw err; + should(res.body.message).match(/still use them/); + done(); + }); + }); + + it('allows adding an option', (done) => { + adminAgent + .put(`${baseUrl}custom-field/${selectField._id}`) + .send({ options: ['unit', 'integration', 'e2e', 'contract'] }) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) throw err; + should(res.body.options).have.length(4); + done(); + }); + }); + + it('respond with 403 for a non-admin', (done) => { + memberAgent + .put(`${baseUrl}custom-field/${textField._id}`) + .send({ label: 'Hijacked' }) + .set('Accept', 'application/json') + .expect(403, done); + }); + }); + + describe('DELETE /custom-field/:fieldId', () => { + it('archives rather than deletes a field that is in use', (done) => { + adminAgent + .delete(`${baseUrl}custom-field/${textField._id}`) + .set('Accept', 'application/json') + .expect(200) + .end(async (err, res) => { + if (err) throw err; + should(res.body.archived).equal(true); + const stillThere = await CustomFieldDefinition.findById(textField._id).lean(); + should(stillThere).not.equal(null); + should(stillThere.archived).equal(true); + done(); + }); + }); + + it('hard-deletes a field that has never been used', (done) => { + adminAgent + .post(`${baseUrl}custom-field`) + .send({ + team: team._id.toString(), key: 'never_used', label: 'Never Used', type: 'text', + }) + .expect(201) + .end((createErr, createRes) => { + if (createErr) throw createErr; + adminAgent + .delete(`${baseUrl}custom-field/${createRes.body._id}`) + .expect(200) + .end(async (err, res) => { + if (err) throw err; + should(res.body.archived).equal(false); + const gone = await CustomFieldDefinition.findById(createRes.body._id).lean(); + should(gone).equal(null); + done(); + }); + }); + }); + + it('still accepts a stored value for an archived field', async () => { + // The field was archived above; a case that already holds a value for it must still + // save rather than failing with "not a configured custom field". + const res = await createCase(memberAgent, { + title: 'unit-testing-cf archived value', + customFields: { test_reference: 'JIRA-2000' }, + }); + should(res.status).equal(201); + should(res.body.customFields.test_reference).equal('JIRA-2000'); + }); + + it('does not enforce required on an archived field', async () => { + const created = await new Promise((resolve, reject) => { + adminAgent + .post(`${baseUrl}custom-field`) + .send({ + team: team._id.toString(), + key: 'archived_required', + label: 'Archived Required', + type: 'text', + required: true, + }) + .expect(201) + .end((err, res) => (err ? reject(err) : resolve(res.body))); + }); + await CustomFieldDefinition + .findByIdAndUpdate(created._id, { archived: true }) + .exec(); + + // Publishing with no value for the archived required field must still succeed - an + // archived field is on its way out and cannot block authoring. + const res = await createCase(memberAgent, { + title: 'unit-testing-cf archived required', + status: 'ACTIVE', + }); + should(res.status).equal(201); + + await CustomFieldDefinition.findByIdAndRemove(created._id).exec(); + }); + + it('respond with 403 for a non-admin', (done) => { + memberAgent + .delete(`${baseUrl}custom-field/${selectField._id}`) + .set('Accept', 'application/json') + .expect(403, done); + }); + + it('respond with 404 for an unknown id', (done) => { + adminAgent + .delete(`${baseUrl}custom-field/5f7e2b9e8f1b2c0017a1b2c3`) + .set('Accept', 'application/json') + .expect(404, done); + }); + }); +}); diff --git a/test/execution-type-filter.tests.js b/test/execution-type-filter.tests.js new file mode 100644 index 0000000..3dd647e --- /dev/null +++ b/test/execution-type-filter.tests.js @@ -0,0 +1,215 @@ +/** + * Tests for the automated/manual filter on the dashboard and metrics endpoints. + * + * The important property is that omitting the parameter is unchanged behaviour: every + * existing client, and the whole rest of the suite, must keep seeing what it saw before + * manual runs existed. The filter narrows; it never reshapes. + */ +const request = require('supertest'); +const should = require('should'); +const pino = require('pino'); +const bcrypt = require('bcryptjs'); +const app = require('../server.js'); +const testUtils = require('./test-utils.js'); +const User = require('../app/models/user.js'); +const Build = require('../app/models/build.js'); +const TestExecution = require('../app/models/execution.js'); +const ManualTestRun = require('../app/models/manual-test-run.js'); +const ManualTestCase = require('../app/models/manual-test-case.js'); +const ManualTestCaseVersion = require('../app/models/manual-test-case-version.js'); +const Environment = require('../app/models/environment.js'); +const { Team } = require('../app/models/team.js'); + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); +const baseUrl = '/rest/api/v1.0/'; + +const MEMBER_PASSWORD = 'unit-testing-EtMember1!'; + +describe('Execution Type Filter Tests', () => { + let memberAgent; + let team; + let environment; + let automatedBuild; + let manualRun; + + const login = (username, password) => new Promise((resolve, reject) => { + const agent = request.agent(app); + agent + .post(`${baseUrl}auth/login`) + .send({ username, password }) + .end((err, res) => { + if (err) return reject(err); + if (res.status !== 200) return reject(new Error(`login failed for ${username}: ${res.status}`)); + return resolve(agent); + }); + }); + + const send = (agent, method, url, body) => new Promise((resolve, reject) => { + const req = agent[method](`${baseUrl}${url}`).set('Accept', 'application/json'); + if (body !== undefined) req.send(body); + req.end((err, res) => (err ? reject(err) : resolve(res))); + }); + + before(async () => { + await Promise.all([ + User.deleteMany({ username: /^unit-testing-et/ }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-et/ }).exec(), + ManualTestRun.deleteMany({ name: /^unit-testing-et/ }).exec(), + Team.deleteMany({ name: /^unit-testing-et/ }).exec(), + Environment.deleteMany({ name: /^unit-testing-et/ }).exec(), + ]); + logger.info('Cleared any lingering execution type fixtures'); + + team = await new Team({ name: 'unit-testing-et-team', components: [{ name: 'et-component' }] }).save(); + environment = await new Environment({ name: 'unit-testing-et-env' }).save(); + + const memberHash = await bcrypt.hash(MEMBER_PASSWORD, 10); + await User.create({ + username: 'unit-testing-et-member', password: memberHash, role: 'user', teams: [team._id], + }); + memberAgent = await login('unit-testing-et-member', MEMBER_PASSWORD); + await testUtils.getAdminAgent(); + + // One automated build, created the ordinary way. + automatedBuild = await testUtils.createBuild(team, environment, 'unit-testing-et automated'); + + // One manual run, which creates its own manual-tagged build. + const testCase = await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-et case', + status: 'ACTIVE', + steps: [{ action: 'Do it', expected: 'Done' }], + }); + const run = await send(memberAgent, 'post', 'manual-test-run', { + name: 'unit-testing-et manual run', + team: team._id.toString(), + environment: environment.name, + testCaseIds: [testCase.body._id], + }); + manualRun = run.body; + await send(memberAgent, 'put', `manual-test-run/${manualRun._id}/test-case/${testCase.body._id}/result`, { status: 'PASS' }); + }); + + after(async () => { + const runs = await ManualTestRun.find({ name: /^unit-testing-et/ }).lean(); + const buildIds = runs.map((run) => run.build).filter(Boolean); + const cases = await ManualTestCase.find({ title: /^unit-testing-et/ }).select('_id').lean(); + await Promise.all([ + TestExecution.deleteMany({ build: { $in: buildIds } }).exec(), + Build.deleteMany({ _id: { $in: [...buildIds, automatedBuild._id] } }).exec(), + ManualTestRun.deleteMany({ name: /^unit-testing-et/ }).exec(), + ManualTestCaseVersion.deleteMany({ testCase: { $in: cases.map((c) => c._id) } }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-et/ }).exec(), + User.deleteMany({ username: /^unit-testing-et/ }).exec(), + Team.deleteMany({ name: /^unit-testing-et/ }).exec(), + Environment.deleteMany({ name: /^unit-testing-et/ }).exec(), + ]); + }); + + describe('GET /build', () => { + it('returns both types when the parameter is omitted', async () => { + const res = await send(memberAgent, 'get', `build?teamId=${team._id}&limit=50`); + should(res.status).equal(200); + const types = new Set(res.body.builds.map((build) => build.executionType)); + should(types.has('automated')).equal(true); + should(types.has('manual')).equal(true); + }); + + it('returns only manual builds when asked', async () => { + const res = await send(memberAgent, 'get', `build?teamId=${team._id}&executionType=manual&limit=50`); + should(res.status).equal(200); + should(res.body.builds.length).be.above(0); + res.body.builds.forEach((build) => should(build.executionType).equal('manual')); + }); + + it('returns only automated builds when asked', async () => { + const res = await send(memberAgent, 'get', `build?teamId=${team._id}&executionType=automated&limit=50`); + should(res.status).equal(200); + should(res.body.builds.length).be.above(0); + res.body.builds.forEach((build) => should(build.executionType).equal('automated')); + }); + + it('the two filtered counts add up to the unfiltered count', async () => { + const [all, automated, manual] = await Promise.all([ + send(memberAgent, 'get', `build?teamId=${team._id}&limit=50`), + send(memberAgent, 'get', `build?teamId=${team._id}&executionType=automated&limit=50`), + send(memberAgent, 'get', `build?teamId=${team._id}&executionType=manual&limit=50`), + ]); + should(automated.body.metrics.totalTestRuns + manual.body.metrics.totalTestRuns) + .equal(all.body.metrics.totalTestRuns); + }); + + it('respond with 422 for an unknown execution type', async () => { + const res = await send(memberAgent, 'get', `build?teamId=${team._id}&executionType=sideways`); + should(res.status).equal(422); + }); + }); + + describe('GET /metrics/phase', () => { + // The metrics endpoint rejects API-token auth, so the session agent is required. + const metricsUrl = (extra = '') => `metrics/phase?teamId=${team._id}&groupingPeriod=day${extra}`; + + it('includes both types when the parameter is omitted', async () => { + const res = await send(memberAgent, 'get', metricsUrl()); + should(res.status).equal(200); + should(res.body.executionType).equal(undefined); + const totals = res.body.periods.reduce((sum, period) => sum + period.result.TOTAL, 0); + should(totals).be.aboveOrEqual(1); + }); + + it('reports a per-period breakdown so the UI can stack the two', async () => { + const res = await send(memberAgent, 'get', metricsUrl()); + should(res.status).equal(200); + res.body.periods.forEach((period) => { + should(period.executionTypeBreakdown).not.equal(undefined); + const { automated, manual } = period.executionTypeBreakdown; + // The breakdown must account for every execution in the period, or a stacked + // chart built from it would silently under-report. + should(automated + manual).equal(period.result.TOTAL); + }); + }); + + it('narrows to manual when asked', async () => { + const res = await send(memberAgent, 'get', metricsUrl('&executionType=manual')); + should(res.status).equal(200); + should(res.body.executionType).equal('manual'); + res.body.periods.forEach((period) => { + should(period.executionTypeBreakdown.automated).equal(0); + }); + const manualTotal = res.body.periods + .reduce((sum, period) => sum + period.executionTypeBreakdown.manual, 0); + should(manualTotal).be.above(0); + }); + + it('narrows to automated when asked', async () => { + const res = await send(memberAgent, 'get', metricsUrl('&executionType=automated')); + should(res.status).equal(200); + res.body.periods.forEach((period) => { + should(period.executionTypeBreakdown.manual).equal(0); + }); + }); + + it('respond with 422 for an unknown execution type', async () => { + const res = await send(memberAgent, 'get', metricsUrl('&executionType=sideways')); + should(res.status).equal(422); + }); + + it('counts an execution with no stored executionType as automated', async () => { + // Executions written before manual runs existed have no field on disk. They must + // land in the automated bucket rather than falling out of the breakdown. + const execution = await TestExecution.findOne({ build: automatedBuild._id }).lean(); + if (execution) { + await TestExecution.collection.updateOne( + { _id: execution._id }, + { $unset: { executionType: '' } }, + ); + const res = await send(memberAgent, 'get', metricsUrl()); + should(res.status).equal(200); + res.body.periods.forEach((period) => { + const { automated, manual } = period.executionTypeBreakdown; + should(automated + manual).equal(period.result.TOTAL); + }); + } + }); + }); +}); diff --git a/test/feature-settings.tests.js b/test/feature-settings.tests.js new file mode 100644 index 0000000..c52bf7c --- /dev/null +++ b/test/feature-settings.tests.js @@ -0,0 +1,235 @@ +const request = require('supertest'); +const should = require('should'); +const bcrypt = require('bcryptjs'); +const app = require('../server.js'); +const User = require('../app/models/user.js'); +const FeatureSettings = require('../app/models/feature-settings.js'); +const featureSettingsService = require('../app/utils/feature-settings-service.js'); +const featureConfig = require('../config/feature.config.js'); +const testUtils = require('./test-utils.js'); + +const baseUrl = '/rest/api/v1.0/'; + +describe('Feature Settings API Tests', () => { + let adminAgent; + let userAgent; + let regularUser; + let team; + + before(async () => { + await User.deleteMany({ username: /^feature-testing/ }).exec(); + const hash = await bcrypt.hash('feature-testing-Password1', 10); + regularUser = await User.create({ username: 'feature-testing-user', password: hash, role: 'user' }); + // The test case listing requires a team, so the enabled-path assertions can check a + // real 200 rather than a validation failure that would pass the guard either way. + team = await testUtils.createTeam('feature-testing-team'); + + adminAgent = await testUtils.getAdminAgent(); + userAgent = request.agent(app); + await userAgent + .post(`${baseUrl}auth/login`) + .send({ username: 'feature-testing-user', password: 'feature-testing-Password1' }); + }); + + // Every feature toggle is global in-memory state, so leaving one off would disable the + // manual testing suites that run after this file. Reset the document and reload. + afterEach(async () => { + await FeatureSettings.deleteMany({}).exec(); + await featureSettingsService.loadFeatureSettings(); + }); + + after(async () => { + await User.findOneAndRemove({ _id: regularUser._id }).exec(); + testUtils.cleanUp(); + await FeatureSettings.deleteMany({}).exec(); + await featureSettingsService.loadFeatureSettings(); + }); + + describe('GET /settings/features', () => { + it('should default every feature to enabled', async () => { + const res = await adminAgent.get(`${baseUrl}settings/features`).expect(200); + res.body.should.have.property('manualTestingEnabled', true); + }); + + it('should not be readable by a non-admin', async () => { + await userAgent.get(`${baseUrl}settings/features`).expect(403); + }); + }); + + describe('PUT /settings/features', () => { + it('should persist a disabled feature', async () => { + const res = await adminAgent + .put(`${baseUrl}settings/features`) + .send({ manualTestingEnabled: false }) + .expect(200); + res.body.should.have.property('manualTestingEnabled', false); + + const reread = await adminAgent.get(`${baseUrl}settings/features`).expect(200); + reread.body.should.have.property('manualTestingEnabled', false); + }); + + it('should reject a non-boolean value', async () => { + await adminAgent + .put(`${baseUrl}settings/features`) + .send({ manualTestingEnabled: 'no' }) + .expect(422); + }); + + it('should not be writable by a non-admin', async () => { + await userAgent + .put(`${baseUrl}settings/features`) + .send({ manualTestingEnabled: false }) + .expect(403); + }); + }); + + describe('Manual testing route guard', () => { + const disableManualTesting = () => adminAgent + .put(`${baseUrl}settings/features`) + .send({ manualTestingEnabled: false }) + .expect(200); + + it('should allow the manual routes while the feature is enabled', async () => { + await adminAgent.get(`${baseUrl}manual-test-case?teamId=${team._id}`).expect(200); + }); + + it('should 404 every manual route group once disabled', async () => { + await disableManualTesting(); + // The guard runs before the validators, so a request that would otherwise be a 422 + // is still a 404 - the route does not exist rather than being malformed. + await adminAgent.get(`${baseUrl}manual-test-case?teamId=${team._id}`).expect(404); + await adminAgent.get(`${baseUrl}manual-test-case`).expect(404); + await adminAgent.get(`${baseUrl}manual-test-run`).expect(404); + await adminAgent.get(`${baseUrl}manual-folder`).expect(404); + await adminAgent.get(`${baseUrl}shared-step`).expect(404); + }); + + it('should block writes as well as reads once disabled', async () => { + await disableManualTesting(); + await adminAgent + .post(`${baseUrl}manual-test-case`) + .send({ title: 'should never be created' }) + .expect(404); + }); + + it('should leave unrelated routes untouched once disabled', async () => { + await disableManualTesting(); + await adminAgent.get(`${baseUrl}team`).expect(200); + }); + + it('should restore access when re-enabled', async () => { + await disableManualTesting(); + await adminAgent.get(`${baseUrl}manual-test-case?teamId=${team._id}`).expect(404); + + await adminAgent + .put(`${baseUrl}settings/features`) + .send({ manualTestingEnabled: true }) + .expect(200); + await adminAgent.get(`${baseUrl}manual-test-case?teamId=${team._id}`).expect(200); + }); + }); + + describe('Environment seeding', () => { + // config/feature.config.js reads the environment once at require time, so these set + // the parsed seed directly rather than process.env - the effect under test is what + // loadDoc does with a seed, not how the string was parsed. + const setSeed = (value) => { featureConfig.seeds.manualTestingEnabled = value; }; + + afterEach(() => { + setSeed(true); + }); + + // The parsing itself: only the exact string 'false' disables a feature, so an unset, + // empty or misspelled value can never accidentally turn one off. + it('should only treat the string "false" as disabled', () => { + const configPath = require.resolve('../config/feature.config.js'); + const original = process.env.ANGLES_MANUAL_TESTING_ENABLED; + + const seedFor = (value) => { + if (value === undefined) { + delete process.env.ANGLES_MANUAL_TESTING_ENABLED; + } else { + process.env.ANGLES_MANUAL_TESTING_ENABLED = value; + } + delete require.cache[configPath]; + // eslint-disable-next-line global-require + return require('../config/feature.config.js').seeds.manualTestingEnabled; + }; + + try { + seedFor('false').should.equal(false); + seedFor('true').should.equal(true); + seedFor(undefined).should.equal(true); + seedFor('').should.equal(true); + seedFor('FALSE').should.equal(true); + } finally { + if (original === undefined) { + delete process.env.ANGLES_MANUAL_TESTING_ENABLED; + } else { + process.env.ANGLES_MANUAL_TESTING_ENABLED = original; + } + // Restore the cached module every other test (and the running app) holds a + // reference to, so re-requiring here does not leave a second copy behind. + delete require.cache[configPath]; + } + }); + + it('should create the settings from the seed on first run', async () => { + await FeatureSettings.deleteMany({}).exec(); + setSeed(false); + + await featureSettingsService.loadFeatureSettings(); + + const res = await adminAgent.get(`${baseUrl}settings/features`).expect(200); + res.body.should.have.property('manualTestingEnabled', false); + }); + + it('should default to enabled when the seed is not set', async () => { + await FeatureSettings.deleteMany({}).exec(); + setSeed(true); + + await featureSettingsService.loadFeatureSettings(); + + const res = await adminAgent.get(`${baseUrl}settings/features`).expect(200); + res.body.should.have.property('manualTestingEnabled', true); + }); + + // The whole point of seed-only semantics: a restart must never revert an admin. + it('should not override a stored value the admin has already set', async () => { + await FeatureSettings.deleteMany({}).exec(); + setSeed(false); + await featureSettingsService.loadFeatureSettings(); + + // The admin turns it back on through the UI. + await adminAgent + .put(`${baseUrl}settings/features`) + .send({ manualTestingEnabled: true }) + .expect(200); + + // Restarting with the seed still set to false must leave their choice alone. + await featureSettingsService.loadFeatureSettings(); + + const res = await adminAgent.get(`${baseUrl}settings/features`).expect(200); + res.body.should.have.property('manualTestingEnabled', true); + featureConfig.manualTestingEnabled.should.equal(true); + }); + }); + + describe('GET /auth/config', () => { + it('should report the feature toggles so any client can gate its navigation', async () => { + const res = await request(app).get(`${baseUrl}auth/config`).expect(200); + res.body.should.have.property('features'); + res.body.features.should.have.property('manualTestingEnabled', true); + }); + + it('should report a disabled feature without needing a restart', async () => { + await adminAgent + .put(`${baseUrl}settings/features`) + .send({ manualTestingEnabled: false }) + .expect(200); + + const res = await request(app).get(`${baseUrl}auth/config`).expect(200); + res.body.features.should.have.property('manualTestingEnabled', false); + }); + }); +}); diff --git a/test/manual-folder.tests.js b/test/manual-folder.tests.js new file mode 100644 index 0000000..a6d4b31 --- /dev/null +++ b/test/manual-folder.tests.js @@ -0,0 +1,378 @@ +/** + * Tests for the manual test case folder tree. + * + * The load-bearing properties are structural: nesting has to survive a move (a folder + * dragged elsewhere takes its whole subtree with it), a folder must never end up inside + * its own subtree (which would strand the branch where no query starting at a root can + * reach it), and filing a case must not disturb its immutable version history. + */ +const request = require('supertest'); +const should = require('should'); +const bcrypt = require('bcryptjs'); +const app = require('../server.js'); +const User = require('../app/models/user.js'); +const ManualFolder = require('../app/models/manual-folder.js'); +const ManualTestCase = require('../app/models/manual-test-case.js'); +const ManualTestCaseVersion = require('../app/models/manual-test-case-version.js'); +const { Team } = require('../app/models/team.js'); + +const baseUrl = '/rest/api/v1.0/'; +const MEMBER_PASSWORD = 'unit-testing-MfMember1!'; +const LEAD_PASSWORD = 'unit-testing-MfLead1!'; + +describe('Manual Folder API Tests', () => { + let memberAgent; + let leadAgent; + let team; + let otherTeam; + + const login = (username, password) => new Promise((resolve, reject) => { + const agent = request.agent(app); + agent.post(`${baseUrl}auth/login`).send({ username, password }).end((err, res) => { + if (err) return reject(err); + if (res.status !== 200) return reject(new Error(`login failed: ${res.status}`)); + return resolve(agent); + }); + }); + + const send = (agent, method, url, body) => new Promise((resolve, reject) => { + const req = agent[method](`${baseUrl}${url}`).set('Accept', 'application/json'); + if (body !== undefined) req.send(body); + req.end((err, res) => (err ? reject(err) : resolve(res))); + }); + + const makeFolder = async (name, parent) => { + const res = await send(leadAgent, 'post', 'manual-folder', { + team: team._id.toString(), name, parent, + }); + should(res.status).equal(201); + return res.body; + }; + + before(async () => { + await Promise.all([ + User.deleteMany({ username: /^unit-testing-mf/ }).exec(), + Team.deleteMany({ name: /^unit-testing-mf/ }).exec(), + ManualFolder.deleteMany({ name: /^unit-testing-mf/ }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-mf/ }).exec(), + ]); + team = await new Team({ name: 'unit-testing-mf-team', components: [{ name: 'mf-c' }] }).save(); + otherTeam = await new Team({ name: 'unit-testing-mf-other', components: [{ name: 'mf-o' }] }).save(); + + const [memberHash, leadHash] = await Promise.all([ + bcrypt.hash(MEMBER_PASSWORD, 10), bcrypt.hash(LEAD_PASSWORD, 10), + ]); + await User.create({ + username: 'unit-testing-mf-member', password: memberHash, role: 'user', teams: [team._id], + }); + await User.create({ + username: 'unit-testing-mf-lead', password: leadHash, role: 'team_lead', teams: [team._id], + }); + memberAgent = await login('unit-testing-mf-member', MEMBER_PASSWORD); + leadAgent = await login('unit-testing-mf-lead', LEAD_PASSWORD); + }); + + after(async () => { + const cases = await ManualTestCase.find({ title: /^unit-testing-mf/ }).select('_id').lean(); + await Promise.all([ + ManualTestCaseVersion.deleteMany({ testCase: { $in: cases.map((c) => c._id) } }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-mf/ }).exec(), + ManualFolder.deleteMany({ team: { $in: [team._id, otherTeam._id] } }).exec(), + User.deleteMany({ username: /^unit-testing-mf/ }).exec(), + Team.deleteMany({ name: /^unit-testing-mf/ }).exec(), + ]); + }); + + describe('creating folders at any depth', () => { + it('creates a root folder with an empty path', async () => { + const root = await makeFolder('unit-testing-mf Checkout'); + should(root.depth).equal(0); + should(root.path).be.an.Array().and.have.length(0); + should(root.parent).be.exactly(null); + }); + + it('nests a folder under another', async () => { + const root = await makeFolder('unit-testing-mf Billing'); + const child = await makeFolder('Payments', root._id); + should(child.depth).equal(1); + should(child.path).have.length(1); + should(child.path[0]).equal(root._id); + }); + + it('nests as deep as asked', async () => { + let parent; + const created = []; + for (let level = 0; level < 6; level += 1) { + // eslint-disable-next-line no-await-in-loop + const folder = await makeFolder(`unit-testing-mf L${level}`, parent); + created.push(folder); + parent = folder._id; + } + should(created[5].depth).equal(5); + // The path is every ancestor, outermost first - that is what makes a subtree query + // a single lookup rather than one per level. + should(created[5].path).have.length(5); + should(created[5].path[0]).equal(created[0]._id); + should(created[5].path[4]).equal(created[4]._id); + }); + + it('rejects a duplicate name under the same parent', async () => { + const root = await makeFolder('unit-testing-mf Dupes'); + await makeFolder('Same', root._id); + const res = await send(leadAgent, 'post', 'manual-folder', { + team: team._id.toString(), name: 'Same', parent: root._id, + }); + should(res.status).equal(409); + }); + + it('allows the same name under a different parent', async () => { + const a = await makeFolder('unit-testing-mf BranchA'); + const b = await makeFolder('unit-testing-mf BranchB'); + await makeFolder('Shared name', a._id); + const res = await send(leadAgent, 'post', 'manual-folder', { + team: team._id.toString(), name: 'Shared name', parent: b._id, + }); + should(res.status).equal(201); + }); + + it('rejects a duplicate root name', async () => { + // Mongo treats every null as distinct in a unique index, so roots would escape the + // constraint without the explicit null default. + await makeFolder('unit-testing-mf UniqueRoot'); + const res = await send(leadAgent, 'post', 'manual-folder', { + team: team._id.toString(), name: 'unit-testing-mf UniqueRoot', + }); + should(res.status).equal(409); + }); + + it('respond with 404 for a parent in another team', async () => { + const foreign = await new ManualFolder({ team: otherTeam._id, name: 'foreign', depth: 0 }).save(); + const res = await send(leadAgent, 'post', 'manual-folder', { + team: team._id.toString(), name: 'unit-testing-mf x', parent: foreign._id.toString(), + }); + should(res.status).equal(404); + }); + + it('respond with 422 for a folder with no name', async () => { + const res = await send(leadAgent, 'post', 'manual-folder', { team: team._id.toString() }); + should(res.status).equal(422); + }); + }); + + describe('moving a folder', () => { + let outer; + let inner; + let leaf; + let target; + + before(async () => { + outer = await makeFolder('unit-testing-mf Outer'); + inner = await makeFolder('Inner', outer._id); + leaf = await makeFolder('Leaf', inner._id); + target = await makeFolder('unit-testing-mf Target'); + }); + + it('takes the whole subtree with it', async () => { + const res = await send(leadAgent, 'put', `manual-folder/${inner._id}`, { parent: target._id }); + should(res.status).equal(200); + const movedLeaf = await ManualFolder.findById(leaf._id).lean(); + // The leaf still sits under Inner, but its ancestors above Inner have changed. + should(movedLeaf.depth).equal(2); + should(movedLeaf.path.map((id) => id.toString())) + .deepEqual([target._id.toString(), inner._id.toString()]); + }); + + it('refuses to move a folder inside itself', async () => { + const res = await send(leadAgent, 'put', `manual-folder/${target._id}`, { parent: target._id }); + should(res.status).equal(400); + }); + + it('refuses to move a folder inside its own sub-folder', async () => { + // This is the one that would strand the branch: no query starting at a root could + // ever reach it again. + const res = await send(leadAgent, 'put', `manual-folder/${target._id}`, { parent: leaf._id }); + should(res.status).equal(400); + const unchanged = await ManualFolder.findById(target._id).lean(); + should(unchanged.depth).equal(0); + }); + + it('moves a folder back to the root', async () => { + const res = await send(leadAgent, 'put', `manual-folder/${inner._id}`, { parent: null }); + should(res.status).equal(200); + should(res.body.depth).equal(0); + const movedLeaf = await ManualFolder.findById(leaf._id).lean(); + should(movedLeaf.depth).equal(1); + }); + + it('renames without moving', async () => { + const res = await send(leadAgent, 'put', `manual-folder/${leaf._id}`, { name: 'Renamed leaf' }); + should(res.status).equal(200); + should(res.body.name).equal('Renamed leaf'); + should(res.body.depth).equal(1); + }); + }); + + describe('filing test cases', () => { + let folder; + let subFolder; + let testCase; + + before(async () => { + folder = await makeFolder('unit-testing-mf Filing'); + subFolder = await makeFolder('Deeper', folder._id); + const created = await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-mf filed case', + steps: [{ action: 'Do something' }], + }); + testCase = created.body; + }); + + it('creates an unfiled case at the root', async () => { + should(testCase.folder === null || testCase.folder === undefined).equal(true); + }); + + it('moves a case into a folder', async () => { + const res = await send(memberAgent, 'put', 'manual-folder/move', { + testCaseIds: [testCase._id], folder: folder._id, + }); + should(res.status).equal(200); + should(res.body.moved).equal(1); + const stored = await ManualTestCase.findById(testCase._id).lean(); + should(stored.folder.toString()).equal(folder._id); + }); + + it('does not burn a version when a case is filed', async () => { + // Filing is organisation, not content. A move that bumped the version would churn + // the history of every case whenever someone reorganised the tree. + const stored = await ManualTestCase.findById(testCase._id).lean(); + should(stored.version).equal(testCase.version); + const versions = await ManualTestCaseVersion.countDocuments({ testCase: testCase._id }); + should(versions).equal(1); + }); + + it('leaves no folder on the frozen version', async () => { + // An execution renders what was tested, not where the case has since been filed. + const frozen = await ManualTestCaseVersion.findOne({ testCase: testCase._id }).lean(); + should(frozen.folder).equal(undefined); + }); + + it('records the move in the change history', async () => { + const res = await send(memberAgent, 'get', `manual-test-case/${testCase._id}/history`); + should(res.status).equal(200); + const move = res.body.history.find((entry) => entry.action === 'MOVE'); + should(move).not.equal(undefined); + should(move.changedBy).not.equal(undefined); + }); + + it('lists cases in a folder', async () => { + const res = await send(memberAgent, 'get', `manual-test-case?teamId=${team._id}&folder=${folder._id}`); + should(res.status).equal(200); + should(res.body.testCases.map((c) => c._id)).containEql(testCase._id); + }); + + it('excludes sub-folder cases unless asked', async () => { + const nested = await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-mf nested case', + folder: subFolder._id, + steps: [{ action: 'Nested' }], + }); + should(nested.status).equal(201); + const shallow = await send(memberAgent, 'get', `manual-test-case?teamId=${team._id}&folder=${folder._id}`); + should(shallow.body.testCases.map((c) => c._id)).not.containEql(nested.body._id); + }); + + it('includes sub-folder cases when asked', async () => { + const deep = await send(memberAgent, 'get', `manual-test-case?teamId=${team._id}&folder=${folder._id}&includeSubFolders=true`); + should(deep.body.testCases.length).be.aboveOrEqual(2); + }); + + it('lists unfiled cases with folder=none', async () => { + const res = await send(memberAgent, 'get', `manual-test-case?teamId=${team._id}&folder=none`); + should(res.status).equal(200); + res.body.testCases.forEach((c) => { + should(c.folder === null || c.folder === undefined).equal(true); + }); + }); + + it('moves a case back to the root', async () => { + const res = await send(memberAgent, 'put', 'manual-folder/move', { + testCaseIds: [testCase._id], folder: null, + }); + should(res.status).equal(200); + const stored = await ManualTestCase.findById(testCase._id).lean(); + should(stored.folder).be.exactly(null); + }); + + it('respond with 404 for a folder in another team', async () => { + const foreign = await new ManualFolder({ team: otherTeam._id, name: 'foreign-file', depth: 0 }).save(); + const res = await send(memberAgent, 'put', 'manual-folder/move', { + testCaseIds: [testCase._id], folder: foreign._id.toString(), + }); + should(res.status).equal(404); + }); + }); + + describe('the folder tree', () => { + it('returns folders nested, with test case counts', async () => { + const res = await send(memberAgent, 'get', `manual-folder?teamId=${team._id}`); + should(res.status).equal(200); + should(res.body.folders).be.an.Array(); + const withChildren = res.body.folders.find((f) => f.children && f.children.length > 0); + should(withChildren).not.equal(undefined); + should(res.body.unfiledCount).be.a.Number(); + }); + + it('respond with 403 for a team the user is not in', async () => { + const res = await send(memberAgent, 'get', `manual-folder?teamId=${otherTeam._id}`); + should(res.status).equal(403); + }); + }); + + describe('deleting a folder', () => { + let parent; + let child; + let occupied; + + before(async () => { + parent = await makeFolder('unit-testing-mf DeleteParent'); + child = await makeFolder('Child', parent._id); + occupied = await makeFolder('unit-testing-mf Occupied'); + await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-mf blocks delete', + folder: occupied._id, + steps: [{ action: 'Blocking' }], + }); + }); + + it('respond with 409 when the folder holds sub-folders', async () => { + const res = await send(leadAgent, 'delete', `manual-folder/${parent._id}`); + should(res.status).equal(409); + should(res.body.message).match(/sub-folder/); + }); + + it('respond with 409 when the folder holds test cases', async () => { + const res = await send(leadAgent, 'delete', `manual-folder/${occupied._id}`); + should(res.status).equal(409); + should(res.body.message).match(/test case/); + }); + + it('respond with 403 when a plain member deletes', async () => { + const res = await send(memberAgent, 'delete', `manual-folder/${child._id}`); + should(res.status).equal(403); + }); + + it('deletes an empty folder', async () => { + const res = await send(leadAgent, 'delete', `manual-folder/${child._id}`); + should(res.status).equal(200); + should(await ManualFolder.findById(child._id).lean()).equal(null); + }); + + it('deletes the parent once it is empty', async () => { + const res = await send(leadAgent, 'delete', `manual-folder/${parent._id}`); + should(res.status).equal(200); + }); + }); +}); diff --git a/test/manual-history.tests.js b/test/manual-history.tests.js new file mode 100644 index 0000000..2916897 --- /dev/null +++ b/test/manual-history.tests.js @@ -0,0 +1,497 @@ +/** + * Tests for the manual test case change history. + * + * History is an audit trail, distinct from the version collection: a version is the + * content a tester saw, while a history entry says who changed what and why. The + * interesting cases are the ones the version collection cannot express - a status change + * that burns no version, and a case re-versioned by somebody else's shared step edit. + */ +const request = require('supertest'); +const should = require('should'); +const pino = require('pino'); +const bcrypt = require('bcryptjs'); +const app = require('../server.js'); +const testUtils = require('./test-utils.js'); +const User = require('../app/models/user.js'); +const ManualChangeHistory = require('../app/models/manual-change-history.js'); +const ManualTestCase = require('../app/models/manual-test-case.js'); +const ManualTestCaseVersion = require('../app/models/manual-test-case-version.js'); +const CustomFieldDefinition = require('../app/models/custom-field-definition.js'); +const SharedStep = require('../app/models/shared-step.js'); +const historyUtils = require('../app/utils/history-utils.js'); +const { Team } = require('../app/models/team.js'); + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); +const baseUrl = '/rest/api/v1.0/'; + +const MEMBER_PASSWORD = 'unit-testing-HiMember1!'; +const LEAD_PASSWORD = 'unit-testing-HiLead1!'; +const OUTSIDER_PASSWORD = 'unit-testing-HiOutsider1!'; + +// History is written without being awaited, so a read immediately after a mutation can +// race it. Polls briefly rather than sleeping a fixed amount. +const waitForHistory = async (entityId, expected, attempts = 40) => { + for (let i = 0; i < attempts; i += 1) { + // eslint-disable-next-line no-await-in-loop + const count = await ManualChangeHistory.countDocuments({ entityId }); + if (count >= expected) return count; + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { setTimeout(resolve, 25); }); + } + return ManualChangeHistory.countDocuments({ entityId }); +}; + +describe('Manual Change History Tests', () => { + let adminAgent; + let memberAgent; + let leadAgent; + let outsiderAgent; + let team; + let otherTeam; + let memberUser; + + const login = (username, password) => new Promise((resolve, reject) => { + const agent = request.agent(app); + agent + .post(`${baseUrl}auth/login`) + .send({ username, password }) + .end((err, res) => { + if (err) return reject(err); + if (res.status !== 200) return reject(new Error(`login failed for ${username}: ${res.status}`)); + return resolve(agent); + }); + }); + + const send = (agent, method, url, body) => new Promise((resolve, reject) => { + const req = agent[method](`${baseUrl}${url}`).set('Accept', 'application/json'); + if (body !== undefined) req.send(body); + req.end((err, res) => (err ? reject(err) : resolve(res))); + }); + + const createCase = (body) => send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-hi case', + steps: [{ action: 'Step one', expected: 'It works' }], + ...body, + }); + + before(async () => { + await Promise.all([ + User.deleteMany({ username: /^unit-testing-hi/ }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-hi/ }).exec(), + Team.deleteMany({ name: /^unit-testing-hi/ }).exec(), + ]); + logger.info('Cleared any lingering history fixtures'); + + team = await new Team({ name: 'unit-testing-hi-team', components: [{ name: 'hi-component' }] }).save(); + otherTeam = await new Team({ name: 'unit-testing-hi-other', components: [{ name: 'hi-other' }] }).save(); + + const [memberHash, leadHash, outsiderHash] = await Promise.all([ + bcrypt.hash(MEMBER_PASSWORD, 10), + bcrypt.hash(LEAD_PASSWORD, 10), + bcrypt.hash(OUTSIDER_PASSWORD, 10), + ]); + const users = await User.create([ + { + username: 'unit-testing-hi-member', password: memberHash, role: 'user', teams: [team._id], + }, + { + username: 'unit-testing-hi-lead', password: leadHash, role: 'team_lead', teams: [team._id], + }, + { + username: 'unit-testing-hi-outsider', password: outsiderHash, role: 'user', teams: [otherTeam._id], + }, + ]); + [memberUser] = users; + + [adminAgent, memberAgent, leadAgent, outsiderAgent] = await Promise.all([ + testUtils.getAdminAgent(), + login('unit-testing-hi-member', MEMBER_PASSWORD), + login('unit-testing-hi-lead', LEAD_PASSWORD), + login('unit-testing-hi-outsider', OUTSIDER_PASSWORD), + ]); + }); + + after(async () => { + const cases = await ManualTestCase.find({ title: /^unit-testing-hi/ }).select('_id').lean(); + await Promise.all([ + ManualChangeHistory.deleteMany({ team: { $in: [team._id, otherTeam._id] } }).exec(), + ManualTestCaseVersion.deleteMany({ testCase: { $in: cases.map((c) => c._id) } }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-hi/ }).exec(), + SharedStep.deleteMany({ team: { $in: [team._id, otherTeam._id] } }).exec(), + CustomFieldDefinition.deleteMany({ team: { $in: [team._id, otherTeam._id] } }).exec(), + User.deleteMany({ username: /^unit-testing-hi/ }).exec(), + Team.deleteMany({ name: /^unit-testing-hi/ }).exec(), + ]); + }); + + describe('diffDocuments', () => { + it('reports a scalar change as one entry', () => { + const changes = historyUtils.diffDocuments( + { title: 'before' }, + { title: 'after' }, + ['title'], + ); + should(changes).have.length(1); + should(changes[0]).eql({ field: 'title', from: 'before', to: 'after' }); + }); + + it('ignores fields absent from both sides', () => { + const changes = historyUtils.diffDocuments({}, {}, ['title', 'priority']); + should(changes).have.length(0); + }); + + it('breaks a steps change down per position rather than one blob', () => { + const changes = historyUtils.diffDocuments( + { steps: [{ action: 'a' }, { action: 'b' }] }, + { steps: [{ action: 'a' }, { action: 'CHANGED' }] }, + ['steps'], + ); + should(changes).have.length(1); + should(changes[0].field).equal('steps[1]'); + should(changes[0].from.action).equal('b'); + should(changes[0].to.action).equal('CHANGED'); + }); + + it('reports an added step', () => { + const changes = historyUtils.diffDocuments( + { steps: [{ action: 'a' }] }, + { steps: [{ action: 'a' }, { action: 'new' }] }, + ['steps'], + ); + should(changes).have.length(1); + should(changes[0].field).equal('steps[1]'); + should(changes[0].from).equal(null); + should(changes[0].to.action).equal('new'); + }); + + it('breaks a customFields change down per key', () => { + const changes = historyUtils.diffDocuments( + { customFields: { a: 1, b: 2 } }, + { customFields: { a: 1, b: 99 } }, + ['customFields'], + ); + should(changes).have.length(1); + should(changes[0].field).equal('customFields.b'); + should(changes[0].from).equal(2); + should(changes[0].to).equal(99); + }); + + it('does not report a step that only differs by an empty attachments array', () => { + // The same normalisation the version comparison relies on. + const changes = historyUtils.diffDocuments( + { steps: [{ action: 'a', attachments: [] }] }, + { steps: [{ action: 'a' }] }, + ['steps'], + ); + should(changes).have.length(0); + }); + }); + + describe('test case history', () => { + let testCase; + + it('records a CREATE entry naming the author', async () => { + const res = await createCase({ title: 'unit-testing-hi tracked case' }); + should(res.status).equal(201); + testCase = res.body; + + await waitForHistory(testCase._id, 1); + const history = await send(memberAgent, 'get', `manual-test-case/${testCase._id}/history`); + should(history.status).equal(200); + should(history.body.history).have.length(1); + should(history.body.history[0].action).equal('CREATE'); + should(history.body.history[0].version).equal(1); + should(history.body.history[0].changedBy.username).equal('unit-testing-hi-member'); + }); + + it('never exposes the user document beyond the username', async () => { + const history = await send(memberAgent, 'get', `manual-test-case/${testCase._id}/history`); + const [entry] = history.body.history; + should(entry.changedBy.apiTokens).equal(undefined); + should(entry.changedBy.password).equal(undefined); + should(Object.keys(entry.changedBy).sort()).eql(['_id', 'username']); + }); + + it('records an UPDATE with the field-level changes', async () => { + const res = await send(memberAgent, 'put', `manual-test-case/${testCase._id}`, { + title: 'unit-testing-hi tracked case renamed', + priority: 'CRITICAL', + comment: 'Sharpened the title', + }); + should(res.status).equal(200); + + await waitForHistory(testCase._id, 2); + const history = await send(memberAgent, 'get', `manual-test-case/${testCase._id}/history`); + const [entry] = history.body.history; + should(entry.action).equal('UPDATE'); + should(entry.version).equal(2); + should(entry.comment).equal('Sharpened the title'); + const fields = entry.changes.map((change) => change.field).sort(); + should(fields).eql(['priority', 'title']); + }); + + it('records a STATUS_CHANGE that burns no version', async () => { + const before = await ManualTestCase.findById(testCase._id).lean(); + const res = await send(memberAgent, 'put', `manual-test-case/${testCase._id}`, { + status: 'ACTIVE', + }); + should(res.status).equal(200); + // The version is deliberately unchanged - this is exactly what the version + // collection cannot record, and why history exists alongside it. + should(res.body.version).equal(before.version); + + await waitForHistory(testCase._id, 3); + const history = await send(memberAgent, 'get', `manual-test-case/${testCase._id}/history`); + const [entry] = history.body.history; + should(entry.action).equal('STATUS_CHANGE'); + should(entry.changes).have.length(1); + should(entry.changes[0].field).equal('status'); + should(entry.changes[0].from).equal('DRAFT'); + should(entry.changes[0].to).equal('ACTIVE'); + }); + + it('writes nothing for an update that changes nothing', async () => { + const before = await ManualChangeHistory.countDocuments({ entityId: testCase._id }); + const res = await send(memberAgent, 'put', `manual-test-case/${testCase._id}`, { + title: 'unit-testing-hi tracked case renamed', + }); + should(res.status).equal(200); + await new Promise((resolve) => { setTimeout(resolve, 150); }); + should(await ManualChangeHistory.countDocuments({ entityId: testCase._id })).equal(before); + }); + + it('records step changes per position', async () => { + const res = await send(memberAgent, 'put', `manual-test-case/${testCase._id}`, { + steps: [ + { action: 'Step one', expected: 'It works' }, + { action: 'Step two', expected: 'Also works' }, + ], + }); + should(res.status).equal(200); + + const history = await send(memberAgent, 'get', `manual-test-case/${testCase._id}/history`); + const [entry] = history.body.history; + should(entry.action).equal('UPDATE'); + should(entry.changes[0].field).equal('steps[1]'); + should(entry.changes[0].to.action).equal('Step two'); + }); + + it('records a CLONE against the new case', async () => { + const res = await send(memberAgent, 'post', `manual-test-case/${testCase._id}/clone`, { + title: 'unit-testing-hi cloned', + }); + should(res.status).equal(201); + + await waitForHistory(res.body._id, 1); + const history = await send(memberAgent, 'get', `manual-test-case/${res.body._id}/history`); + should(history.body.history).have.length(1); + should(history.body.history[0].action).equal('CLONE'); + should(history.body.history[0].comment).match(new RegExp(testCase._id)); + }); + + it('paginates, newest first', async () => { + const history = await send(memberAgent, 'get', `manual-test-case/${testCase._id}/history?limit=2`); + should(history.body.history).have.length(2); + should(history.body.metrics.totalEntries).be.above(2); + const [first, second] = history.body.history; + should(new Date(first.changedAt) >= new Date(second.changedAt)).equal(true); + }); + + it('respond with 403 for a user outside the team', async () => { + const res = await send(outsiderAgent, 'get', `manual-test-case/${testCase._id}/history`); + should(res.status).equal(403); + }); + + it('respond with 404 for an unknown case', async () => { + const res = await send(memberAgent, 'get', 'manual-test-case/5f7e2b9e8f1b2c0017a1b2c3/history'); + should(res.status).equal(404); + }); + + it('records a DELETE that outlives the case', async () => { + const created = await createCase({ title: 'unit-testing-hi doomed' }); + const doomed = created.body; + + const deleted = await send(leadAgent, 'delete', `manual-test-case/${doomed._id}`); + should(deleted.status).equal(200); + + await waitForHistory(doomed._id, 2); + // The case is gone, so the entry is read directly - "what happened to that test + // case?" is only answerable because the deletion itself was logged. + const entries = await ManualChangeHistory + .find({ entityId: doomed._id }) + .sort('-changedAt') + .lean(); + should(entries[0].action).equal('DELETE'); + should(entries[0].comment).match(/unit-testing-hi doomed/); + }); + }); + + describe('shared step history', () => { + let sharedStep; + let referencingCase; + + before(async () => { + const created = await send(leadAgent, 'post', 'shared-step', { + team: team._id.toString(), + name: 'unit-testing-hi login', + steps: [{ action: 'Log in', expected: 'Dashboard shown' }], + }); + sharedStep = created.body; + + const caseRes = await createCase({ + title: 'unit-testing-hi case including shared step', + steps: [{ sharedStep: sharedStep._id, action: 'placeholder' }], + }); + referencingCase = caseRes.body; + }); + + it('records a CREATE for the shared step', async () => { + await waitForHistory(sharedStep._id, 1); + const history = await send(leadAgent, 'get', `shared-step/${sharedStep._id}/history`); + should(history.status).equal(200); + should(history.body.history[0].action).equal('CREATE'); + should(history.body.history[0].changedBy.username).equal('unit-testing-hi-lead'); + }); + + it('records an UPDATE on the shared step itself', async () => { + const res = await send(leadAgent, 'put', `shared-step/${sharedStep._id}`, { + steps: [ + { action: 'Log in', expected: 'Dashboard shown' }, + { action: 'Dismiss the banner', expected: 'Banner gone' }, + ], + comment: 'Cookie banner appeared', + }); + should(res.status).equal(200); + + await waitForHistory(sharedStep._id, 2); + const history = await send(leadAgent, 'get', `shared-step/${sharedStep._id}/history`); + const [entry] = history.body.history; + should(entry.action).equal('UPDATE'); + should(entry.version).equal(2); + should(entry.comment).equal('Cookie banner appeared'); + should(entry.changes[0].field).equal('steps[1]'); + }); + + it('records SHARED_STEP_UPDATE against each cascaded case, naming the cause', async () => { + await waitForHistory(referencingCase._id, 2); + const history = await send(memberAgent, 'get', `manual-test-case/${referencingCase._id}/history`); + const [entry] = history.body.history; + // Nobody edited this case directly - without the entry its version bump would be + // entirely unexplained. + should(entry.action).equal('SHARED_STEP_UPDATE'); + should(entry.version).equal(2); + should(entry.causedBy.name).equal('unit-testing-hi login'); + should(entry.comment).match(/shared step "unit-testing-hi login" was updated to v2/); + }); + + it('respond with 403 for a user outside the team', async () => { + const res = await send(outsiderAgent, 'get', `shared-step/${sharedStep._id}/history`); + should(res.status).equal(403); + }); + }); + + describe('custom field history', () => { + let field; + + it('records a CREATE', async () => { + const res = await send(adminAgent, 'post', 'custom-field', { + team: team._id.toString(), + key: 'hi_reference', + label: 'Reference', + type: 'text', + }); + should(res.status).equal(201); + field = res.body; + + await waitForHistory(field._id, 1); + const entries = await ManualChangeHistory.find({ entityId: field._id }).lean(); + should(entries[0].action).equal('CREATE'); + should(entries[0].entityType).equal('customfield'); + }); + + it('records an UPDATE when the label changes', async () => { + const res = await send(adminAgent, 'put', `custom-field/${field._id}`, { + label: 'Ticket Reference', + }); + should(res.status).equal(200); + + await waitForHistory(field._id, 2); + const entries = await ManualChangeHistory + .find({ entityId: field._id }) + .sort('-changedAt') + .lean(); + should(entries[0].action).equal('UPDATE'); + should(entries[0].changes[0].field).equal('label'); + should(entries[0].changes[0].from).equal('Reference'); + should(entries[0].changes[0].to).equal('Ticket Reference'); + }); + + it('records an ARCHIVE rather than a DELETE for a field in use', async () => { + const used = await createCase({ + title: 'unit-testing-hi case using the field', + customFields: { hi_reference: 'JIRA-1' }, + }); + should(used.status).equal(201); + + const res = await send(adminAgent, 'delete', `custom-field/${field._id}`); + should(res.status).equal(200); + should(res.body.archived).equal(true); + + await waitForHistory(field._id, 3); + const entries = await ManualChangeHistory + .find({ entityId: field._id }) + .sort('-changedAt') + .lean(); + should(entries[0].action).equal('ARCHIVE'); + should(entries[0].comment).match(/in use by/); + }); + + it('records a DELETE for a field that was never used', async () => { + const created = await send(adminAgent, 'post', 'custom-field', { + team: team._id.toString(), + key: 'hi_unused', + label: 'Unused', + type: 'text', + }); + should(created.status).equal(201); + + const res = await send(adminAgent, 'delete', `custom-field/${created.body._id}`); + should(res.status).equal(200); + should(res.body.archived).equal(false); + + await waitForHistory(created.body._id, 2); + const entries = await ManualChangeHistory + .find({ entityId: created.body._id }) + .sort('-changedAt') + .lean(); + should(entries[0].action).equal('DELETE'); + }); + }); + + describe('history write failures do not fail the request', () => { + it('still saves the case when the history write throws', async () => { + const original = ManualChangeHistory.prototype.save; + ManualChangeHistory.prototype.save = () => Promise.reject(new Error('history is down')); + try { + const res = await createCase({ title: 'unit-testing-hi history down' }); + // The change is what the user asked for; the audit line is not worth failing it. + should(res.status).equal(201); + const saved = await ManualTestCase.findById(res.body._id).lean(); + should(saved.title).equal('unit-testing-hi history down'); + } finally { + ManualChangeHistory.prototype.save = original; + } + }); + }); + + describe('history is scoped to its team', () => { + it('does not return another team\'s entries', async () => { + const mine = await ManualChangeHistory.countDocuments({ team: team._id }); + const theirs = await ManualChangeHistory.countDocuments({ team: otherTeam._id }); + should(mine).be.above(0); + should(theirs).equal(0); + should(memberUser.teams[0].toString()).equal(team._id.toString()); + }); + }); +}); diff --git a/test/manual-test-case.tests.js b/test/manual-test-case.tests.js new file mode 100644 index 0000000..75fe05f --- /dev/null +++ b/test/manual-test-case.tests.js @@ -0,0 +1,519 @@ +/** + * Tests for manual test case CRUD and the immutable version history behind it. + * + * The versioning assertions are the important ones: a regression there is silent - nothing + * errors, history just quietly starts showing the wrong content - so they check the frozen + * content directly rather than only that a version number moved. + */ +const request = require('supertest'); +const should = require('should'); +const pino = require('pino'); +const bcrypt = require('bcryptjs'); +const app = require('../server.js'); +const testUtils = require('./test-utils.js'); +const User = require('../app/models/user.js'); +const ManualTestCase = require('../app/models/manual-test-case.js'); +const ManualTestCaseVersion = require('../app/models/manual-test-case-version.js'); +const { Team } = require('../app/models/team.js'); + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); +const baseUrl = '/rest/api/v1.0/'; + +const MEMBER_PASSWORD = 'unit-testing-McMember1!'; +const OUTSIDER_PASSWORD = 'unit-testing-McOutsider1!'; +const LEAD_PASSWORD = 'unit-testing-McLead1!'; + +describe('Manual Test Case API Tests', () => { + let adminAgent; // admin agent + let memberAgent; // plain user in the owning team + let outsiderAgent; // plain user in a different team + let leadAgent; // team_lead in the owning team (may delete) + let team; + let otherTeam; + let createdCase; + + const login = (username, password) => new Promise((resolve, reject) => { + const agent = request.agent(app); + agent + .post(`${baseUrl}auth/login`) + .send({ username, password }) + .end((err, res) => { + if (err) return reject(err); + if (res.status !== 200) return reject(new Error(`login failed for ${username}: ${res.status}`)); + return resolve(agent); + }); + }); + + const validCase = (overrides = {}) => ({ + team: team._id.toString(), + title: 'unit-testing-mc login with valid credentials', + description: 'Verifies a user can log in.', + preconditions: 'A registered account exists.', + priority: 'HIGH', + tags: ['login', 'smoke'], + steps: [ + { action: 'Navigate to the login page', expected: 'The login form is displayed' }, + { action: 'Enter valid credentials and submit', expected: 'The dashboard is displayed' }, + ], + ...overrides, + }); + + before(async () => { + await Promise.all([ + User.deleteMany({ username: /^unit-testing-mc/ }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-mc/ }).exec(), + Team.deleteMany({ name: /^unit-testing-mc/ }).exec(), + ]); + logger.info('Cleared any lingering manual test case fixtures'); + + team = await new Team({ + name: 'unit-testing-mc-team', + components: [{ name: 'mc-component' }], + }).save(); + otherTeam = await new Team({ + name: 'unit-testing-mc-other', + components: [{ name: 'mc-other-component' }], + }).save(); + + const [memberHash, outsiderHash, leadHash] = await Promise.all([ + bcrypt.hash(MEMBER_PASSWORD, 10), + bcrypt.hash(OUTSIDER_PASSWORD, 10), + bcrypt.hash(LEAD_PASSWORD, 10), + ]); + await User.create([ + { + username: 'unit-testing-mc-member', password: memberHash, role: 'user', teams: [team._id], + }, + { + username: 'unit-testing-mc-outsider', password: outsiderHash, role: 'user', teams: [otherTeam._id], + }, + { + username: 'unit-testing-mc-lead', password: leadHash, role: 'team_lead', teams: [team._id], + }, + ]); + + [adminAgent, memberAgent, outsiderAgent, leadAgent] = await Promise.all([ + testUtils.getAdminAgent(), + login('unit-testing-mc-member', MEMBER_PASSWORD), + login('unit-testing-mc-outsider', OUTSIDER_PASSWORD), + login('unit-testing-mc-lead', LEAD_PASSWORD), + ]); + }); + + after(async () => { + const cases = await ManualTestCase.find({ title: /^unit-testing-mc/ }).select('_id').lean(); + await Promise.all([ + ManualTestCaseVersion.deleteMany({ + testCase: { $in: cases.map((c) => c._id) }, + }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-mc/ }).exec(), + User.deleteMany({ username: /^unit-testing-mc/ }).exec(), + Team.deleteMany({ name: /^unit-testing-mc/ }).exec(), + ]); + }); + + describe('POST /manual-test-case', () => { + it('respond with 201 when creating a valid test case', (done) => { + adminAgent + .post(`${baseUrl}manual-test-case`) + .send(validCase()) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) throw err; + createdCase = res.body; + should(res.body.title).equal('unit-testing-mc login with valid credentials'); + should(res.body.status).equal('DRAFT'); + should(res.body.priority).equal('HIGH'); + should(res.body.version).equal(1); + should(res.body.steps).have.length(2); + done(); + }); + }); + + it('assigns sequential order values to steps that omit them', (done) => { + adminAgent + .get(`${baseUrl}manual-test-case/${createdCase._id}`) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) throw err; + should(res.body.steps[0].order).equal(1); + should(res.body.steps[1].order).equal(2); + done(); + }); + }); + + it('writes an immutable version 1 alongside the case', async () => { + const versions = await ManualTestCaseVersion + .find({ testCase: createdCase._id }) + .lean(); + should(versions).have.length(1); + should(versions[0].version).equal(1); + should(versions[0].title).equal('unit-testing-mc login with valid credentials'); + should(versions[0].steps).have.length(2); + }); + + it('respond with 422 when creating a test case with an empty body', (done) => { + adminAgent + .post(`${baseUrl}manual-test-case`) + .send({}) + .set('Accept', 'application/json') + .expect(422, done); + }); + + it('respond with 422 when a step has no action', (done) => { + adminAgent + .post(`${baseUrl}manual-test-case`) + .send(validCase({ steps: [{ expected: 'something happens' }] })) + .set('Accept', 'application/json') + .expect(422, done); + }); + + it('respond with 422 when the priority is not a known value', (done) => { + adminAgent + .post(`${baseUrl}manual-test-case`) + .send(validCase({ priority: 'URGENT' })) + .set('Accept', 'application/json') + .expect(422, done); + }); + + it('respond with 404 when the team does not exist', (done) => { + adminAgent + .post(`${baseUrl}manual-test-case`) + .send(validCase({ team: '5f7e2b9e8f1b2c0017a1b2c3' })) + .set('Accept', 'application/json') + .expect(404, done); + }); + + it('respond with 403 when the user has no access to the team', (done) => { + outsiderAgent + .post(`${baseUrl}manual-test-case`) + .send(validCase()) + .set('Accept', 'application/json') + .expect(403, done); + }); + }); + + describe('GET /manual-test-case', () => { + it('respond with the team\'s test cases and a total count', (done) => { + memberAgent + .get(`${baseUrl}manual-test-case?teamId=${team._id}`) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) throw err; + should(res.body.testCases.length).be.above(0); + should(res.body.metrics.totalTestCases).be.above(0); + done(); + }); + }); + + it('filters by status', (done) => { + memberAgent + .get(`${baseUrl}manual-test-case?teamId=${team._id}&status=ACTIVE`) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) throw err; + res.body.testCases.forEach((testCase) => should(testCase.status).equal('ACTIVE')); + done(); + }); + }); + + it('filters by search term', (done) => { + memberAgent + .get(`${baseUrl}manual-test-case?teamId=${team._id}&search=valid credentials`) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) throw err; + should(res.body.testCases.length).be.above(0); + done(); + }); + }); + + it('treats a regex metacharacter in the search term as a literal', (done) => { + // An unescaped "(a+)+$" would be evaluated as a pattern by the database. + memberAgent + .get(`${baseUrl}manual-test-case?teamId=${team._id}&search=${encodeURIComponent('(a+)+$')}`) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) throw err; + should(res.body.testCases).have.length(0); + done(); + }); + }); + + it('respond with 403 when the user has no access to the team', (done) => { + outsiderAgent + .get(`${baseUrl}manual-test-case?teamId=${team._id}`) + .set('Accept', 'application/json') + .expect(403, done); + }); + + it('respond with 422 when teamId is missing', (done) => { + memberAgent + .get(`${baseUrl}manual-test-case`) + .set('Accept', 'application/json') + .expect(422, done); + }); + }); + + describe('GET /manual-test-case/:caseId', () => { + it('respond with the test case', (done) => { + memberAgent + .get(`${baseUrl}manual-test-case/${createdCase._id}`) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) throw err; + should(res.body._id).equal(createdCase._id); + done(); + }); + }); + + it('respond with 403 for a user outside the owning team', (done) => { + outsiderAgent + .get(`${baseUrl}manual-test-case/${createdCase._id}`) + .set('Accept', 'application/json') + .expect(403, done); + }); + + it('respond with 404 for an unknown id', (done) => { + memberAgent + .get(`${baseUrl}manual-test-case/5f7e2b9e8f1b2c0017a1b2c3`) + .set('Accept', 'application/json') + .expect(404, done); + }); + }); + + describe('PUT /manual-test-case/:caseId', () => { + it('bumps the version and freezes a new one when content changes', (done) => { + memberAgent + .put(`${baseUrl}manual-test-case/${createdCase._id}`) + .send({ title: 'unit-testing-mc login with valid credentials v2' }) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) throw err; + should(res.body.version).equal(2); + should(res.body.title).equal('unit-testing-mc login with valid credentials v2'); + done(); + }); + }); + + it('leaves version 1 showing the original content', async () => { + const version1 = await ManualTestCaseVersion + .findOne({ testCase: createdCase._id, version: 1 }) + .lean(); + should(version1.title).equal('unit-testing-mc login with valid credentials'); + }); + + it('does not bump the version when nothing changed', (done) => { + memberAgent + .put(`${baseUrl}manual-test-case/${createdCase._id}`) + .send({ title: 'unit-testing-mc login with valid credentials v2' }) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) throw err; + should(res.body.version).equal(2); + done(); + }); + }); + + it('does not bump the version for a status-only change', (done) => { + memberAgent + .put(`${baseUrl}manual-test-case/${createdCase._id}`) + .send({ status: 'ACTIVE' }) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) throw err; + should(res.body.status).equal('ACTIVE'); + should(res.body.version).equal(2); + done(); + }); + }); + + it('bumps the version when steps change', (done) => { + memberAgent + .put(`${baseUrl}manual-test-case/${createdCase._id}`) + .send({ + steps: [ + { action: 'Navigate to the login page', expected: 'The login form is displayed' }, + { action: 'Enter valid credentials and submit', expected: 'The dashboard is displayed' }, + { action: 'Log out', expected: 'The login form is displayed again' }, + ], + }) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) throw err; + should(res.body.version).equal(3); + should(res.body.steps).have.length(3); + done(); + }); + }); + + it('leaves the earlier versions\' steps untouched', async () => { + const versions = await ManualTestCaseVersion + .find({ testCase: createdCase._id }) + .sort('version') + .lean(); + should(versions).have.length(3); + should(versions[0].steps).have.length(2); + should(versions[1].steps).have.length(2); + should(versions[2].steps).have.length(3); + }); + + it('respond with 403 for a user outside the owning team', (done) => { + outsiderAgent + .put(`${baseUrl}manual-test-case/${createdCase._id}`) + .send({ title: 'unit-testing-mc hijacked' }) + .set('Accept', 'application/json') + .expect(403, done); + }); + }); + + describe('GET /manual-test-case/:caseId/version', () => { + it('lists every version, newest first', (done) => { + memberAgent + .get(`${baseUrl}manual-test-case/${createdCase._id}/version`) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) throw err; + should(res.body.versions).have.length(3); + should(res.body.versions[0].version).equal(3); + done(); + }); + }); + + it('serves the frozen content of a specific version', (done) => { + memberAgent + .get(`${baseUrl}manual-test-case/${createdCase._id}/version/1`) + .set('Accept', 'application/json') + .expect(200) + .end((err, res) => { + if (err) throw err; + should(res.body.version).equal(1); + should(res.body.title).equal('unit-testing-mc login with valid credentials'); + should(res.body.steps).have.length(2); + done(); + }); + }); + + it('respond with 404 for a version that does not exist', (done) => { + memberAgent + .get(`${baseUrl}manual-test-case/${createdCase._id}/version/99`) + .set('Accept', 'application/json') + .expect(404, done); + }); + + it('respond with 403 for a user outside the owning team', (done) => { + outsiderAgent + .get(`${baseUrl}manual-test-case/${createdCase._id}/version/1`) + .set('Accept', 'application/json') + .expect(403, done); + }); + }); + + describe('POST /manual-test-case/:caseId/clone', () => { + let clonedCase; + + it('clones a test case as a new draft starting at version 1', (done) => { + memberAgent + .post(`${baseUrl}manual-test-case/${createdCase._id}/clone`) + .send({ title: 'unit-testing-mc cloned case' }) + .set('Accept', 'application/json') + .expect(201) + .end((err, res) => { + if (err) throw err; + clonedCase = res.body; + should(res.body.title).equal('unit-testing-mc cloned case'); + should(res.body.status).equal('DRAFT'); + should(res.body.version).equal(1); + should(res.body.steps).have.length(3); + should(res.body._id).not.equal(createdCase._id); + done(); + }); + }); + + it('gives the clone its own version history', async () => { + const versions = await ManualTestCaseVersion + .find({ testCase: clonedCase._id }) + .lean(); + should(versions).have.length(1); + should(versions[0].version).equal(1); + }); + + it('defaults the title when none is supplied', (done) => { + memberAgent + .post(`${baseUrl}manual-test-case/${createdCase._id}/clone`) + .send({}) + .set('Accept', 'application/json') + .expect(201) + .end((err, res) => { + if (err) throw err; + should(res.body.title).endWith('(copy)'); + done(); + }); + }); + }); + + describe('DELETE /manual-test-case/:caseId', () => { + let deletableCase; + + beforeEach((done) => { + leadAgent + .post(`${baseUrl}manual-test-case`) + .send(validCase({ title: 'unit-testing-mc deletable case' })) + .set('Accept', 'application/json') + .expect(201) + .end((err, res) => { + if (err) throw err; + deletableCase = res.body; + done(); + }); + }); + + it('respond with 403 when a plain team member tries to delete', (done) => { + memberAgent + .delete(`${baseUrl}manual-test-case/${deletableCase._id}`) + .set('Accept', 'application/json') + .expect(403, done); + }); + + it('respond with 403 for a user outside the owning team', (done) => { + outsiderAgent + .delete(`${baseUrl}manual-test-case/${deletableCase._id}`) + .set('Accept', 'application/json') + .expect(403, done); + }); + + it('respond with 200 when a team lead deletes, removing its versions', async () => { + await new Promise((resolve, reject) => { + leadAgent + .delete(`${baseUrl}manual-test-case/${deletableCase._id}`) + .set('Accept', 'application/json') + .expect(200) + .end((err) => (err ? reject(err) : resolve())); + }); + const versions = await ManualTestCaseVersion + .countDocuments({ testCase: deletableCase._id }); + should(versions).equal(0); + }); + + it('respond with 404 for an unknown id', (done) => { + leadAgent + .delete(`${baseUrl}manual-test-case/5f7e2b9e8f1b2c0017a1b2c3`) + .set('Accept', 'application/json') + .expect(404, done); + }); + }); +}); diff --git a/test/manual-test-run.tests.js b/test/manual-test-run.tests.js new file mode 100644 index 0000000..6f623e9 --- /dev/null +++ b/test/manual-test-run.tests.js @@ -0,0 +1,508 @@ +/** + * Tests for manual test runs. + * + * The load-bearing behaviour is the join with the automated model: a run creates a real + * Build tagged executionType 'manual', and results are written as TestExecution documents + * through the same buildMetricsUtils path automated results take. If that drifts, manual + * results stop appearing on the dashboard - or appear with the wrong totals. + * + * Also covers the version binding: a case edited mid-run must not shift under the tester. + */ +const request = require('supertest'); +const should = require('should'); +const pino = require('pino'); +const bcrypt = require('bcryptjs'); +const app = require('../server.js'); +const testUtils = require('./test-utils.js'); +const User = require('../app/models/user.js'); +const Build = require('../app/models/build.js'); +const TestExecution = require('../app/models/execution.js'); +const ManualTestRun = require('../app/models/manual-test-run.js'); +const ManualTestCase = require('../app/models/manual-test-case.js'); +const ManualTestCaseVersion = require('../app/models/manual-test-case-version.js'); +const ManualChangeHistory = require('../app/models/manual-change-history.js'); +const Environment = require('../app/models/environment.js'); +const { Team } = require('../app/models/team.js'); + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); +const baseUrl = '/rest/api/v1.0/'; + +const MEMBER_PASSWORD = 'unit-testing-MrMember1!'; +const LEAD_PASSWORD = 'unit-testing-MrLead1!'; +const OUTSIDER_PASSWORD = 'unit-testing-MrOutsider1!'; + +describe('Manual Test Run API Tests', () => { + let memberAgent; + let leadAgent; + let outsiderAgent; + let team; + let otherTeam; + let environment; + let caseA; + let caseB; + + const login = (username, password) => new Promise((resolve, reject) => { + const agent = request.agent(app); + agent + .post(`${baseUrl}auth/login`) + .send({ username, password }) + .end((err, res) => { + if (err) return reject(err); + if (res.status !== 200) return reject(new Error(`login failed for ${username}: ${res.status}`)); + return resolve(agent); + }); + }); + + const send = (agent, method, url, body) => new Promise((resolve, reject) => { + const req = agent[method](`${baseUrl}${url}`).set('Accept', 'application/json'); + if (body !== undefined) req.send(body); + req.end((err, res) => (err ? reject(err) : resolve(res))); + }); + + const createCase = (title, extra = {}) => send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title, + status: 'ACTIVE', + steps: [ + { action: 'Open the page', expected: 'Page loads' }, + { action: 'Click submit', expected: 'Form submits' }, + ], + ...extra, + }); + + const createRun = (agent, body) => send(agent, 'post', 'manual-test-run', { + name: 'unit-testing-mr regression run', + team: team._id.toString(), + environment: environment.name, + testCaseIds: [caseA._id], + ...body, + }); + + before(async () => { + await Promise.all([ + User.deleteMany({ username: /^unit-testing-mr/ }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-mr/ }).exec(), + ManualTestRun.deleteMany({ name: /^unit-testing-mr/ }).exec(), + Team.deleteMany({ name: /^unit-testing-mr/ }).exec(), + Environment.deleteMany({ name: /^unit-testing-mr/ }).exec(), + ]); + logger.info('Cleared any lingering manual run fixtures'); + + team = await new Team({ name: 'unit-testing-mr-team', components: [{ name: 'mr-component' }] }).save(); + otherTeam = await new Team({ name: 'unit-testing-mr-other', components: [{ name: 'mr-other' }] }).save(); + environment = await new Environment({ name: 'unit-testing-mr-env' }).save(); + + const [memberHash, leadHash, outsiderHash] = await Promise.all([ + bcrypt.hash(MEMBER_PASSWORD, 10), + bcrypt.hash(LEAD_PASSWORD, 10), + bcrypt.hash(OUTSIDER_PASSWORD, 10), + ]); + await User.create([ + { + username: 'unit-testing-mr-member', password: memberHash, role: 'user', teams: [team._id], + }, + { + username: 'unit-testing-mr-lead', password: leadHash, role: 'team_lead', teams: [team._id], + }, + { + username: 'unit-testing-mr-outsider', password: outsiderHash, role: 'user', teams: [otherTeam._id], + }, + ]); + + [memberAgent, leadAgent, outsiderAgent] = await Promise.all([ + login('unit-testing-mr-member', MEMBER_PASSWORD), + login('unit-testing-mr-lead', LEAD_PASSWORD), + login('unit-testing-mr-outsider', OUTSIDER_PASSWORD), + ]); + await testUtils.getAdminAgent(); + + caseA = (await createCase('unit-testing-mr case A')).body; + caseB = (await createCase('unit-testing-mr case B')).body; + }); + + after(async () => { + const runs = await ManualTestRun.find({ name: /^unit-testing-mr/ }).lean(); + const buildIds = runs.map((run) => run.build).filter(Boolean); + const cases = await ManualTestCase.find({ title: /^unit-testing-mr/ }).select('_id').lean(); + await Promise.all([ + TestExecution.deleteMany({ build: { $in: buildIds } }).exec(), + Build.deleteMany({ _id: { $in: buildIds } }).exec(), + ManualTestRun.deleteMany({ name: /^unit-testing-mr/ }).exec(), + ManualChangeHistory.deleteMany({ team: { $in: [team._id, otherTeam._id] } }).exec(), + ManualTestCaseVersion.deleteMany({ testCase: { $in: cases.map((c) => c._id) } }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-mr/ }).exec(), + User.deleteMany({ username: /^unit-testing-mr/ }).exec(), + Team.deleteMany({ name: /^unit-testing-mr/ }).exec(), + Environment.deleteMany({ name: /^unit-testing-mr/ }).exec(), + ]); + }); + + describe('POST /manual-test-run', () => { + let run; + + it('creates a run bound to each case\'s current version', async () => { + const res = await createRun(memberAgent, { testCaseIds: [caseA._id, caseB._id] }); + should(res.status).equal(201); + run = res.body; + should(run.status).equal('PLANNED'); + should(run.testCases).have.length(2); + should(run.testCases[0].versionNumber).equal(1); + should(run.testCases[0].status).equal('NOT_RUN'); + should(run.testCases[0].testCaseVersion).not.equal(undefined); + }); + + it('creates a backing build tagged as manual', async () => { + should(run.build).not.equal(undefined); + const build = await Build.findById(run.build).lean(); + should(build.executionType).equal('manual'); + should(build.team.toString()).equal(team._id.toString()); + should(build.suites).have.length(0); + }); + + it('captures platform details the same way an automated run does', async () => { + const res = await createRun(memberAgent, { + name: 'unit-testing-mr platform run', + platforms: [{ + platformName: 'iOS', + platformVersion: '17.2', + deviceName: 'iPhone 15', + screenWidth: 1179, + screenHeight: 2556, + }], + }); + should(res.status).equal(201); + should(res.body.platforms).have.length(1); + should(res.body.platforms[0].platformName).equal('ios'); + should(res.body.platforms[0].deviceName).equal('iphone 15'); + should(res.body.platforms[0].screenWidth).equal(1179); + }); + + it('respond with 400 for a deprecated test case', async () => { + const deprecated = await createCase('unit-testing-mr deprecated case'); + await send(memberAgent, 'put', `manual-test-case/${deprecated.body._id}`, { status: 'DEPRECATED' }); + + const res = await createRun(memberAgent, { testCaseIds: [deprecated.body._id] }); + should(res.status).equal(400); + should(res.body.message).match(/deprecated/i); + }); + + it('respond with 404 for a case belonging to another team', async () => { + const foreign = await send(outsiderAgent, 'post', 'manual-test-case', { + team: otherTeam._id.toString(), + title: 'unit-testing-mr foreign case', + steps: [{ action: 'x' }], + }); + const res = await createRun(memberAgent, { testCaseIds: [foreign.body._id] }); + should(res.status).equal(404); + }); + + it('respond with 404 for an unknown environment', async () => { + const res = await createRun(memberAgent, { environment: 'unit-testing-mr-nope' }); + should(res.status).equal(404); + }); + + it('respond with 403 for a user outside the team', async () => { + const res = await createRun(outsiderAgent, {}); + should(res.status).equal(403); + }); + + it('respond with 422 when the name is missing', async () => { + const res = await send(memberAgent, 'post', 'manual-test-run', { + team: team._id.toString(), + environment: environment.name, + }); + should(res.status).equal(422); + }); + + it('leaves no orphan build behind when run creation fails', async () => { + const before = await Build.countDocuments({ team: team._id, executionType: 'manual' }); + const res = await createRun(memberAgent, { testCaseIds: ['5f7e2b9e8f1b2c0017a1b2c3'] }); + should(res.status).equal(404); + const after = await Build.countDocuments({ team: team._id, executionType: 'manual' }); + should(after).equal(before); + }); + }); + + describe('recording results', () => { + let run; + + beforeEach(async () => { + const res = await createRun(memberAgent, { + name: 'unit-testing-mr result run', + testCaseIds: [caseA._id, caseB._id], + }); + run = res.body; + }); + + it('writes a manual TestExecution and folds it into the build', async () => { + const version = await ManualTestCaseVersion + .findById(run.testCases[0].testCaseVersion) + .lean(); + + const res = await send(memberAgent, 'put', `manual-test-run/${run._id}/test-case/${caseA._id}/result`, { + status: 'PASS', + stepResults: version.steps.map((step) => ({ + stepId: step._id.toString(), + status: 'PASS', + actual: 'As expected', + })), + }); + should(res.status).equal(200); + should(res.body.status).equal('IN_PROGRESS'); + + const runCase = res.body.testCases.find((c) => c.testCase === caseA._id); + should(runCase.status).equal('PASS'); + should(runCase.execution).not.equal(undefined); + + const execution = await TestExecution.findById(runCase.execution).lean(); + should(execution.executionType).equal('manual'); + should(execution.status).equal('PASS'); + should(execution.manualTestCase.toString()).equal(caseA._id); + should(execution.versionNumber).equal(1); + should(execution.actions[0].steps).have.length(2); + should(execution.actions[0].steps[0].actual).equal('As expected'); + + // Folded through the same path an automated result takes. + const build = await Build.findById(run.build).lean(); + should(build.result.PASS).equal(1); + should(build.status).equal('PASS'); + should(build.suites).have.length(1); + }); + + it('records a FAIL and reflects it in the build result map', async () => { + const res = await send(memberAgent, 'put', `manual-test-run/${run._id}/test-case/${caseA._id}/result`, { + status: 'FAIL', + }); + should(res.status).equal(200); + + const build = await Build.findById(run.build).lean(); + should(build.result.FAIL).equal(1); + should(build.status).equal('FAIL'); + }); + + it('maps BLOCKED to SKIPPED on the execution, keeping BLOCKED on the run', async () => { + const res = await send(memberAgent, 'put', `manual-test-run/${run._id}/test-case/${caseA._id}/result`, { + status: 'BLOCKED', + notes: 'Environment was down', + }); + should(res.status).equal(200); + + const runCase = res.body.testCases.find((c) => c.testCase === caseA._id); + // The richer state survives on the run... + should(runCase.status).equal('BLOCKED'); + + // ...but the execution uses the closed enum the metrics layer depends on. SKIPPED, + // not ERROR: nothing was verified and no defect was found, so it must not inflate + // the failure count. + const execution = await TestExecution.findById(runCase.execution).lean(); + should(execution.status).equal('SKIPPED'); + + const build = await Build.findById(run.build).lean(); + should(build.result.SKIPPED).equal(1); + should(build.result.ERROR).equal(0); + }); + + it('updates the existing execution when a result is re-recorded', async () => { + const first = await send(memberAgent, 'put', `manual-test-run/${run._id}/test-case/${caseA._id}/result`, { status: 'FAIL' }); + const firstExecution = first.body.testCases.find((c) => c.testCase === caseA._id).execution; + + const second = await send(memberAgent, 'put', `manual-test-run/${run._id}/test-case/${caseA._id}/result`, { status: 'PASS' }); + const secondExecution = second.body.testCases.find((c) => c.testCase === caseA._id).execution; + + // Same execution updated, not a second one added - otherwise the build's totals + // would exceed the number of cases in the run. + should(secondExecution).equal(firstExecution); + const count = await TestExecution.countDocuments({ build: run.build }); + should(count).equal(1); + + const build = await Build.findById(run.build).lean(); + should(build.result.PASS).equal(1); + should(build.result.FAIL).equal(0); + }); + + it('completes the run once every case has a result', async () => { + await send(memberAgent, 'put', `manual-test-run/${run._id}/test-case/${caseA._id}/result`, { status: 'PASS' }); + const res = await send(memberAgent, 'put', `manual-test-run/${run._id}/test-case/${caseB._id}/result`, { status: 'PASS' }); + should(res.status).equal(200); + should(res.body.status).equal('COMPLETED'); + should(res.body.end).not.equal(null); + + const build = await Build.findById(run.build).lean(); + should(build.result.PASS).equal(2); + }); + + it('respond with 404 for a case not in the run', async () => { + const other = await createCase('unit-testing-mr not in run'); + const res = await send(memberAgent, 'put', `manual-test-run/${run._id}/test-case/${other.body._id}/result`, { status: 'PASS' }); + should(res.status).equal(404); + }); + + it('respond with 403 for a user outside the team', async () => { + const res = await send(outsiderAgent, 'put', `manual-test-run/${run._id}/test-case/${caseA._id}/result`, { status: 'PASS' }); + should(res.status).equal(403); + }); + + it('respond with 422 for an unknown status', async () => { + const res = await send(memberAgent, 'put', `manual-test-run/${run._id}/test-case/${caseA._id}/result`, { status: 'MAYBE' }); + should(res.status).equal(422); + }); + }); + + describe('version binding', () => { + let run; + let editableCase; + + before(async () => { + editableCase = (await createCase('unit-testing-mr editable case')).body; + const res = await createRun(memberAgent, { + name: 'unit-testing-mr binding run', + testCaseIds: [editableCase._id], + }); + run = res.body; + }); + + it('does not shift the steps under the tester when the case is edited mid-run', async () => { + const edited = await send(memberAgent, 'put', `manual-test-case/${editableCase._id}`, { + steps: [ + { action: 'Open the page', expected: 'Page loads' }, + { action: 'Accept cookies', expected: 'Banner dismissed' }, + { action: 'Click submit', expected: 'Form submits' }, + ], + }); + should(edited.status).equal(200); + should(edited.body.version).equal(2); + + // The run is still on v1 and its expanded steps are the v1 steps. + const res = await send(memberAgent, 'get', `manual-test-run/${run._id}?expand=true`); + should(res.status).equal(200); + should(res.body.testCases[0].versionNumber).equal(1); + should(res.body.testCases[0].version.steps).have.length(2); + const actions = res.body.testCases[0].version.steps.map((s) => s.action); + should(actions).not.containEql('Accept cookies'); + }); + + it('rebinds a NOT_RUN case onto the latest version', async () => { + const res = await send(memberAgent, 'put', `manual-test-run/${run._id}/test-case/${editableCase._id}/rebind`); + should(res.status).equal(200); + should(res.body.testCases[0].versionNumber).equal(2); + + const expanded = await send(memberAgent, 'get', `manual-test-run/${run._id}?expand=true`); + should(expanded.body.testCases[0].version.steps).have.length(3); + }); + + it('respond with 409 when rebinding a case that already has a result', async () => { + await send(memberAgent, 'put', `manual-test-run/${run._id}/test-case/${editableCase._id}/result`, { status: 'PASS' }); + const res = await send(memberAgent, 'put', `manual-test-run/${run._id}/test-case/${editableCase._id}/rebind`); + should(res.status).equal(409); + should(res.body.message).match(/already has a recorded result/); + }); + + it('records the execution against the version it was actually run on', async () => { + const runDoc = await ManualTestRun.findById(run._id).lean(); + const runCase = runDoc.testCases[0]; + const execution = await TestExecution.findById(runCase.execution).lean(); + should(execution.versionNumber).equal(2); + should(execution.manualTestCaseVersion.toString()) + .equal(runCase.testCaseVersion.toString()); + }); + }); + + describe('GET /manual-test-run', () => { + it('lists the team\'s runs', async () => { + const res = await send(memberAgent, 'get', `manual-test-run?teamId=${team._id}`); + should(res.status).equal(200); + should(res.body.testRuns.length).be.above(0); + should(res.body.metrics.totalTestRuns).be.above(0); + }); + + it('filters by status', async () => { + const res = await send(memberAgent, 'get', `manual-test-run?teamId=${team._id}&status=COMPLETED`); + should(res.status).equal(200); + res.body.testRuns.forEach((run) => should(run.status).equal('COMPLETED')); + }); + + it('respond with 403 for a user outside the team', async () => { + const res = await send(outsiderAgent, 'get', `manual-test-run?teamId=${team._id}`); + should(res.status).equal(403); + }); + }); + + describe('PUT /manual-test-run/:runId', () => { + it('adds a case to a run in flight, bound to its current version', async () => { + const created = await createRun(memberAgent, { + name: 'unit-testing-mr growing run', + testCaseIds: [caseA._id], + }); + const res = await send(memberAgent, 'put', `manual-test-run/${created.body._id}`, { + testCaseIds: [caseB._id], + }); + should(res.status).equal(200); + should(res.body.testCases).have.length(2); + }); + + it('does not duplicate a case already in the run', async () => { + const created = await createRun(memberAgent, { name: 'unit-testing-mr dedupe run' }); + const res = await send(memberAgent, 'put', `manual-test-run/${created.body._id}`, { + testCaseIds: [caseA._id], + }); + should(res.status).equal(200); + should(res.body.testCases).have.length(1); + }); + }); + + describe('PUT /manual-test-run/:runId/status', () => { + it('cancels a run and stamps the end date', async () => { + const created = await createRun(memberAgent, { name: 'unit-testing-mr cancel run' }); + const res = await send(memberAgent, 'put', `manual-test-run/${created.body._id}/status`, { + status: 'CANCELLED', + }); + should(res.status).equal(200); + should(res.body.status).equal('CANCELLED'); + should(res.body.end).not.equal(null); + }); + + it('refuses a result against a cancelled run', async () => { + const created = await createRun(memberAgent, { name: 'unit-testing-mr cancelled result run' }); + await send(memberAgent, 'put', `manual-test-run/${created.body._id}/status`, { status: 'CANCELLED' }); + const res = await send(memberAgent, 'put', `manual-test-run/${created.body._id}/test-case/${caseA._id}/result`, { status: 'PASS' }); + should(res.status).equal(409); + }); + }); + + describe('DELETE /manual-test-run/:runId', () => { + it('removes the run along with its build and executions', async () => { + const created = await createRun(leadAgent, { name: 'unit-testing-mr doomed run' }); + await send(leadAgent, 'put', `manual-test-run/${created.body._id}/test-case/${caseA._id}/result`, { status: 'PASS' }); + + const buildId = created.body.build; + should(await TestExecution.countDocuments({ build: buildId })).equal(1); + + const res = await send(leadAgent, 'delete', `manual-test-run/${created.body._id}`); + should(res.status).equal(200); + + // Leaving these would show dashboard results for a run that no longer exists. + should(await Build.countDocuments({ _id: buildId })).equal(0); + should(await TestExecution.countDocuments({ build: buildId })).equal(0); + }); + + it('respond with 403 when a plain member tries to delete', async () => { + const created = await createRun(memberAgent, { name: 'unit-testing-mr undeletable run' }); + const res = await send(memberAgent, 'delete', `manual-test-run/${created.body._id}`); + should(res.status).equal(403); + }); + + it('respond with 404 for an unknown id', async () => { + const res = await send(leadAgent, 'delete', 'manual-test-run/5f7e2b9e8f1b2c0017a1b2c3'); + should(res.status).equal(404); + }); + }); + + describe('existing automated behaviour is unchanged', () => { + it('defaults an automated build and execution to executionType automated', async () => { + const build = await testUtils.createBuild(team, environment, 'unit-testing-mr automated build'); + const saved = await Build.findById(build._id).lean(); + should(saved.executionType).equal('automated'); + await Build.deleteOne({ _id: build._id }).exec(); + }); + }); +}); diff --git a/test/manual-version-recovery.tests.js b/test/manual-version-recovery.tests.js new file mode 100644 index 0000000..9f7ac48 --- /dev/null +++ b/test/manual-version-recovery.tests.js @@ -0,0 +1,235 @@ +/** + * Regression tests for the version/head write ordering. + * + * The head and its frozen versions are two separate writes. If a version lands and the head + * save then fails, the head sits one version behind what is on disk - and because + * { testCase, version } is unique, every later edit retries that same number and fails with + * a duplicate key error. The case is then permanently unsaveable. + * + * That is exactly what a shared step inclusion used to trigger: the version stores expanded + * steps (which have an action) while the head stores the placeholder (which does not), so + * the version validated and the head did not. + */ +const request = require('supertest'); +const should = require('should'); +const bcrypt = require('bcryptjs'); +const app = require('../server.js'); +const User = require('../app/models/user.js'); +const ManualTestCase = require('../app/models/manual-test-case.js'); +const ManualTestCaseVersion = require('../app/models/manual-test-case-version.js'); +const SharedStep = require('../app/models/shared-step.js'); +const { Team } = require('../app/models/team.js'); + +const baseUrl = '/rest/api/v1.0/'; +const LEAD_PASSWORD = 'unit-testing-MvrLead1!'; + +describe('Manual Test Case Version Recovery Tests', () => { + let leadAgent; + let team; + let sharedStep; + + const login = (username, password) => new Promise((resolve, reject) => { + const agent = request.agent(app); + agent + .post(`${baseUrl}auth/login`) + .send({ username, password }) + .end((err, res) => { + if (err) return reject(err); + if (res.status !== 200) return reject(new Error(`login failed: ${res.status}`)); + return resolve(agent); + }); + }); + + const send = (agent, method, url, body) => new Promise((resolve, reject) => { + const req = agent[method](`${baseUrl}${url}`).set('Accept', 'application/json'); + if (body !== undefined) req.send(body); + req.end((err, res) => (err ? reject(err) : resolve(res))); + }); + + before(async () => { + await Promise.all([ + User.deleteMany({ username: /^unit-testing-mvr/ }).exec(), + Team.deleteMany({ name: /^unit-testing-mvr/ }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-mvr/ }).exec(), + SharedStep.deleteMany({ name: /^unit-testing-mvr/ }).exec(), + ]); + + team = await new Team({ name: 'unit-testing-mvr-team', components: [{ name: 'mvr-component' }] }).save(); + const hash = await bcrypt.hash(LEAD_PASSWORD, 10); + await User.create({ + username: 'unit-testing-mvr-lead', password: hash, role: 'team_lead', teams: [team._id], + }); + leadAgent = await login('unit-testing-mvr-lead', LEAD_PASSWORD); + + const created = await send(leadAgent, 'post', 'shared-step', { + team: team._id.toString(), + name: 'unit-testing-mvr shared', + steps: [{ action: 'Log in', expected: 'Logged in' }], + }); + sharedStep = created.body; + }); + + after(async () => { + const cases = await ManualTestCase.find({ title: /^unit-testing-mvr/ }).select('_id').lean(); + await Promise.all([ + ManualTestCaseVersion.deleteMany({ testCase: { $in: cases.map((c) => c._id) } }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-mvr/ }).exec(), + SharedStep.deleteMany({ name: /^unit-testing-mvr/ }).exec(), + User.deleteMany({ username: /^unit-testing-mvr/ }).exec(), + Team.deleteMany({ name: /^unit-testing-mvr/ }).exec(), + ]); + }); + + describe('a shared step inclusion carries no action of its own', () => { + let caseId; + + it('accepts a step that only references a shared step', async () => { + const res = await send(leadAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-mvr inclusion', + steps: [{ sharedStep: sharedStep._id }], + }); + should(res.status).equal(201); + caseId = res.body._id; + }); + + it('saves an update that adds an inclusion without an action', async () => { + // The exact shape the UI sends: an inclusion plus a literal step. + const res = await send(leadAgent, 'put', `manual-test-case/${caseId}`, { + steps: [ + { sharedStep: sharedStep._id }, + { action: 'Press spin', expected: 'Reels spin' }, + ], + }); + should(res.status).equal(200); + should(res.body.version).equal(2); + }); + + it('leaves the head and the versions in step', async () => { + const head = await ManualTestCase.findById(caseId).lean(); + const latest = await ManualTestCaseVersion + .findOne({ testCase: caseId }).sort('-version').lean(); + should(head.version).equal(latest.version); + }); + + it('saves again rather than colliding on the version index', async () => { + // This is the failure the user hit: the second edit after an inclusion was added. + const res = await send(leadAgent, 'put', `manual-test-case/${caseId}`, { + steps: [ + { sharedStep: sharedStep._id }, + { action: 'Press spin', expected: 'Reels spin' }, + { action: 'Increase coin size', expected: 'Stake rises' }, + ], + }); + should(res.status).equal(200); + should(res.body.version).equal(3); + }); + + it('accepts expanded steps echoed back by a client', async () => { + // A client that read the case holds *expanded* steps - they carry sharedStepRef + // rather than sharedStep, and their action is whatever the shared step said, which + // may be empty. Saving that back must not be rejected. + const res = await send(leadAgent, 'put', `manual-test-case/${caseId}`, { + steps: [ + { + order: 1, action: 'Log in', expected: 'Logged in', sharedStepRef: sharedStep._id, sharedStepVersion: 1, + }, + { order: 2, action: 'Press spin', expected: 'Reels spin' }, + ], + }); + should(res.status).equal(200); + }); + + it('accepts an expanded step whose source had no action', async () => { + const res = await send(leadAgent, 'put', `manual-test-case/${caseId}`, { + steps: [ + { + order: 1, expected: 'Something', sharedStepRef: sharedStep._id, sharedStepVersion: 1, + }, + { order: 2, action: 'Press spin' }, + ], + }); + should(res.status).equal(200); + }); + + it('still requires an action on a literal step', async () => { + const res = await send(leadAgent, 'put', `manual-test-case/${caseId}`, { + steps: [{ expected: 'Something happens' }], + }); + should(res.status).equal(422); + }); + }); + + describe('recovering a case whose head fell behind its versions', () => { + let caseId; + + before(async () => { + const created = await send(leadAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-mvr drifted', + steps: [{ action: 'Step one' }], + }); + caseId = created.body._id; + + // Reproduce the wedged state directly: a version at v2 with the head still at v1, + // which is what a version write followed by a failed head save leaves behind. + const head = await ManualTestCase.findById(caseId).lean(); + await ManualTestCaseVersion.create({ + testCase: caseId, + version: 2, + team: head.team, + title: head.title, + steps: [{ order: 1, action: 'Orphaned' }], + createdBy: head.createdBy, + }); + }); + + it('starts from the broken state the bug produced', async () => { + const head = await ManualTestCase.findById(caseId).lean(); + const latest = await ManualTestCaseVersion + .findOne({ testCase: caseId }).sort('-version').lean(); + should(head.version).equal(1); + should(latest.version).equal(2); + }); + + it('saves instead of failing with a duplicate key error', async () => { + const res = await send(leadAgent, 'put', `manual-test-case/${caseId}`, { + title: 'unit-testing-mvr drifted, edited', + }); + should(res.status).equal(200); + // Skips past the orphan rather than colliding with it. + should(res.body.version).equal(3); + }); + + it('leaves the head and the versions in step afterwards', async () => { + const head = await ManualTestCase.findById(caseId).lean(); + const latest = await ManualTestCaseVersion + .findOne({ testCase: caseId }).sort('-version').lean(); + should(head.version).equal(latest.version); + should(head.version).equal(3); + }); + }); + + describe('a failed head save leaves no orphan version', () => { + it('writes nothing when the update is invalid', async () => { + const created = await send(leadAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-mvr atomic', + steps: [{ action: 'Step one' }], + }); + const caseId = created.body._id; + const before = await ManualTestCaseVersion.countDocuments({ testCase: caseId }); + + // Long enough to fail the schema's maxlength, after the content-changed check. + const res = await send(leadAgent, 'put', `manual-test-case/${caseId}`, { + title: 'x'.repeat(500), + }); + should(res.status).be.aboveOrEqual(400); + + const after = await ManualTestCaseVersion.countDocuments({ testCase: caseId }); + should(after).equal(before); + const head = await ManualTestCase.findById(caseId).lean(); + should(head.version).equal(1); + }); + }); +}); diff --git a/test/prometheus.tests.js b/test/prometheus.tests.js index 740ee8a..4cb6d4f 100644 --- a/test/prometheus.tests.js +++ b/test/prometheus.tests.js @@ -267,13 +267,27 @@ describe('Prometheus Metrics API Tests', () => { samplesFor(samples, 'angles_builds').length.should.be.above(0); }); - it('should expose build counts labelled by team and environment', () => { + it('should expose build counts labelled by team, environment and execution type', () => { const key = 'angles_builds_by_team{team="prometheus-unit-testing-team"' - + ',environment="prometheus-unit-testing-environment",status="SKIPPED"}'; + + ',environment="prometheus-unit-testing-environment",status="SKIPPED"' + + ',execution_type="automated"}'; samples.should.have.property(key); samples[key].should.be.aboveOrEqual(1); }); + it('should label a build with no stored executionType as automated', () => { + // Builds written before manual runs existed have no executionType on disk. They + // must still be counted as the automated runs they are, not as "unknown". + const automated = samplesFor(samples, 'angles_builds_by_execution_type') + .filter((key) => key.includes('execution_type="automated"')); + automated.length.should.be.above(0); + }); + + it('should expose execution counts split by execution type', () => { + samplesFor(samples, 'angles_executions_by_execution_type') + .length.should.be.above(0); + }); + it('should expose the last build timestamp per team', () => { const key = 'angles_last_build_timestamp_seconds{team="prometheus-unit-testing-team"}'; samples.should.have.property(key); diff --git a/test/shared-step.tests.js b/test/shared-step.tests.js new file mode 100644 index 0000000..02d0b2b --- /dev/null +++ b/test/shared-step.tests.js @@ -0,0 +1,438 @@ +/** + * Tests for re-usable shared steps and the expansion behind them. + * + * The load-bearing assertions are the ones about expansion placement: a frozen version + * stores expanded steps, never a reference, so editing a shared step must leave every + * previously written version showing exactly what its tester saw. Test 2 of the plan's + * versioning suite lives here. + */ +const request = require('supertest'); +const should = require('should'); +const pino = require('pino'); +const bcrypt = require('bcryptjs'); +const app = require('../server.js'); +const testUtils = require('./test-utils.js'); +const User = require('../app/models/user.js'); +const SharedStep = require('../app/models/shared-step.js'); +const ManualTestCase = require('../app/models/manual-test-case.js'); +const ManualTestCaseVersion = require('../app/models/manual-test-case-version.js'); +const { Team } = require('../app/models/team.js'); + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); +const baseUrl = '/rest/api/v1.0/'; + +const MEMBER_PASSWORD = 'unit-testing-SsMember1!'; +const LEAD_PASSWORD = 'unit-testing-SsLead1!'; +const OUTSIDER_PASSWORD = 'unit-testing-SsOutsider1!'; + +describe('Shared Step API Tests', () => { + let memberAgent; + let leadAgent; + let outsiderAgent; + let team; + let otherTeam; + let loginStep; + + const login = (username, password) => new Promise((resolve, reject) => { + const agent = request.agent(app); + agent + .post(`${baseUrl}auth/login`) + .send({ username, password }) + .end((err, res) => { + if (err) return reject(err); + if (res.status !== 200) return reject(new Error(`login failed for ${username}: ${res.status}`)); + return resolve(agent); + }); + }); + + const send = (agent, method, url, body) => new Promise((resolve, reject) => { + const req = agent[method](`${baseUrl}${url}`).set('Accept', 'application/json'); + if (body !== undefined) req.send(body); + req.end((err, res) => (err ? reject(err) : resolve(res))); + }); + + before(async () => { + await Promise.all([ + User.deleteMany({ username: /^unit-testing-ss/ }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-ss/ }).exec(), + Team.deleteMany({ name: /^unit-testing-ss/ }).exec(), + ]); + logger.info('Cleared any lingering shared step fixtures'); + + team = await new Team({ name: 'unit-testing-ss-team', components: [{ name: 'ss-component' }] }).save(); + otherTeam = await new Team({ name: 'unit-testing-ss-other', components: [{ name: 'ss-other' }] }).save(); + await SharedStep.deleteMany({ team: { $in: [team._id, otherTeam._id] } }).exec(); + + const [memberHash, leadHash, outsiderHash] = await Promise.all([ + bcrypt.hash(MEMBER_PASSWORD, 10), + bcrypt.hash(LEAD_PASSWORD, 10), + bcrypt.hash(OUTSIDER_PASSWORD, 10), + ]); + await User.create([ + { + username: 'unit-testing-ss-member', password: memberHash, role: 'user', teams: [team._id], + }, + { + username: 'unit-testing-ss-lead', password: leadHash, role: 'team_lead', teams: [team._id], + }, + { + username: 'unit-testing-ss-outsider', password: outsiderHash, role: 'team_lead', teams: [otherTeam._id], + }, + ]); + + [memberAgent, leadAgent, outsiderAgent] = await Promise.all([ + login('unit-testing-ss-member', MEMBER_PASSWORD), + login('unit-testing-ss-lead', LEAD_PASSWORD), + login('unit-testing-ss-outsider', OUTSIDER_PASSWORD), + ]); + await testUtils.getAdminAgent(); + }); + + after(async () => { + const cases = await ManualTestCase.find({ title: /^unit-testing-ss/ }).select('_id').lean(); + await Promise.all([ + ManualTestCaseVersion.deleteMany({ testCase: { $in: cases.map((c) => c._id) } }).exec(), + ManualTestCase.deleteMany({ title: /^unit-testing-ss/ }).exec(), + SharedStep.deleteMany({ team: { $in: [team._id, otherTeam._id] } }).exec(), + User.deleteMany({ username: /^unit-testing-ss/ }).exec(), + Team.deleteMany({ name: /^unit-testing-ss/ }).exec(), + ]); + }); + + describe('POST /shared-step', () => { + it('respond with 201 when a team lead creates a shared step', async () => { + const res = await send(leadAgent, 'post', 'shared-step', { + team: team._id.toString(), + name: 'unit-testing-ss login', + description: 'Log in as a standard user.', + steps: [ + { action: 'Navigate to the login page', expected: 'The login form is displayed' }, + { action: 'Submit valid credentials', expected: 'The dashboard is displayed' }, + ], + }); + should(res.status).equal(201); + loginStep = res.body; + should(res.body.version).equal(1); + should(res.body.steps).have.length(2); + should(res.body.steps[0].order).equal(1); + }); + + it('respond with 403 when a plain team member tries to create one', async () => { + const res = await send(memberAgent, 'post', 'shared-step', { + team: team._id.toString(), + name: 'unit-testing-ss sneaky', + steps: [{ action: 'x' }], + }); + should(res.status).equal(403); + }); + + it('respond with 409 for a duplicate name within the team', async () => { + const res = await send(leadAgent, 'post', 'shared-step', { + team: team._id.toString(), + name: 'unit-testing-ss login', + steps: [{ action: 'x' }], + }); + should(res.status).equal(409); + }); + + it('respond with 422 when no steps are supplied', async () => { + const res = await send(leadAgent, 'post', 'shared-step', { + team: team._id.toString(), + name: 'unit-testing-ss empty', + steps: [], + }); + should(res.status).equal(422); + }); + + it('respond with 400 when a shared step tries to include another', async () => { + const res = await send(leadAgent, 'post', 'shared-step', { + team: team._id.toString(), + name: 'unit-testing-ss nested', + steps: [{ action: 'include', sharedStep: loginStep._id }], + }); + should(res.status).equal(400); + should(res.body.message).match(/cannot include another shared step/); + }); + }); + + describe('GET /shared-step', () => { + it('is readable by a plain team member', async () => { + const res = await send(memberAgent, 'get', `shared-step?teamId=${team._id}`); + should(res.status).equal(200); + should(res.body.sharedSteps.length).be.above(0); + should(res.body.metrics.totalSharedSteps).be.above(0); + }); + + it('filters by search term', async () => { + const res = await send(memberAgent, 'get', `shared-step?teamId=${team._id}&search=login`); + should(res.status).equal(200); + should(res.body.sharedSteps.length).be.above(0); + }); + + it('respond with 403 for a user outside the team', async () => { + const res = await send(outsiderAgent, 'get', `shared-step?teamId=${team._id}`); + should(res.status).equal(403); + }); + }); + + describe('including a shared step in a test case', () => { + let testCase; + + it('accepts a step that references a shared step', async () => { + const res = await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-ss case with inclusion', + steps: [ + { action: 'Open the application', expected: 'The landing page loads' }, + { sharedStep: loginStep._id, action: 'placeholder' }, + { action: 'Open the reports tab', expected: 'Reports are listed' }, + ], + }); + should(res.status).equal(201); + testCase = res.body; + // The head keeps the placeholder so it tracks the shared step. + should(testCase.steps).have.length(3); + should(testCase.steps[1].sharedStep).equal(loginStep._id); + }); + + it('expands the shared step on read when asked', async () => { + const res = await send(memberAgent, 'get', `manual-test-case/${testCase._id}?expand=true`); + should(res.status).equal(200); + should(res.body.expanded).equal(true); + // 1 + 2 (expanded) + 1 = 4 steps, contiguously ordered. + should(res.body.steps).have.length(4); + should(res.body.steps.map((s) => s.order)).eql([1, 2, 3, 4]); + should(res.body.steps[1].action).equal('Navigate to the login page'); + should(res.body.steps[1].sharedStepRef).equal(loginStep._id); + should(res.body.steps[1].sharedStepVersion).equal(1); + // An expanded step is literal, never itself a reference. + should(res.body.steps[1].sharedStep).equal(undefined); + }); + + it('leaves the head unexpanded by default', async () => { + const res = await send(memberAgent, 'get', `manual-test-case/${testCase._id}`); + should(res.status).equal(200); + should(res.body.steps).have.length(3); + should(res.body.expanded).equal(undefined); + }); + + it('stores the expanded steps in the frozen version', async () => { + const version = await ManualTestCaseVersion + .findOne({ testCase: testCase._id, version: 1 }) + .lean(); + should(version.steps).have.length(4); + should(version.steps[1].action).equal('Navigate to the login page'); + should(version.steps[1].sharedStepVersion).equal(1); + // A version must never hold a reference that could resolve differently later. + version.steps.forEach((step) => should(step.sharedStep).equal(undefined)); + }); + + it('respond with 404 for a shared step that does not exist', async () => { + const res = await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-ss bad reference', + steps: [{ sharedStep: '5f7e2b9e8f1b2c0017a1b2c3', action: 'x' }], + }); + should(res.status).equal(404); + should(res.body.message).match(/No shared step found/); + }); + + it('respond with 404 for a shared step belonging to another team', async () => { + const foreign = await send(outsiderAgent, 'post', 'shared-step', { + team: otherTeam._id.toString(), + name: 'unit-testing-ss foreign', + steps: [{ action: 'foreign step' }], + }); + should(foreign.status).equal(201); + + const res = await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-ss cross team reference', + steps: [{ sharedStep: foreign.body._id, action: 'x' }], + }); + should(res.status).equal(404); + }); + }); + + describe('GET /shared-step/:id/usage', () => { + it('lists the test cases that include the shared step', async () => { + const res = await send(memberAgent, 'get', `shared-step/${loginStep._id}/usage`); + should(res.status).equal(200); + should(res.body.metrics.totalTestCases).be.above(0); + should(res.body.testCases[0].title).match(/^unit-testing-ss/); + }); + }); + + describe('editing a shared step cascades into referencing cases', () => { + let caseA; + let caseB; + + before(async () => { + const [a, b] = await Promise.all([ + send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-ss cascade case A', + steps: [{ sharedStep: loginStep._id, action: 'placeholder' }, { action: 'Do A' }], + }), + send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-ss cascade case B', + steps: [{ sharedStep: loginStep._id, action: 'placeholder' }, { action: 'Do B' }], + }), + ]); + caseA = a.body; + caseB = b.body; + should(caseA.version).equal(1); + should(caseB.version).equal(1); + }); + + it('bumps the shared step version and versions every referencing case', async () => { + const res = await send(leadAgent, 'put', `shared-step/${loginStep._id}`, { + steps: [ + { action: 'Navigate to the login page', expected: 'The login form is displayed' }, + { action: 'Accept the cookie banner', expected: 'The banner is dismissed' }, + { action: 'Submit valid credentials', expected: 'The dashboard is displayed' }, + ], + }); + should(res.status).equal(200); + should(res.body.version).equal(2); + // Both cascade cases plus the inclusion case created earlier. + should(res.body.cascade.testCasesVersioned).be.aboveOrEqual(3); + should(res.body.cascade.failures).have.length(0); + }); + + it('leaves each referencing case on a new version', async () => { + const [a, b] = await Promise.all([ + send(memberAgent, 'get', `manual-test-case/${caseA._id}`), + send(memberAgent, 'get', `manual-test-case/${caseB._id}`), + ]); + should(a.body.version).equal(2); + should(b.body.version).equal(2); + }); + + it('leaves the OLD version showing the OLD shared content', async () => { + // This is the assertion the whole design exists for. + const version1 = await ManualTestCaseVersion + .findOne({ testCase: caseA._id, version: 1 }) + .lean(); + // The shared step had two steps at v1, plus the case's own trailing step. + should(version1.steps).have.length(3); + should(version1.steps[0].action).equal('Navigate to the login page'); + should(version1.steps[1].action).equal('Submit valid credentials'); + should(version1.steps[2].action).equal('Do A'); + should(version1.steps[0].sharedStepVersion).equal(1); + // No trace of the step added by the edit. + const actions = version1.steps.map((s) => s.action); + should(actions).not.containEql('Accept the cookie banner'); + }); + + it('shows the NEW shared content in the new version', async () => { + const version2 = await ManualTestCaseVersion + .findOne({ testCase: caseA._id, version: 2 }) + .lean(); + should(version2.steps).have.length(4); + should(version2.steps.map((s) => s.action)).containEql('Accept the cookie banner'); + should(version2.steps[0].sharedStepVersion).equal(2); + }); + + it('keeps the head tracking the shared step rather than flattening it', async () => { + const head = await ManualTestCase.findById(caseA._id).lean(); + should(head.steps).have.length(2); + should(head.steps[0].sharedStep.toString()).equal(loginStep._id); + }); + + it('does not cascade when only the name or description changes', async () => { + const before = await ManualTestCase.findById(caseA._id).lean(); + const res = await send(leadAgent, 'put', `shared-step/${loginStep._id}`, { + description: 'Log in as a standard user, dismissing the cookie banner.', + }); + should(res.status).equal(200); + should(res.body.version).equal(2); + should(res.body.cascade.testCasesVersioned).equal(0); + + const after = await ManualTestCase.findById(caseA._id).lean(); + should(after.version).equal(before.version); + }); + + it('does not cascade when the steps are re-sent unchanged', async () => { + const current = await SharedStep.findById(loginStep._id).lean(); + const res = await send(leadAgent, 'put', `shared-step/${loginStep._id}`, { + steps: current.steps.map((step) => ({ + action: step.action, + expected: step.expected, + order: step.order, + })), + }); + should(res.status).equal(200); + should(res.body.version).equal(2); + should(res.body.cascade.testCasesVersioned).equal(0); + }); + + it('respond with 403 when a plain member tries to edit', async () => { + const res = await send(memberAgent, 'put', `shared-step/${loginStep._id}`, { + description: 'hijacked', + }); + should(res.status).equal(403); + }); + }); + + describe('DELETE /shared-step/:id', () => { + it('respond with 409 when the shared step is still referenced', async () => { + const res = await send(leadAgent, 'delete', `shared-step/${loginStep._id}`); + should(res.status).equal(409); + should(res.body.message).match(/included by/); + }); + + it('deletes a shared step that nothing references', async () => { + const created = await send(leadAgent, 'post', 'shared-step', { + team: team._id.toString(), + name: 'unit-testing-ss unreferenced', + steps: [{ action: 'nothing uses me' }], + }); + should(created.status).equal(201); + + const res = await send(leadAgent, 'delete', `shared-step/${created.body._id}`); + should(res.status).equal(200); + const gone = await SharedStep.findById(created.body._id).lean(); + should(gone).equal(null); + }); + + it('respond with 403 for a plain team member', async () => { + const res = await send(memberAgent, 'delete', `shared-step/${loginStep._id}`); + should(res.status).equal(403); + }); + + it('respond with 404 for an unknown id', async () => { + const res = await send(leadAgent, 'delete', 'shared-step/5f7e2b9e8f1b2c0017a1b2c3'); + should(res.status).equal(404); + }); + }); + + describe('a deleted shared step leaves a resolvable case', () => { + it('keeps the placeholder as an unresolved step rather than dropping it', async () => { + const created = await send(leadAgent, 'post', 'shared-step', { + team: team._id.toString(), + name: 'unit-testing-ss doomed', + steps: [{ action: 'about to vanish' }], + }); + const doomed = created.body; + + const caseRes = await send(memberAgent, 'post', 'manual-test-case', { + team: team._id.toString(), + title: 'unit-testing-ss orphaned reference', + steps: [{ sharedStep: doomed._id, action: 'placeholder' }, { action: 'Still here' }], + }); + should(caseRes.status).equal(201); + + // Bypasses the delete guard on purpose: the guard stops this happening through the + // API, but the expansion must still degrade safely if a document goes missing. + await SharedStep.findByIdAndRemove(doomed._id).exec(); + + const res = await send(memberAgent, 'get', `manual-test-case/${caseRes.body._id}?expand=true`); + should(res.status).equal(200); + should(res.body.steps).have.length(2); + should(res.body.steps[0].unresolvedSharedStep).equal(true); + should(res.body.steps[1].action).equal('Still here'); + }); + }); +});