diff --git a/app/middleware/acl.js b/app/middleware/acl.js index dcd931259..2f5f556d7 100644 --- a/app/middleware/acl.js +++ b/app/middleware/acl.js @@ -117,13 +117,16 @@ module.exports = async (req, res, next) => { try { // Update last time the user logged in, limit to once a day const currentDate = new Date().toDateString(); - let userMetadata = await db.models.userMetadata.findOrCreate({where: {userId: req.user.id}}); + let userMetadata = await db.models.userMetadata.findOrCreate({ + where: {userId: req.user.id}, + defaults: {updatedBy: req.user.id}, + }); userMetadata = userMetadata[0]; const userLastLogin = userMetadata.lastLoginAt ? new Date(userMetadata.lastLoginAt).toDateString() : ''; if (userLastLogin !== currentDate) { - await userMetadata.update({lastLoginAt: new Date()}); + await userMetadata.update({lastLoginAt: new Date()}, {userId: req.user.id}); } try { diff --git a/app/models/index.js b/app/models/index.js index 1acc09fb6..0ef38f099 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -133,6 +133,20 @@ summary.probeResults = require('./reports/probeResults')(sequelize, Sq); summary.therapeuticTargets = require('./reports/genomic/summary/therapeuticTargets')(sequelize, Sq); summary.microbial = require('./reports/genomic/summary/microbial')(sequelize, Sq); +// Pathway Analysis Legends +// DEVSU-2310 batch 2: restore `const pathwayAnalysisLegends =` when re-enabling the associations below +require('./legend/legend')(sequelize, Sq); + +// DEVSU-2310 batch 2: re-enable with the update-pathway-analysis-legend-fk migration. +// These associations define the legendId attribute on pathwayAnalysis, so they must +// stay commented out alongside the model attribute until legend_id exists. +// summary.pathwayAnalysis.belongsTo(pathwayAnalysisLegends, { +// as: 'legend', foreignKey: 'legendId', targetKey: 'id', onDelete: 'SET NULL', constraints: true, +// }); +// pathwayAnalysisLegends.hasMany(summary.pathwayAnalysis, { +// as: 'pathwayAnalyses', foreignKey: 'legendId', onDelete: 'SET NULL', constraints: true, +// }); + analysisReports.belongsTo(user, { as: 'createdBy', foreignKey: 'createdBy_id', targetKey: 'id', onDelete: 'SET NULL', controlled: true, }); diff --git a/app/models/legend/legend.js b/app/models/legend/legend.js new file mode 100644 index 000000000..4ba7a43a7 --- /dev/null +++ b/app/models/legend/legend.js @@ -0,0 +1,123 @@ +const {DEFAULT_COLUMNS} = require('../base'); + +module.exports = (sequelize, Sq) => { + const legend = sequelize.define( + 'legend', + { + ...DEFAULT_COLUMNS, + format: { + type: Sq.ENUM('PNG', 'JPG', 'SVG'), + defaultValue: 'PNG', + }, + filename: { + type: Sq.TEXT, + allowNull: false, + }, + name: { + type: Sq.TEXT, + allowNull: false, + }, + data: { + type: Sq.TEXT, + allowNull: false, + }, + default: { + type: Sq.BOOLEAN, + defaultValue: false, + set(value) { + this.setDataValue('default', value === true || value === 'true'); + }, + }, + }, + { + tableName: 'pathway_analysis_legends', + indexes: [ + { + unique: true, + fields: ['default'], + where: { + default: true, + deleted_at: null, + }, + name: 'idx_one_default_legend', + }, + ], + scopes: { + public: { + attributes: { + exclude: ['id', 'deletedAt', 'updatedBy'], + }, + }, + }, + hooks: { + beforeCreate: async (instance, options) => { + // If setting to true, unset all others + if (instance.default === true) { + await sequelize.models.legend.update( + {default: false}, + { + where: { + default: true, + }, + transaction: options.transaction, + }, + ); + } + }, + beforeUpdate: async (instance, options) => { + // If setting to true, unset all others + if (instance.changed('default') && instance.default === true) { + await sequelize.models.legend.update( + {default: false}, + { + where: { + default: true, + id: {[sequelize.Sequelize.Op.ne]: instance.id}, + }, + transaction: options.transaction, + }, + ); + } + }, + }, + }, + ); + + // set instance methods + legend.prototype.view = function (scope) { + if (scope === 'public') { + const { + id, deletedAt, updatedBy, ...publicView + } = this.dataValues; + return publicView; + } + return this; + }; + + // Ensure at least one default exists + legend.ensureDefaultExists = async function (options = {}) { + const {transaction} = options; + const hasDefault = await sequelize.models.legend.findOne({ + where: {default: true}, + transaction, + lock: transaction ? transaction.LOCK.UPDATE : undefined, + }); + + if (!hasDefault) { + const mostRecent = await sequelize.models.legend.findOne({ + order: [['createdAt', 'DESC']], + transaction, + lock: transaction ? transaction.LOCK.UPDATE : undefined, + }); + if (mostRecent) { + await mostRecent.update({default: true}, {transaction}); + } + } + }; + + legend.prototype.ensureDefaultExists = async function (options = {}) { + return legend.ensureDefaultExists(options); + }; + + return legend; +}; diff --git a/app/models/reports/genomic/summary/pathwayAnalysis.js b/app/models/reports/genomic/summary/pathwayAnalysis.js index d646941c0..d3a44d74c 100644 --- a/app/models/reports/genomic/summary/pathwayAnalysis.js +++ b/app/models/reports/genomic/summary/pathwayAnalysis.js @@ -12,6 +12,16 @@ module.exports = (sequelize, Sq) => { key: 'id', }, }, + // DEVSU-2310 batch 2: re-enable with the update-pathway-analysis-legend-fk migration + // legendId: { + // name: 'legendId', + // field: 'legend_id', + // type: Sq.INTEGER, + // references: { + // model: 'pathway_analysis_legends', + // key: 'id', + // }, + // }, pathway: { type: Sq.TEXT, allowNull: true, @@ -20,11 +30,6 @@ module.exports = (sequelize, Sq) => { schema: {format: 'svg', type: 'string'}, }, }, - legend: { - type: Sq.ENUM(['v1', 'v2', 'v3', 'custom']), - allowNull: false, - defaultValue: 'v3', - }, }, { ...DEFAULT_REPORT_OPTIONS, tableName: 'reports_summary_pathway_analysis', diff --git a/app/models/reports/report.js b/app/models/reports/report.js index 56521a35c..a1211c119 100644 --- a/app/models/reports/report.js +++ b/app/models/reports/report.js @@ -76,20 +76,6 @@ module.exports = (sequelize, Sq) => { }, allowNull: false, }, - seqQc: { - name: 'seqQc', - field: 'seq_qc', - type: Sq.JSONB, - jsonSchema: { - schema: { - type: 'array', - items: { - type: 'object', - }, - example: [{Reads: '2534M', bioQC: 'passed'}], - }, - }, - }, config: { type: Sq.TEXT, }, diff --git a/app/models/reports/smallMutations.js b/app/models/reports/smallMutations.js index 7f913c777..a97b4dfbd 100644 --- a/app/models/reports/smallMutations.js +++ b/app/models/reports/smallMutations.js @@ -134,6 +134,9 @@ module.exports = (sequelize, Sq) => { field: 'tumour_ref_copies', type: Sq.INTEGER, }, + exon: { + type: Sq.TEXT, + }, library: { type: Sq.TEXT, }, diff --git a/app/routes/index.js b/app/routes/index.js index 8faafb9f9..c1176134f 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -19,6 +19,7 @@ const notificationRoute = require('./notification'); const variantTextRoute = require('./variantText'); const templateRoute = require('./template'); const appendixRoute = require('./appendix'); +const legendRoute = require('./legend'); // Get module route files const RouterInterface = require('./routingInterface'); @@ -101,6 +102,9 @@ class Routing extends RouterInterface { // Get appendix routes this.router.use('/appendix', appendixRoute); + // Global legend routes + this.router.use('/legend', legendRoute); + return true; } } diff --git a/app/routes/legend/index.js b/app/routes/legend/index.js new file mode 100644 index 000000000..f91b7f508 --- /dev/null +++ b/app/routes/legend/index.js @@ -0,0 +1,145 @@ +const HTTP_STATUS = require('http-status-codes'); +const express = require('express'); + +const db = require('../../models'); +const logger = require('../../log'); +const {uploadLegendImage, updateLegendImage} = require('../report/images'); + +const router = express.Router({mergeParams: true}); + +// Middleware for legend lookup +router.param('legend', async (req, res, next, legendIdent) => { + let result; + try { + result = await db.models.legend.findOne({ + where: {ident: legendIdent}, + }); + } catch (error) { + logger.error(`Unable to lookup legend error: ${error.message}`); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Unable to lookup legend'}}); + } + + if (!result) { + logger.error(`Unable to find legend ${legendIdent}`); + return res.status(HTTP_STATUS.NOT_FOUND).json({error: {message: 'Unable to find the requested legend'}}); + } + + req.legend = result; + return next(); +}); + +router.route('/:legend([A-z0-9-]{36})') + .get((req, res) => { + return res.json(req.legend.view('public')); + }) + .put(async (req, res) => { + // Use the first uploaded file, if any, to replace the stored image + const [image] = req.files ? Object.values(req.files) : []; + + try { + await db.transaction(async (transaction) => { + if (image) { + await updateLegendImage(req.legend, image, {updates: req.body, userId: req.user.id, transaction}); + } else { + await req.legend.update(req.body, {userId: req.user.id, transaction}); + } + await db.models.legend.ensureDefaultExists({transaction}); + }); + await req.legend.reload(); + return res.json(req.legend.view('public')); + } catch (error) { + logger.error(`Error while updating legend image ${error}`); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR) + .json({error: {message: 'Error while updating legend image'}}); + } + }) + .delete(async (req, res) => { + const force = (req.query.force === 'true'); + const wasDefault = req.legend.default; + + try { + await db.transaction(async (transaction) => { + await req.legend.destroy({force, transaction}); + + // Only re-evaluate the default if we just removed the default one. + if (wasDefault) { + await db.models.legend.ensureDefaultExists({transaction}); + } + }); + return res.status(HTTP_STATUS.NO_CONTENT).send(); + } catch (error) { + logger.error(`Error while deleting legend image ${error}`); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR) + .json({error: {message: 'Error while deleting legend image'}}); + } + }); + +// Route for querying legend by numeric id +router.route('/:legendId(\\d+)') + .get(async (req, res) => { + try { + const legend = await db.models.legend.findByPk(req.params.legendId); + if (!legend) { + logger.error(`Unable to find legend with id ${req.params.legendId}`); + const msg = 'Unable to find the requested legend'; + return res.status(HTTP_STATUS.NOT_FOUND) + .json({error: {message: msg}}); + } + return res.json(legend.view('public')); + } catch (error) { + logger.error(`Unable to lookup legend by id error: ${error.message}`); + const msg = 'Unable to lookup legend'; + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR) + .json({error: {message: msg}}); + } + }); + +// Route for adding a legend image +router.route('/') + .get(async (req, res) => { + try { + const legends = await db.models.legend.findAll(); + return res.json(legends.map((legend) => {return legend.view('public');})); + } catch (error) { + logger.error(`Error while retrieving legend images ${error.message}`); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Error while retrieving legend images'}}); + } + }) + .post(async (req, res) => { + // Check that image files were uploaded + if (!req.files || Object.keys(req.files).length === 0) { + logger.error('No attached images to upload'); + return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: 'No attached images to upload'}}); + } + + try { + const results = []; + for (const [key, image] of Object.entries(req.files)) { + try { + // Set options (value or undefined) + const options = { + filename: image.name.trim(), + name: req.body.name || image.name.trim(), + default: req.body.default, + }; + + // Load image + await db.transaction(async (transaction) => { + await uploadLegendImage(image.data, {...options, transaction}); + await db.models.legend.ensureDefaultExists({transaction}); + }); + + // Return that this image was uploaded successfully + results.push({name: key, upload: 'successful'}); + } catch (error) { + results.push({name: key, upload: 'failed', error: {message: error.message}}); + } + } + return res.status(HTTP_STATUS.MULTI_STATUS).json(results); + } catch (error) { + logger.error(`Error while uploading images ${error.message}`); + return res.status(HTTP_STATUS.BAD_REQUEST).json({error: {message: `Error while uploading images ${error.message}`}}); + } + }); + +module.exports = router; diff --git a/app/routes/report/appendices.js b/app/routes/report/appendices.js index c554602ba..b77dcaa1a 100644 --- a/app/routes/report/appendices.js +++ b/app/routes/report/appendices.js @@ -13,7 +13,7 @@ router.route('/') try { const result = await db.models.report.findOne({ where: {ident: req.report.ident}, - attributes: ['seqQc', 'config'], + attributes: ['config'], include: ['seqQC'], }); return res.json(result); diff --git a/app/routes/report/images.js b/app/routes/report/images.js index 370475133..708dbda8b 100644 --- a/app/routes/report/images.js +++ b/app/routes/report/images.js @@ -47,6 +47,77 @@ const uploadReportImage = async (reportId, key, image, options = {}) => { } }; +/** + * Resize, reformat and upload a legend image to the pathway_analysis_legends table + * + * @param {Buffer|string} image - Buffer containing image data or the absolute path to the image file + * @param {object} options - An object containing additional image upload options + * + * @property {string} options.filename - An optional filename for the image + * @property {string} options.name - An optional name/version for the legend + * @property {boolean|string} options.default - Whether this legend is the default + * @property {object} options.transaction - An optional transaction to run the create under + * + * @returns {Promise} - Returns the created legend db entry + * @throws {Promise} - Something goes wrong with image processing and saving entry + */ +const uploadLegendImage = async (image, options = {}) => { + logger.verbose('Loading legend image'); + + const config = {format: DEFAULT_FORMAT, size: IMAGE_SIZE_LIMIT}; + + try { + const imageData = await processImage(image, config.size, config.format); + + return db.models.legend.create({ + format: config.format, + filename: options.filename, + name: options.name || options.filename, + data: imageData, + default: options.default, + }, {transaction: options.transaction}); + } catch (error) { + logger.error(`Error processing legend image ${options.filename} ${error}`); + throw new Error(`Error processing legend image ${options.filename} ${error}`); + } +}; + +/** + * Resize, reformat and replace the image on an existing legend entry + * + * @param {object} legend - The legend db instance to update + * @param {object} image - The uploaded file, containing a data buffer and name + * @param {object} options - An object containing additional update options + * + * @property {object} options.updates - Additional legend fields to update (e.g. name, default) + * @property {Number} options.userId - The id of the user performing the update + * @property {object} options.transaction - An optional transaction to run the update under + * + * @returns {Promise} - Returns the updated legend db entry + * @throws {Promise} - Something goes wrong with image processing and saving entry + */ +const updateLegendImage = async (legend, image, options = {}) => { + logger.verbose('Updating legend image'); + + const config = {format: DEFAULT_FORMAT, size: IMAGE_SIZE_LIMIT}; + + try { + const imageData = await processImage(image.data, config.size, config.format); + + return legend.update({ + ...options.updates, + format: config.format, + filename: image.name.trim(), + data: imageData, + }, {userId: options.userId, transaction: options.transaction}); + } catch (error) { + logger.error(`Error processing legend image ${image.name} ${error}`); + throw new Error(`Error processing legend image ${image.name} ${error}`); + } +}; + module.exports = { uploadReportImage, + uploadLegendImage, + updateLegendImage, }; diff --git a/app/routes/report/summary/pathwayAnalysis.js b/app/routes/report/summary/pathwayAnalysis.js index b0f959859..72234f21c 100644 --- a/app/routes/report/summary/pathwayAnalysis.js +++ b/app/routes/report/summary/pathwayAnalysis.js @@ -79,7 +79,7 @@ router.route('/') return res.json(req.pathwayAnalysis.view('public')); } catch (error) { logger.error(`Unable to update pathway analysis ${error}`); - return res.status().json({error: {message: 'Unable to update pathway analysis'}}); + return res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({error: {message: 'Unable to update pathway analysis'}}); } }) .delete(async (req, res) => { diff --git a/app/routes/report/therapeuticTargets.js b/app/routes/report/therapeuticTargets.js index 9c3829446..80ad07498 100644 --- a/app/routes/report/therapeuticTargets.js +++ b/app/routes/report/therapeuticTargets.js @@ -142,7 +142,7 @@ router.route('/') const {report: {id: reportId}, body} = req; try { await db.transaction(async (transaction) => { - return Promise.all(body.map((target) => { + await Promise.all(body.map((target) => { return db.models.therapeuticTarget.update( {rank: target.rank}, { @@ -155,6 +155,30 @@ router.route('/') }, ); })); + + await db.models.signatures.update({ + authorId: null, + authorSignedAt: null, + reviewerId: null, + reviewerSignedAt: null, + }, { + where: {reportId}, + individualHooks: true, + paranoid: true, + transaction, + userId: req.user.id, + }); + + // If the report was "reviewed" send it back to "ready" + await db.models.report.update({ + state: 'ready', + }, { + where: {id: reportId, state: 'reviewed'}, + individualHooks: true, + paranoid: true, + transaction, + userId: req.user.id, + }); }); return res.json({updated: true}); diff --git a/app/routes/swagger/swagger.json b/app/routes/swagger/swagger.json index e9fad861f..5edea12f4 100644 --- a/app/routes/swagger/swagger.json +++ b/app/routes/swagger/swagger.json @@ -4595,6 +4595,199 @@ } } }, + "/legend": { + "get": { + "summary": "Get Legend Images", + "description": "Retrieve all legend images", + "parameters": [ + { + "$ref": "#/components/parameters/legend" + } + ], + "tags": [ + "Legends" + ], + "security": [ + { + "basicAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns the details of all legend images" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "description": "Legends not found" + } + } + }, + "post": { + "summary": "Add Legend Images", + "description": "Upload new legend images to report", + "parameters": [], + "requestBody": { + "description": "Legend images to upload (allows multiple images with different versions)", + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "legend name / version (e.g. v1, v2, etc.)" + }, + "default": { + "type": "boolean", + "description": "whether this legend image is the default" + } + } + } + } + } + }, + "tags": [ + "Legends" + ], + "security": [ + { + "basicAuth": [] + } + ], + "responses": { + "207": { + "description": "Returns an array of objects indicating which legend images were uploaded successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "upload": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "description": "Malformed request syntax or no files attached" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "description": "Legend not found" + } + } + } + }, + "/legend/{legend}": { + "get": { + "summary": "Get Legend Image", + "description": "Retrieve a specific legend image", + "parameters": [ + { + "$ref": "#/components/parameters/legend" + } + ], + "tags": [ + "Legends" + ], + "security": [ + { + "basicAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns the details of the specified legend image" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "description": "Legend not found" + } + } + }, + "delete": { + "summary": "Delete Legend Image", + "description": "Removes the specified legend image", + "parameters": [ + { + "$ref": "#/components/parameters/legend" + }, + { + "name": "force", + "in": "query", + "required": false, + "description": "Whether to do a hard delete or not", + "schema": { + "type": "boolean" + } + } + ], + "tags": [ + "Legends" + ], + "security": [ + { + "basicAuth": [] + } + ], + "responses": { + "204": { + "description": "No content" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "description": "legend not found" + } + } + } + }, + "/legend/{legendId}": { + "get": { + "summary": "Get Legend Image by ID", + "description": "Retrieve a specific legend image by numeric ID", + "parameters": [ + { + "$ref": "#/components/parameters/legendId" + } + ], + "tags": [ + "Legends" + ], + "security": [ + { + "basicAuth": [] + } + ], + "responses": { + "200": { + "description": "Returns the details of the specified legend image" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedError" + }, + "404": { + "description": "Legend not found" + } + } + } + }, "/reports/{report}/kb-matches": { "get": { "summary": "Get KB Matches", @@ -8837,6 +9030,25 @@ "format": "UUIDv4" } }, + "legend": { + "name": "legend", + "in": "path", + "required": true, + "description": "legend image ident", + "schema": { + "type": "string", + "format": "UUIDv4" + } + }, + "legendId": { + "name": "legendId", + "in": "path", + "required": true, + "description": "legend image ID", + "schema": { + "type": "integer" + } + }, "alteration": { "name": "alteration", "in": "path", diff --git a/app/routes/user/settings.js b/app/routes/user/settings.js index 336f9b70c..06105cd67 100644 --- a/app/routes/user/settings.js +++ b/app/routes/user/settings.js @@ -21,11 +21,14 @@ router.route('/') }) .put(async (req, res) => { try { - await db.models.userMetadata.update({settings: req.body}, { + await db.models.userMetadata.update({ + settings: req.body, + updatedBy: req.user.id, + }, { where: { user_id: req.user.id, }, - fields: ['settings'], + fields: ['settings', 'updatedBy'], hooks: false, limit: 1, }); diff --git a/app/routes/user/user.js b/app/routes/user/user.js index e2cf8e2cd..f5e0e9ba0 100644 --- a/app/routes/user/user.js +++ b/app/routes/user/user.js @@ -260,9 +260,15 @@ router.route('/') // Create transaction transaction = await db.transaction(); // Create user - const createdUser = await db.models.user.create(req.body, {transaction}); + const createdUser = await db.models.user.create({ + ...req.body, + updatedBy: req.user.id, + }, {transaction}); // Create user metadata - await db.models.userMetadata.create({userId: createdUser.id}, {transaction}); + await db.models.userMetadata.create({ + userId: createdUser.id, + updatedBy: req.user.id, + }, {transaction}); // Commit changes await transaction.commit(); // Return new user diff --git a/demo/ipr_demodb.postgres.dump b/demo/ipr_demodb.postgres.dump index bde88c4d5..da2bcd22d 100644 Binary files a/demo/ipr_demodb.postgres.dump and b/demo/ipr_demodb.postgres.dump differ diff --git a/migrationTools/migratedb.sh b/migrationTools/migratedb.sh index c38602468..51b9c2c06 100644 --- a/migrationTools/migratedb.sh +++ b/migrationTools/migratedb.sh @@ -5,9 +5,30 @@ echo "Migrating against: $IPR_DATABASE_NAME" parent_folder="migrations" -mapfile -t subdirs < <(find "$parent_folder" -mindepth 1 -maxdepth 1 -type d) +run_migration() { + local migration_path=$1 + echo "$migration_path" + npx sequelize-cli db:migrate --migrations-path "$migration_path" --url "postgres://$IPR_SERVICE_USER:$IPR_SERVICE_PASS@$IPR_DATABASE_SERVER/$IPR_DATABASE_NAME" +} -for dir in "${subdirs[@]}"; do - echo "$dir" - npx sequelize-cli db:migrate --migrations-path "$dir" --url "postgres://$IPR_SERVICE_USER:$IPR_SERVICE_PASS@$IPR_DATABASE_SERVER/$IPR_DATABASE_NAME" +# Handle latest migrations (check for batch subdirectories first) +if [ -d "$parent_folder/latest" ]; then + latest_batches=$(find "$parent_folder/latest" -maxdepth 1 -type d ! -name "latest" | sort) + if [ -n "$latest_batches" ]; then + # Run batch migrations in order + while IFS= read -r batch_dir; do + run_migration "$batch_dir" + done <<< "$latest_batches" + fi + # Also run non-batched migrations in latest (if any exist) + if [ -n "$(find "$parent_folder/latest" -maxdepth 1 -name '*.js' -type f)" ]; then + run_migration "$parent_folder/latest" + fi +fi + +# Handle other migration directories (legacy, etc.) +mapfile -t other_dirs < <(find "$parent_folder" -mindepth 1 -maxdepth 1 -type d ! -name "latest") + +for dir in "${other_dirs[@]}"; do + run_migration "$dir" done diff --git a/migrations/v8.6.0/20260421000000-DEVSU-2310-create-legend-table.js b/migrations/v8.6.0/20260421000000-DEVSU-2310-create-legend-table.js new file mode 100644 index 000000000..ebc48e01c --- /dev/null +++ b/migrations/v8.6.0/20260421000000-DEVSU-2310-create-legend-table.js @@ -0,0 +1,38 @@ +const TABLE = 'pathway_analysis_legends'; +const {DEFAULT_COLUMNS} = require('../../app/models/base'); + +module.exports = { + up: (queryInterface, Sq) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.createTable(TABLE, { + ...DEFAULT_COLUMNS, + format: { + type: Sq.ENUM('PNG', 'JPG', 'SVG'), + defaultValue: 'PNG', + }, + filename: { + type: Sq.TEXT, + allowNull: false, + }, + name: { + type: Sq.TEXT, + allowNull: false, + }, + data: { + type: Sq.TEXT, + allowNull: false, + }, + default: { + type: Sq.BOOLEAN, + defaultValue: false, + }, + }, {transaction}); + }); + }, + + down: (queryInterface) => { + return queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.dropTable(TABLE, {transaction}); + }); + }, +}; diff --git a/migrations/v8.6.0/20260513214647-DEVSU-2914-add-exon-column-to-small-mutations.js b/migrations/v8.6.0/20260513214647-DEVSU-2914-add-exon-column-to-small-mutations.js new file mode 100644 index 000000000..97fb0dc28 --- /dev/null +++ b/migrations/v8.6.0/20260513214647-DEVSU-2914-add-exon-column-to-small-mutations.js @@ -0,0 +1,13 @@ +const TABLE = 'reports_small_mutations'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn(TABLE, 'exon', { + type: Sequelize.TEXT, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn(TABLE, 'exon'); + }, +}; diff --git a/migrations/v8.6.0/20260519215459-DEVSU-2928-datafix-seqqc.js b/migrations/v8.6.0/20260519215459-DEVSU-2928-datafix-seqqc.js new file mode 100644 index 000000000..e4a73d317 --- /dev/null +++ b/migrations/v8.6.0/20260519215459-DEVSU-2928-datafix-seqqc.js @@ -0,0 +1,92 @@ +const {v4: uuidv4} = require('uuid'); + +module.exports = { + up: async (queryInterface) => { + await queryInterface.sequelize.transaction(async (transaction) => { + const reports = await queryInterface.sequelize.query( + ` + SELECT id, seq_qc + FROM reports + WHERE seq_qc IS NOT NULL; + `, + { + type: queryInterface.sequelize.QueryTypes.SELECT, + transaction, + }, + ); + + const now = new Date(); + const rows = []; + + for (const report of reports) { + for (const seqQCItem of report.seq_qc) { + rows.push({ + ident: uuidv4(), + created_at: now, + updated_at: now, + report_id: report.id, + reads: seqQCItem.Reads, + bio_qc: seqQCItem.bioQC, + lab_qc: seqQCItem.labQC, + sample: seqQCItem.Sample, + library: seqQCItem.Library, + coverage: seqQCItem.Coverage, + input_ng: seqQCItem.Input_ng, + input_ug: seqQCItem.Input_ug, + protocol: seqQCItem.Protocol, + sample_name: seqQCItem['Sample Name'], + duplicate_reads_perc: seqQCItem.Duplicate_Reads_Perc, + }); + } + } + + if (rows.length !== 0) { + await queryInterface.bulkInsert('reports_seqqc', rows, {transaction}); + } + + await queryInterface.removeColumn('reports', 'seq_qc', {transaction}); + }); + }, + + down: async (queryInterface, Sq) => { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.addColumn('reports', 'seq_qc', { + type: Sq.JSONB, + defaultValue: null, + }, {transaction}); + + await queryInterface.sequelize.query(` + WITH restored_seq_qc AS ( + SELECT + report_id, + jsonb_agg(jsonb_build_object( + 'Reads', reads, + 'bioQC', bio_qc, + 'labQC', lab_qc, + 'Sample', sample, + 'Library', library, + 'Coverage', coverage, + 'Input_ng', input_ng, + 'Input_ug', input_ug, + 'Protocol', protocol, + 'Sample Name', sample_name, + 'Duplicate_Reads_Perc', duplicate_reads_perc + )) AS seq_qc + FROM reports_seqqc + GROUP BY report_id + ) + UPDATE reports r + SET seq_qc = restored_seq_qc.seq_qc + FROM restored_seq_qc + WHERE r.id = restored_seq_qc.report_id; + `, {transaction}); + + await queryInterface.sequelize.query(` + DELETE FROM reports_seqqc rs + USING reports r + WHERE rs.report_id = r.id + AND r.seq_qc IS NOT NULL; + `, {transaction}); + }); + }, +}; diff --git a/package-lock.json b/package-lock.json index 2fd5e8df5..1d60f9ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ipr-api", - "version": "8.5.0", + "version": "8.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ipr-api", - "version": "8.4.0", + "version": "8.6.0", "license": "GPL-3.0", "dependencies": { "@alt3/sequelize-to-json-schemas": "^0.3.56", diff --git a/package.json b/package.json index 7c3386d27..24c683f8a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "ipr-api", - "version": "8.5.0", + "version": "8.6.0", "description": "Integrated Pipeline Reports API", "main": "bin/server.js", "scripts": { diff --git a/test/routes/legend/legend.test.js b/test/routes/legend/legend.test.js new file mode 100644 index 000000000..848829dcc --- /dev/null +++ b/test/routes/legend/legend.test.js @@ -0,0 +1,282 @@ +const HTTP_STATUS = require('http-status-codes'); +const supertest = require('supertest'); +const getPort = require('get-port'); +const db = require('../../../app/models'); + +const CONFIG = require('../../../app/config'); +const {listen} = require('../../../app'); + +CONFIG.set('env', 'test'); +const {username, password} = CONFIG.get('testing'); + +let server; +let request; + +const legendProperties = [ + 'ident', 'createdAt', 'updatedAt', 'format', 'filename', + 'name', 'data', 'default', +]; + +const checkLegend = (legendObject) => { + legendProperties.forEach((field) => { + expect(legendObject).toHaveProperty(field); + }); + expect(legendObject).toEqual(expect.not.objectContaining({ + id: expect.any(Number), + reportId: expect.any(Number), + deletedAt: expect.any(String), + })); +}; + +// Start API +beforeAll(async () => { + const port = await getPort({port: CONFIG.get('web:port')}); + server = await listen(port); + request = supertest(server); +}); + +describe('/legend', () => { + let mockLegendData; + + const buildLegendData = (overrides = {}) => { + return { + filename: 'pathway_legend_v1.png', + name: 'v1', + data: 'v1Data', + default: false, + ...overrides, + }; + }; + + beforeAll(async () => { + // Create legend + mockLegendData = buildLegendData(); + }); + + afterEach(async () => { + await db.models.legend.destroy({ + where: {}, + force: true, + }); + }); + + describe('GET', () => { + test('/{legend} - 200 Success', async () => { + // Create legend + const legend = await db.models.legend.create(mockLegendData); + + const res = await request + .get(`/api/legend/${legend.ident}`) + .auth(username, password) + .type('json') + .expect(HTTP_STATUS.OK); + + // Check that all fields are present and that data is correct + checkLegend(res.body); + + expect(res.body).toEqual(expect.objectContaining(mockLegendData)); + }); + + test('/{legend} - 404 Not Found', async () => { + await request + .get('/api/legend/00000000-0000-0000-0000-000000000000') + .auth(username, password) + .type('json') + .expect(HTTP_STATUS.NOT_FOUND); + }); + }); + + describe('POST', () => { + test('POST / - 207 Multi-Status successful', async () => { + const res = await request + .post('/api/legend') + .attach('v1', 'test/testData/images/pathway_legend_v1.png') + .field('name', 'v1') + .auth(username, password) + .expect(HTTP_STATUS.MULTI_STATUS); + + // Check returned values match successful upload + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + + const [result] = res.body; + + expect(result.name).toBe('v1'); + expect(result.upload).toBe('successful'); + expect(result.error).toBe(undefined); + + const legend = await db.models.legend.findOne({where: {name: 'v1'}}); + expect(legend).toEqual(expect.objectContaining({ + format: 'PNG', + filename: 'pathway_legend_v1.png', + name: 'v1', + default: true, + })); + }); + + test('POST / - default=true promotes new legend as only default', async () => { + const currentDefault = await db.models.legend.create(buildLegendData({default: true})); + + const res = await request + .post('/api/legend') + .attach('golden', 'test/testData/images/golden.jpg') + .field('name', 'Golden Test') + .field('default', 'true') + .auth(username, password) + .expect(HTTP_STATUS.MULTI_STATUS); + + // Check returned values match successful upload + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + + const [result] = res.body; + + expect(result.name).toBe('golden'); + expect(result.upload).toBe('successful'); + expect(result.error).toBe(undefined); + + const [newDefault, oldDefault] = await Promise.all([ + db.models.legend.findOne({where: {name: 'Golden Test'}}), + db.models.legend.findByPk(currentDefault.id), + ]); + + expect(newDefault.default).toBe(true); + expect(oldDefault.default).toBe(false); + }); + + test('POST / - 400 Bad Request no files', async () => { + const res = await request + .post('/api/legend') + .auth(username, password) + .expect(HTTP_STATUS.BAD_REQUEST); + + expect(res.body.error).toEqual(expect.objectContaining({ + message: 'No attached images to upload', + })); + }); + }); + + describe('DELETE', () => { + let legend; + + beforeEach(async () => { + // Create legend + legend = await db.models.legend.create(mockLegendData); + }); + + test('/{legend} - 204 No Content', async () => { + await request + .delete(`/api/legend/${legend.ident}`) + .auth(username, password) + .expect(HTTP_STATUS.NO_CONTENT); + + // Check that legend was deleted + const deletedLegend = await db.models.legend.findOne({ + where: {id: legend.id}, + paranoid: false, + }); + + // Expect nothing to be returned + expect(deletedLegend).toBeNull(); + }); + + test('/{legend} - 204 No Content - Hard delete', async () => { + await request + .delete(`/api/legend/${legend.ident}?force=true`) + .auth(username, password) + .expect(HTTP_STATUS.NO_CONTENT); + + // Check that legend was hard deleted + const deletedLegend = await db.models.legend.findOne({ + where: {id: legend.id}, + paranoid: false, + }); + + // Expect nothing to be returned + expect(deletedLegend).toBeNull(); + }); + + test('/{legend} - deleting the only default assigns default to most recent remaining legend', async () => { + const defaultLegend = await db.models.legend.create(buildLegendData({default: true})); + const mostRecentLegend = await db.models.legend.create(buildLegendData({default: false})); + + await request + .delete(`/api/legend/${defaultLegend.ident}`) + .auth(username, password) + .expect(HTTP_STATUS.NO_CONTENT); + + const updatedMostRecent = await db.models.legend.findByPk(mostRecentLegend.id); + expect(updatedMostRecent.default).toBe(true); + }); + }); + + describe('PUT', () => { + test('/{legend} - setting a legend to default=true unsets previous default', async () => { + const firstLegend = await db.models.legend.create(buildLegendData({default: true})); + const secondLegend = await db.models.legend.create(buildLegendData({default: false})); + + await request + .put(`/api/legend/${secondLegend.ident}`) + .send({default: true}) + .auth(username, password) + .type('json') + .expect(HTTP_STATUS.OK); + + const [updatedFirstLegend, updatedSecondLegend] = await Promise.all([ + db.models.legend.findByPk(firstLegend.id), + db.models.legend.findByPk(secondLegend.id), + ]); + + expect(updatedSecondLegend.default).toBe(true); + expect(updatedFirstLegend.default).toBe(false); + }); + + test('/{legend} - uploading a new image replaces the stored image', async () => { + const legend = await db.models.legend.create( + buildLegendData({data: 'oldData', filename: 'old.png'}), + ); + + const res = await request + .put(`/api/legend/${legend.ident}`) + .attach('image', 'test/testData/images/golden.jpg') + .field('name', 'updated name') + .auth(username, password) + .expect(HTTP_STATUS.OK); + + checkLegend(res.body); + expect(res.body.name).toBe('updated name'); + expect(res.body.filename).toBe('golden.jpg'); + expect(res.body.format).toBe('PNG'); + expect(res.body.data).not.toBe('oldData'); + + // The replacement is persisted, not just reflected in the response + const updated = await db.models.legend.findByPk(legend.id); + expect(updated.data).not.toBe('oldData'); + expect(updated.filename).toBe('golden.jpg'); + }); + + test('/{legend} - metadata-only update preserves the existing image', async () => { + const legend = await db.models.legend.create( + buildLegendData({data: 'keepThisData', name: 'original'}), + ); + + const res = await request + .put(`/api/legend/${legend.ident}`) + .send({name: 'renamed'}) + .auth(username, password) + .type('json') + .expect(HTTP_STATUS.OK); + + expect(res.body.name).toBe('renamed'); + expect(res.body.data).toBe('keepThisData'); + + const updated = await db.models.legend.findByPk(legend.id); + expect(updated.data).toBe('keepThisData'); + }); + }); +}); + +afterAll(async () => { + global.gc && global.gc(); + await server.close(); +}); diff --git a/test/routes/report/summary/pathwayAnalysis.test.js b/test/routes/report/summary/pathwayAnalysis.test.js index 837a1c69d..4c469997f 100644 --- a/test/routes/report/summary/pathwayAnalysis.test.js +++ b/test/routes/report/summary/pathwayAnalysis.test.js @@ -12,8 +12,10 @@ const {username, password} = CONFIG.get('testing'); let server; let request; -const pathwayProperties = ['ident', 'createdAt', 'updatedAt', 'pathway', 'legend']; +const pathwayProperties = ['ident', 'createdAt', 'updatedAt', 'pathway', 'legendId']; +// DEVSU-2310 batch 2: only used by the tests marked test.todo below; drop this disable when they return +// eslint-disable-next-line no-unused-vars const checkPathwayAnalysis = (pathwayObject) => { pathwayProperties.forEach((element) => { expect(pathwayObject).toHaveProperty(element); @@ -32,6 +34,7 @@ beforeAll(async () => { describe('/reports/{report}/summary/pathway-analysis', () => { let report; + let legend; beforeAll(async () => { // Get genomic template @@ -41,6 +44,13 @@ describe('/reports/{report}/summary/pathway-analysis', () => { templateId: template.id, patientId: 'TESTPATIENT1234', }); + // Create legend for pathway analysis tests + legend = await db.models.legend.create({ + filename: 'pathway_legend_v1.png', + name: 'v1', + data: 'v1Data', + default: true, + }); }); describe('GET', () => { @@ -57,16 +67,18 @@ describe('/reports/{report}/summary/pathway-analysis', () => { await db.models.pathwayAnalysis.destroy({where: {ident: pathwayAnalysis.ident}, force: true}); }); - test('/ - 200 Success', async () => { - const res = await request - .get(`/api/reports/${report.ident}/summary/pathway-analysis`) - .auth(username, password) - .type('json') - .expect(HTTP_STATUS.OK); - - checkPathwayAnalysis(res.body); - expect(res.body.ident).toBe(pathwayAnalysis.ident); - }); + // DEVSU-2310 batch 2: re-enable with the update-pathway-analysis-legend-fk migration + test.todo('/ - 200 Success'); + // test('/ - 200 Success', async () => { + // const res = await request + // .get(`/api/reports/${report.ident}/summary/pathway-analysis`) + // .auth(username, password) + // .type('json') + // .expect(HTTP_STATUS.OK); + // + // checkPathwayAnalysis(res.body); + // expect(res.body.ident).toBe(pathwayAnalysis.ident); + // }); }); describe('PUT', () => { @@ -83,27 +95,29 @@ describe('/reports/{report}/summary/pathway-analysis', () => { await db.models.pathwayAnalysis.destroy({where: {ident: pathwayAnalysis.ident}, force: true}); }); - test('/ - 200 Success', async () => { - const res = await request - .put(`/api/reports/${report.ident}/summary/pathway-analysis`) - .auth(username, password) - .type('json') - .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legend', 'v2') - .expect(HTTP_STATUS.OK); - - checkPathwayAnalysis(res.body); - - expect(res.body.pathway).not.toBeNull(); - expect(res.body.legend).toBe('v2'); - }); - - test('/ - 400 Bad request - Invalid legend', async () => { + // DEVSU-2310 batch 2: re-enable with the update-pathway-analysis-legend-fk migration + test.todo('/ - 200 Success'); + // test('/ - 200 Success', async () => { + // const res = await request + // .put(`/api/reports/${report.ident}/summary/pathway-analysis`) + // .auth(username, password) + // .type('json') + // .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') + // .field('legendId', legend.id) + // .expect(HTTP_STATUS.OK); + // + // checkPathwayAnalysis(res.body); + // + // expect(res.body.pathway).not.toBeNull(); + // expect(res.body.legendId).toBe(legend.id); + // }); + + test('/ - 400 Bad request - Invalid legend fk', async () => { await request .put(`/api/reports/${report.ident}/summary/pathway-analysis`) .auth(username, password) .type('json') - .send({legend: 'Not valid legend'}) + .send({legendId: 'Not valid legend id'}) .expect(HTTP_STATUS.BAD_REQUEST); }); @@ -113,7 +127,7 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/golden.jpg') - .field('legend', 'v1') + .field('legendId', legend.id) .expect(HTTP_STATUS.BAD_REQUEST); }); @@ -167,31 +181,33 @@ describe('/reports/{report}/summary/pathway-analysis', () => { }); describe('POST', () => { - test('/ - 201 Created', async () => { - const res = await request - .post(`/api/reports/${report.ident}/summary/pathway-analysis`) - .auth(username, password) - .type('json') - .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legend', 'custom') - .expect(HTTP_STATUS.CREATED); - - checkPathwayAnalysis(res.body); - - expect(res.body.pathway).not.toBeNull(); - expect(res.body.legend).toBe('custom'); - - // Remove pathway analysis - await db.models.pathwayAnalysis.destroy({where: {ident: res.body.ident}}); - }); - - test('/ - 400 Bad request - Invalid legend', async () => { + // DEVSU-2310 batch 2: re-enable with the update-pathway-analysis-legend-fk migration + test.todo('/ - 201 Created'); + // test('/ - 201 Created', async () => { + // const res = await request + // .post(`/api/reports/${report.ident}/summary/pathway-analysis`) + // .auth(username, password) + // .type('json') + // .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') + // .field('legendId', legend.id) + // .expect(HTTP_STATUS.CREATED); + // + // checkPathwayAnalysis(res.body); + // + // expect(res.body.pathway).not.toBeNull(); + // expect(res.body.legendId).toBe(legend.id); + // + // // Remove pathway analysis + // await db.models.pathwayAnalysis.destroy({where: {ident: res.body.ident}}); + // }); + + test('/ - 400 Bad request - Invalid legend id', async () => { await request .post(`/api/reports/${report.ident}/summary/pathway-analysis`) .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legend', 'Not valid legend') + .field('legendId', 'Not valid legend id') .expect(HTTP_STATUS.BAD_REQUEST); }); @@ -201,7 +217,7 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/golden.jpg') - .field('legend', 'v1') + .field('legendId', legend.id) .expect(HTTP_STATUS.BAD_REQUEST); }); @@ -216,7 +232,7 @@ describe('/reports/{report}/summary/pathway-analysis', () => { .auth(username, password) .type('json') .attach('pathway', 'test/testData/images/pathwayAnalysisData.svg') - .field('legend', 'v2') + .field('legendId', legend.id) .expect(HTTP_STATUS.CONFLICT); // Remove pathway analysis @@ -226,6 +242,7 @@ describe('/reports/{report}/summary/pathway-analysis', () => { // Delete report afterAll(async () => { + await db.models.legend.destroy({where: {id: legend.id}, force: true}); await db.models.report.destroy({where: {id: report.id}, force: true}); }); }); diff --git a/test/routes/report/therapeuticTargets.test.js b/test/routes/report/therapeuticTargets.test.js index b0187a024..ce5038f48 100644 --- a/test/routes/report/therapeuticTargets.test.js +++ b/test/routes/report/therapeuticTargets.test.js @@ -390,6 +390,76 @@ describe('/therapeutic-targets', () => { .type('json') .expect(HTTP_STATUS.BAD_REQUEST); }, LONGER_TIMEOUT); + + describe('rank update and report signatures', () => { + let signature; + let user; + let originalState; + + beforeEach(async () => { + user = await db.models.user.findOne({where: {username}}); + signature = await db.models.signatures.create({ + reportId: report.id, + authorId: user.id, + authorSignedAt: new Date(), + reviewerId: user.id, + reviewerSignedAt: new Date(), + creatorId: user.id, + creatorSignedAt: new Date(), + }); + // state is excluded from signature removal, so setting it + // here does not wipe the signature we just created + ({state: originalState} = await db.models.report.findOne({ + where: {id: report.id}, + })); + await db.models.report.update( + {state: 'reviewed'}, + {where: {id: report.id}, hooks: false}, + ); + }); + + afterEach(async () => { + if (signature) { + await db.models.signatures.destroy({ + where: {ident: signature.ident}, + force: true, + }); + } + // restore the original state - report.state is allowNull: false + await db.models.report.update( + {state: originalState}, + {where: {id: report.id}, hooks: false}, + ); + }); + + test('reorder revokes author + reviewer signatures, keeps creator', async () => { + await request + .put(`/api/reports/${report.ident}/therapeutic-targets`) + .auth(username, password) + .send([ + {ident: originalGene.ident, rank: newTarget.rank}, + {ident: newTarget.ident, rank: originalGene.rank}, + ]) + .type('json') + .expect(HTTP_STATUS.OK); + + const updatedSignature = await db.models.signatures.findOne({ + where: {reportId: report.id}, + }); + expect(updatedSignature.authorId).toBe(null); + expect(updatedSignature.authorSignedAt).toBe(null); + expect(updatedSignature.reviewerId).toBe(null); + expect(updatedSignature.reviewerSignedAt).toBe(null); + // creator signature is intentionally preserved + expect(updatedSignature.creatorId).toBe(user.id); + expect(updatedSignature.creatorSignedAt).not.toBe(null); + + const updatedReport = await db.models.report.findOne({ + where: {id: report.id}, + }); + expect(updatedReport.state).toBe('ready'); + }, LONGER_TIMEOUT); + }); }); test.todo('Bad request on update and set gene to null'); diff --git a/test/routes/user/settings.test.js b/test/routes/user/settings.test.js index 18c434955..62a327d85 100644 --- a/test/routes/user/settings.test.js +++ b/test/routes/user/settings.test.js @@ -68,6 +68,7 @@ describe('/user/settings', () => { where: {userId: testUser.id}, }); expect(results.length).toBe(1); + expect(results[0].updatedBy).toBe(testUser.id); }); }); }); diff --git a/test/routes/user/user.test.js b/test/routes/user/user.test.js index 4d2d6ba04..f62e2d029 100644 --- a/test/routes/user/user.test.js +++ b/test/routes/user/user.test.js @@ -46,6 +46,7 @@ beforeAll(async () => { // Tests for user related endpoints describe('/user', () => { let testUser; + let managerUser; let adminGroup; beforeAll(async () => { @@ -53,6 +54,9 @@ describe('/user', () => { testUser = await db.models.user.findOne({ where: {username}, }); + managerUser = await db.models.user.findOne({ + where: {username: managerUsername}, + }); adminGroup = await db.models.userGroup.findOne({ where: {name: 'admin'}, }); @@ -162,6 +166,16 @@ describe('/user', () => { }) .expect(HTTP_STATUS.CREATED); + const createdUser = await db.models.user.findOne({ + where: {ident: res.body.ident}, + }); + const createdMetadata = await db.models.userMetadata.findOne({ + where: {userId: createdUser.id}, + }); + + expect(createdUser.updatedBy).toBe(testUser.id); + expect(createdMetadata.updatedBy).toBe(testUser.id); + // Remove test user from db await db.models.user.destroy({where: {ident: res.body.ident}, force: true}); }); @@ -180,6 +194,16 @@ describe('/user', () => { }) .expect(HTTP_STATUS.CREATED); + const createdUser = await db.models.user.findOne({ + where: {ident: res.body.ident}, + }); + const createdMetadata = await db.models.userMetadata.findOne({ + where: {userId: createdUser.id}, + }); + + expect(createdUser.updatedBy).toBe(managerUser.id); + expect(createdMetadata.updatedBy).toBe(managerUser.id); + // Remove test user from db await db.models.user.destroy({where: {ident: res.body.ident}, force: true}); }); diff --git a/test/testData/mockReportData.json b/test/testData/mockReportData.json index 69a237c0b..9777724de 100644 --- a/test/testData/mockReportData.json +++ b/test/testData/mockReportData.json @@ -123,8 +123,7 @@ } ], "pathwayAnalysis": { - "pathway": "\nimage/svg+xmlGENOMIC CASE NOTES\nPATHWAY VISUALIZATION\nPancreas Cancer \nTissue Comparator: average\nDisease Comparator: PAAD\n\n none\n NOTCH1\n\n Node NOTCH1\n 99\npercentile=99;foldchange=2.35;copychange=0HES4\n\n Node HES4\n 100\npercentile=100;foldchange=25.21;copychange=-1+4\nNOTCH2\n\n Node NOTCH2\n 100\npercentile=100;foldchange=2.84;copychange=4NOTCH\nGUCA2A\n\n Node GUCA2A\n 99\npercentile=99;foldchange=30.23;copychange=-1SMAD2\n6\nSMAD4\nPDAC related genes\nRRM2\n\n Node RRM2\n percentile=18;foldchange=2.63;copychange=0RRM1\n\n Node RRM1\n percentile=54;foldchange=1.02;copychange=0CD274\n\n Node CD274\n percentile=46;foldchange=-3.03;copychange=0CTLA4\n\n Node CTLA4\n percentile=20;foldchange=-1.48;copychange=0PDCD1\n\n Node PDCD1\n percentile=27;foldchange=-1.18;copychange=0Immune Therapy\n\n none\n \n none\n MTOR\n\n Node MTOR\n percentile=36;foldchange=-1.47;copychange=-1PTEN\n\n Node PTEN\n 2\npercentile=2;foldchange=-2.1;copychange=0PIK3CA\n\n Node PIK3CA\n percentile=17;foldchange=-2.03;copychange=0PI3K/MTOR\nTP53\n\n Node TP53\n 92\npercentile=92;foldchange=1.47;copychange=0FANCD2\n\n Node FANCD2\n mutations=p.E803K;percentile=67;foldchange=1.33;copychange=0RAD50\n\n Node RAD50\n percentile=53;foldchange=-1.31;copychange=-1FAM175A\n\n Node FAM175A\n percentile=17;foldchange=-2.02;copychange=0BRCA1\n\n Node BRCA1\n 95\npercentile=95;foldchange=1.28;copychange=0BRCA2\n\n Node BRCA2\n percentile=89;foldchange=1.16;copychange=0Homologous recombination\nERBB4\n\n Node ERBB4\n percentile=31;foldchange=-2.6;copychange=0IGF2\n\n Node IGF2\n 97\npercentile=97;foldchange=1.56;copychange=0+2\nERBB2\n\n Node ERBB2\n percentile=45;foldchange=6.08;copychange=2EGFR\n\n Node EGFR\n percentile=90;foldchange=-1.06;copychange=0FGFR2\n\n Node FGFR2\n 100\npercentile=100;foldchange=3.61;copychange=0FGFR4\n\n Node FGFR4\n 98\npercentile=98;foldchange=13.39;copychange=-1FGFR3\n\n Node FGFR3\n 99\npercentile=99;foldchange=14.12;copychange=0ERBB3\n\n Node ERBB3\n 93\npercentile=93;foldchange=9.73;copychange=0RTKs\nNRG1\n\n Node NRG1\n 100\nATP1B1\nstructural-variants=translocation(ATP1B1,NRG1) and translocation(NRG1,ATP1B1);percentile=100;foldchange=39.25;copychange=-1\n pubmed_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318;pubmed_MAP kinase signalling pathways in cancer_17496922;pmcid_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318\n \n pubmed_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318;pubmed_MAP kinase signalling pathways in cancer_17496922;pmcid_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318\n \n pubmed_MAP kinase signalling pathways in cancer_17496922\n MAP2K2\n\n Node MAP2K2\n percentile=73;foldchange=1.8;copychange=0BRAF\n\n Node BRAF\n 97\npercentile=97;foldchange=-1.25;copychange=0KRAS\n\n Node KRAS\n 6\npercentile=6;foldchange=-2.05;copychange=0MAP2K1\n\n Node MAP2K1\n 3\npercentile=3;foldchange=-1.35;copychange=1MAPK\nCEBPA\n\n Node CEBPA\n 92\npercentile=92;foldchange=-1.05;copychange=0JUN\n\n Node JUN\n hom\nmutations=p.S37fs;percentile=65;foldchange=1.18;copychange=-1", - "legend": "v2" + "pathway": "\nimage/svg+xmlGENOMIC CASE NOTES\nPATHWAY VISUALIZATION\nPancreas Cancer \nTissue Comparator: average\nDisease Comparator: PAAD\n\n none\n NOTCH1\n\n Node NOTCH1\n 99\npercentile=99;foldchange=2.35;copychange=0HES4\n\n Node HES4\n 100\npercentile=100;foldchange=25.21;copychange=-1+4\nNOTCH2\n\n Node NOTCH2\n 100\npercentile=100;foldchange=2.84;copychange=4NOTCH\nGUCA2A\n\n Node GUCA2A\n 99\npercentile=99;foldchange=30.23;copychange=-1SMAD2\n6\nSMAD4\nPDAC related genes\nRRM2\n\n Node RRM2\n percentile=18;foldchange=2.63;copychange=0RRM1\n\n Node RRM1\n percentile=54;foldchange=1.02;copychange=0CD274\n\n Node CD274\n percentile=46;foldchange=-3.03;copychange=0CTLA4\n\n Node CTLA4\n percentile=20;foldchange=-1.48;copychange=0PDCD1\n\n Node PDCD1\n percentile=27;foldchange=-1.18;copychange=0Immune Therapy\n\n none\n \n none\n MTOR\n\n Node MTOR\n percentile=36;foldchange=-1.47;copychange=-1PTEN\n\n Node PTEN\n 2\npercentile=2;foldchange=-2.1;copychange=0PIK3CA\n\n Node PIK3CA\n percentile=17;foldchange=-2.03;copychange=0PI3K/MTOR\nTP53\n\n Node TP53\n 92\npercentile=92;foldchange=1.47;copychange=0FANCD2\n\n Node FANCD2\n mutations=p.E803K;percentile=67;foldchange=1.33;copychange=0RAD50\n\n Node RAD50\n percentile=53;foldchange=-1.31;copychange=-1FAM175A\n\n Node FAM175A\n percentile=17;foldchange=-2.02;copychange=0BRCA1\n\n Node BRCA1\n 95\npercentile=95;foldchange=1.28;copychange=0BRCA2\n\n Node BRCA2\n percentile=89;foldchange=1.16;copychange=0Homologous recombination\nERBB4\n\n Node ERBB4\n percentile=31;foldchange=-2.6;copychange=0IGF2\n\n Node IGF2\n 97\npercentile=97;foldchange=1.56;copychange=0+2\nERBB2\n\n Node ERBB2\n percentile=45;foldchange=6.08;copychange=2EGFR\n\n Node EGFR\n percentile=90;foldchange=-1.06;copychange=0FGFR2\n\n Node FGFR2\n 100\npercentile=100;foldchange=3.61;copychange=0FGFR4\n\n Node FGFR4\n 98\npercentile=98;foldchange=13.39;copychange=-1FGFR3\n\n Node FGFR3\n 99\npercentile=99;foldchange=14.12;copychange=0ERBB3\n\n Node ERBB3\n 93\npercentile=93;foldchange=9.73;copychange=0RTKs\nNRG1\n\n Node NRG1\n 100\nATP1B1\nstructural-variants=translocation(ATP1B1,NRG1) and translocation(NRG1,ATP1B1);percentile=100;foldchange=39.25;copychange=-1\n pubmed_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318;pubmed_MAP kinase signalling pathways in cancer_17496922;pmcid_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318\n \n pubmed_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318;pubmed_MAP kinase signalling pathways in cancer_17496922;pmcid_ROLES OF THE RAF/MEK/ERK PATHWAY IN CELL GROWTH, MALIGNANT TRANSFORMATION AND DRUG RESISTANCE_PMC2696318\n \n pubmed_MAP kinase signalling pathways in cancer_17496922\n MAP2K2\n\n Node MAP2K2\n percentile=73;foldchange=1.8;copychange=0BRAF\n\n Node BRAF\n 97\npercentile=97;foldchange=-1.25;copychange=0KRAS\n\n Node KRAS\n 6\npercentile=6;foldchange=-2.05;copychange=0MAP2K1\n\n Node MAP2K1\n 3\npercentile=3;foldchange=-1.35;copychange=1MAPK\nCEBPA\n\n Node CEBPA\n 92\npercentile=92;foldchange=-1.05;copychange=0JUN\n\n Node JUN\n hom\nmutations=p.S37fs;percentile=65;foldchange=1.18;copychange=-1" }, "probeResults": [ { @@ -943,4 +942,4 @@ "path": "test/testData/images/spearman_brca_receptor.png" } ] -} +} \ No newline at end of file