diff --git a/.eslintrc.js b/.eslintrc.js index 96b39b48e..2eaf3a6fe 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,6 +19,6 @@ module.exports = { "no-console": "off", // no-return-await disabled because of https://stackoverflow.com/a/44806230 "no-return-await": "off", - "no-multiple-empty-lines": "off", + "no-multiple-empty-lines": "off" }, }; diff --git a/src/api.v2/controllers/experimentController.js b/src/api.v2/controllers/experimentController.js index 56e07f2c6..8941c7f3e 100644 --- a/src/api.v2/controllers/experimentController.js +++ b/src/api.v2/controllers/experimentController.js @@ -1,18 +1,18 @@ -/* eslint-disable import/prefer-default-export */ const _ = require('lodash'); -const experiment = require('../model/experiment'); -const userAccess = require('../model/userAccess'); +const Experiment = require('../model/Experiment'); +const UserAccess = require('../model/UserAccess'); const getLogger = require('../../utils/getLogger'); const { OK } = require('../../utils/responses'); +const sqlClient = require('../../sql/sqlClient'); const logger = getLogger('[ExperimentController] - '); const getAllExperiments = async (req, res) => { const { user: { sub: userId } } = req; - const data = await experiment.getAllExperiments(userId); + const data = await new Experiment().getAllExperiments(userId); res.json(data); }; @@ -22,7 +22,7 @@ const getExperiment = async (req, res) => { logger.log(`Getting experiment ${experimentId}`); - const data = await experiment.getExperimentData(experimentId); + const data = await new Experiment().getExperimentData(experimentId); logger.log(`Finished getting experiment ${experimentId}`); @@ -36,8 +36,10 @@ const createExperiment = async (req, res) => { logger.log('Creating experiment'); - await experiment.create({ id: experimentId, name, description }); - await userAccess.createNewExperimentPermissions(user.sub, experimentId); + await sqlClient.get().transaction(async (trx) => { + await new Experiment(trx).create({ id: experimentId, name, description }); + await new UserAccess(trx).createNewExperimentPermissions(user.sub, experimentId); + }); logger.log(`Finished creating experiment ${experimentId}`); @@ -51,7 +53,7 @@ const patchExperiment = async (req, res) => { const snakeCasedKeysToPatch = _.mapKeys(body, (_value, key) => _.snakeCase(key)); - await experiment.update(experimentId, snakeCasedKeysToPatch); + await new Experiment().update(experimentId, snakeCasedKeysToPatch); logger.log(`Finished updating experiment ${experimentId}`); @@ -73,7 +75,7 @@ const updateSamplePosition = async (req, res) => { return; } - await experiment.updateSamplePosition(experimentId, oldPosition, newPosition); + await new Experiment().updateSamplePosition(experimentId, oldPosition, newPosition); logger.log(`Finished reordering samples in ${experimentId}`); diff --git a/src/api.v2/controllers/sampleController.js b/src/api.v2/controllers/sampleController.js new file mode 100644 index 000000000..03521f2be --- /dev/null +++ b/src/api.v2/controllers/sampleController.js @@ -0,0 +1,55 @@ +const Sample = require('../model/Sample'); +const Experiment = require('../model/Experiment'); +const MetadataTrack = require('../model/MetadataTrack'); + +const getLogger = require('../../utils/getLogger'); +const { OK } = require('../../utils/responses'); + +const sqlClient = require('../../sql/sqlClient'); + +const logger = getLogger('[SampleController] - '); + +const createSample = async (req, res) => { + const { + params: { experimentId, sampleId }, + body: { name, sampleTechnology }, + } = req; + + logger.log('Creating sample'); + + await sqlClient.get().transaction(async (trx) => { + await new Sample(trx).create({ + id: sampleId, + experiment_id: experimentId, + name, + sample_technology: sampleTechnology, + }); + + await new Experiment(trx).addSample(experimentId, sampleId); + + await new MetadataTrack(trx) + .createNewSampleValues(experimentId, sampleId); + }); + + logger.log(`Finished creating sample ${sampleId} for experiment ${experimentId}`); + + res.json(OK()); +}; + +const deleteSample = async (req, res) => { + const { params: { experimentId, sampleId } } = req; + + await sqlClient.get().transaction(async (trx) => { + await new Sample(trx).destroy(sampleId); + await new Experiment(trx).deleteSample(experimentId, sampleId); + }); + + logger.log(`Finished deleting sample ${sampleId} from experiment ${experimentId}`); + + res.json(OK()); +}; + +module.exports = { + createSample, + deleteSample, +}; diff --git a/src/api.v2/helpers/generateBasicModelFunctions.js b/src/api.v2/helpers/generateBasicModelFunctions.js deleted file mode 100644 index db9276ee9..000000000 --- a/src/api.v2/helpers/generateBasicModelFunctions.js +++ /dev/null @@ -1,66 +0,0 @@ -const sqlClient = require('../../sql/sqlClient'); - -// The basic functions of a model that uses Knexjs to store and retrieve data from a -// database using the provided `knex` instance. Custom functionality can be -// composed on top of this set of common functions. -// -// The idea is that these are the most-used types of functions that most/all -// "models" will want to have. - -// Copied from https://github.com/robmclarty/knex-express-project-sample/blob/main/server/helpers/model-guts.js -module.exports = ({ - tableName, - selectableProps = [], - timeout = 4000, -}) => { - const create = (props) => sqlClient.get().insert(props) - .returning(selectableProps) - .into(tableName) - .timeout(timeout); - - const findAll = () => sqlClient.get().select(selectableProps) - .from(tableName) - .timeout(timeout); - - const find = (filters) => sqlClient.get().select(selectableProps) - .from(tableName) - .where(filters) - .timeout(timeout); - - // Same as `find` but only returns the first match if >1 are found. - const findOne = (filters) => find(filters) - .then((results) => { - if (!Array.isArray(results)) return results; - - return results[0]; - }); - - const findById = (id) => sqlClient.get().select(selectableProps) - .from(tableName) - .where({ id }) - .timeout(timeout); - - const update = (id, props) => sqlClient.get().update(props) - .from(tableName) - .where({ id }) - .returning(selectableProps) - .timeout(timeout); - - const destroy = (id) => sqlClient.get().del() - .from(tableName) - .where({ id }) - .timeout(timeout); - - return { - tableName, - selectableProps, - timeout, - create, - findAll, - find, - findOne, - findById, - update, - destroy, - }; -}; diff --git a/src/api.v2/helpers/tableNames.js b/src/api.v2/helpers/tableNames.js new file mode 100644 index 000000000..cd3c34f83 --- /dev/null +++ b/src/api.v2/helpers/tableNames.js @@ -0,0 +1,11 @@ +const tableNames = { + EXPERIMENT: 'experiment', + EXPERIMENT_EXECUTION: 'experiment_execution', + SAMPLE: 'sample', + USER_ACCESS: 'user_access', + INVITE_ACCESS: 'invite_access', + METADATA_TRACK: 'metadata_track', + SAMPLE_IN_METADATA_TRACK_MAP: 'sample_in_metadata_track_map', +}; + +module.exports = tableNames; diff --git a/src/api.v2/middlewares/authMiddlewares.js b/src/api.v2/middlewares/authMiddlewares.js index 677f8ba75..b438034d7 100644 --- a/src/api.v2/middlewares/authMiddlewares.js +++ b/src/api.v2/middlewares/authMiddlewares.js @@ -2,7 +2,7 @@ // for how JWT verification works with Cognito. const { UnauthorizedError, UnauthenticatedError } = require('../../utils/responses'); -const userAccess = require('../model/userAccess'); +const UserAccess = require('../model/UserAccess'); /** * General authorization middleware. Resolves with nothing on @@ -20,7 +20,7 @@ const userAccess = require('../model/userAccess'); const authorize = async (userId, resource, method, experimentId) => { // authResource is always experimentId in V2 because there is no project - const granted = await userAccess.canAccessExperiment( + const granted = await new UserAccess().canAccessExperiment( userId, experimentId, resource, diff --git a/src/api.v2/model/BasicModel.js b/src/api.v2/model/BasicModel.js new file mode 100644 index 000000000..905ad3ef7 --- /dev/null +++ b/src/api.v2/model/BasicModel.js @@ -0,0 +1,71 @@ +// The basic functions of a model that uses Knexjs to store and retrieve data from a +// database using the provided `knex` instance. Custom functionality can be +// composed on top of this set of common functions. +// +// The idea is that these are the most-used types of functions that most/all +// "models" will want to have. + +class BasicModel { + constructor(sql, tableName, selectableProps = [], timeout = 4000) { + this.sql = sql; + this.tableName = tableName; + this.selectableProps = selectableProps; + this.timeout = timeout; + } + + create(props) { + return this.sql.insert(props) + .returning(this.selectableProps) + .into(this.tableName) + .timeout(this.timeout); + } + + findAll() { + return this.sql + .select(this.selectableProps) + .from(this.tableName) + .timeout(this.timeout); + } + + find(filters) { + return this.sql.select(this.selectableProps) + .from(this.tableName) + .where(filters) + .timeout(this.timeout); + } + + // Same as `find` but only returns the first match if >1 are found. + findOne(filters) { + return this.find(filters) + // @ts-ignore + .then((results) => { + if (!Array.isArray(results)) return results; + + return results[0]; + }); + } + + findById(id) { + return this.sql.select(this.selectableProps) + .from(this.tableName) + .where({ id }) + .timeout(this.timeout); + } + + update(id, props) { + return this.sql.update(props) + .from(this.tableName) + .where({ id }) + .returning(this.selectableProps) + .timeout(this.timeout); + } + + destroy(id) { + return this.sql.del() + .from(this.tableName) + .where({ id }) + .timeout(this.timeout); + } +} + +module.exports = BasicModel; diff --git a/src/api.v2/model/Experiment.js b/src/api.v2/model/Experiment.js new file mode 100644 index 000000000..ce724b53f --- /dev/null +++ b/src/api.v2/model/Experiment.js @@ -0,0 +1,164 @@ +const _ = require('lodash'); + +const BasicModel = require('./BasicModel'); +const sqlClient = require('../../sql/sqlClient'); +const { collapseKeyIntoArray } = require('../../sql/helpers'); + +const { NotFoundError } = require('../../utils/responses'); + +const tableNames = require('../helpers/tableNames'); + + +const getLogger = require('../../utils/getLogger'); + +const logger = getLogger('[ExperimentModel] - '); + + +const experimentFields = [ + 'id', + 'name', + 'description', + 'samples_order', + 'processing_config', + 'notify_by_email', + 'created_at', + 'updated_at', +]; + +class Experiment extends BasicModel { + constructor(sql = sqlClient.get()) { + super(sql, tableNames.EXPERIMENT, experimentFields); + } + + async getAllExperiments(userId) { + const fields = [ + 'id', + 'name', + 'description', + 'samples_order', + 'notify_by_email', + 'created_at', + 'updated_at', + ]; + + const aliasedExperimentFields = fields.map((field) => `e.${field}`); + function mainQuery() { + this.select([...aliasedExperimentFields, 'm.key']) + .from(tableNames.USER_ACCESS) + .where('user_id', userId) + .join(`${tableNames.EXPERIMENT} as e`, 'e.id', `${tableNames.USER_ACCESS}.experiment_id`) + .leftJoin(`${tableNames.METADATA_TRACK} as m`, 'e.id', 'm.experiment_id') + .as('mainQuery'); + } + + const result = await collapseKeyIntoArray(mainQuery, [...fields], 'key', 'metadataKeys', this.sql); + + return result; + } + + async getExperimentData(experimentId) { + function mainQuery() { + this.select('*') + .from(tableNames.EXPERIMENT) + .leftJoin(tableNames.EXPERIMENT_EXECUTION, `${tableNames.EXPERIMENT}.id`, `${tableNames.EXPERIMENT_EXECUTION}.experiment_id`) + .where('id', experimentId) + .as('mainQuery'); + } + + const experimentExecutionFields = [ + 'params_hash', 'state_machine_arn', 'execution_arn', + ]; + + const pipelineExecutionKeys = experimentExecutionFields.reduce((acum, current) => { + acum.push(`'${current}'`); + acum.push(current); + + return acum; + }, []); + + const replaceNullsWithObject = (object, nullableKey) => ( + `COALESCE( + ${object} + FILTER( + WHERE ${nullableKey} IS NOT NULL + ), + '{}'::jsonb + )` + ); + + const result = await this.sql + .select([ + ...experimentFields, + this.sql.raw( + `${replaceNullsWithObject( + `jsonb_object_agg(pipeline_type, jsonb_build_object(${pipelineExecutionKeys.join(', ')}))`, + 'pipeline_type', + )} as pipelines`, + ), + ]) + .from(mainQuery) + .groupBy(experimentFields) + .first(); + + if (_.isEmpty(result)) { + throw new NotFoundError('Experiment not found'); + } + + return result; + } + + // Sets samples_order as an array that has the sample in oldPosition moved to newPosition + async updateSamplePosition( + experimentId, oldPosition, newPosition, + ) { + const trx = await this.sql.transaction(); + + try { + const result = await trx(tableNames.EXPERIMENT) + .update({ + samples_order: trx.raw(`( + SELECT jsonb_insert(samples_order - ${oldPosition}, '{${newPosition}}', samples_order -> ${oldPosition}, false) + FROM ( + SELECT (samples_order) + FROM experiment e + WHERE e.id = '${experimentId}' + ) samples_order + )`), + }).where('id', experimentId) + .returning(['samples_order']); + + const { samplesOrder = null } = result[0] || {}; + + if (_.isNil(samplesOrder) + || !_.inRange(oldPosition, 0, samplesOrder.length) + || !_.inRange(newPosition, 0, samplesOrder.length) + ) { + logger.log('Invalid positions or samples_order was broken, rolling back transaction'); + throw new Error('Invalid update parameters'); + } + + trx.commit(); + } catch (e) { + trx.rollback(); + throw e; + } + } + + async addSample(experimentId, sampleId) { + await this.sql(tableNames.EXPERIMENT) + .update({ + samples_order: this.sql.raw(`samples_order || '["${sampleId}"]'::jsonb`), + }) + .where('id', experimentId); + } + + async deleteSample(experimentId, sampleId) { + await this.sql(tableNames.EXPERIMENT) + .update({ + samples_order: this.sql.raw(`samples_order - '${sampleId}'`), + }) + .where('id', experimentId); + } +} + +module.exports = Experiment; diff --git a/src/api.v2/model/InviteAccess.js b/src/api.v2/model/InviteAccess.js new file mode 100644 index 000000000..b394013ca --- /dev/null +++ b/src/api.v2/model/InviteAccess.js @@ -0,0 +1,19 @@ +const BasicModel = require('./BasicModel'); +const sqlClient = require('../../sql/sqlClient'); + +const tableNames = require('../helpers/tableNames'); + +const selectableProps = [ + 'user_email', + 'experiment_id', + 'access_role', + 'updated_at', +]; + +class InviteAccess extends BasicModel { + constructor(sql = sqlClient.get()) { + super(sql, tableNames.INVITE_ACCESS, selectableProps); + } +} + +module.exports = InviteAccess; diff --git a/src/api.v2/model/MetadataTrack.js b/src/api.v2/model/MetadataTrack.js new file mode 100644 index 000000000..4f3e66273 --- /dev/null +++ b/src/api.v2/model/MetadataTrack.js @@ -0,0 +1,41 @@ +// @ts-nocheck +const BasicModel = require('./BasicModel'); +const sqlClient = require('../../sql/sqlClient'); + +const tableNames = require('../helpers/tableNames'); + +const sampleFields = [ + 'id', + 'experiment_id', + 'name', + 'sample_technology', + 'created_at', + 'updated_at', +]; + +class MetadataTrack extends BasicModel { + constructor(sql = sqlClient.get()) { + super(sql, tableNames.METADATA_TRACK, sampleFields); + } + + async createNewSampleValues(experimentId, sampleId) { + const tracks = await this.sql.select(['id']) + .from(tableNames.METADATA_TRACK) + .where({ experiment_id: experimentId }); + + if (tracks.length === 0) { + return; + } + + const valuesToInsert = tracks.map(({ id }) => ({ + metadata_track_id: id, + sample_id: sampleId, + value: 'N.A.', + })); + + await this.sql(tableNames.SAMPLE_IN_METADATA_TRACK_MAP) + .insert(valuesToInsert); + } +} + +module.exports = MetadataTrack; diff --git a/src/api.v2/model/Sample.js b/src/api.v2/model/Sample.js new file mode 100644 index 000000000..d17110c47 --- /dev/null +++ b/src/api.v2/model/Sample.js @@ -0,0 +1,21 @@ +const BasicModel = require('./BasicModel'); +const sqlClient = require('../../sql/sqlClient'); + +const tableNames = require('../helpers/tableNames'); + +const sampleFields = [ + 'id', + 'experiment_id', + 'name', + 'sample_technology', + 'created_at', + 'updated_at', +]; + +class Sample extends BasicModel { + constructor(sql = sqlClient.get()) { + super(sql, tableNames.SAMPLE, sampleFields); + } +} + +module.exports = Sample; diff --git a/src/api.v2/model/UserAccess.js b/src/api.v2/model/UserAccess.js new file mode 100644 index 000000000..1d1b779c8 --- /dev/null +++ b/src/api.v2/model/UserAccess.js @@ -0,0 +1,64 @@ +const _ = require('lodash'); + +const config = require('../../config'); + +const BasicModel = require('./BasicModel'); +const sqlClient = require('../../sql/sqlClient'); + +const { isRoleAuthorized } = require('../helpers/roles'); + +const AccessRole = require('../../utils/enums/AccessRole'); + +const tableNames = require('../helpers/tableNames'); + +const selectableProps = [ + 'user_id', + 'experiment_id', + 'access_role', + 'updated_at', +]; + +const getLogger = require('../../utils/getLogger'); + +const logger = getLogger('[UserAccessModel] - '); + +class UserAccess extends BasicModel { + constructor(sql = sqlClient.get()) { + super(sql, tableNames.USER_ACCESS, selectableProps); + } + + async createNewExperimentPermissions(userId, experimentId) { + logger.log('Setting up access permissions for experiment'); + + await this.create( + { user_id: config.adminSub, experiment_id: experimentId, access_role: AccessRole.ADMIN }, + ); + + if (userId === config.adminSub) { + logger.log('User is the admin, so only creating admin access'); + return; + } + + await this.create( + { user_id: userId, experiment_id: experimentId, access_role: AccessRole.OWNER }, + ); + } + + async canAccessExperiment(userId, experimentId, url, method) { + const result = await this.sql(tableNames.USER_ACCESS) + .first() + .where({ experiment_id: experimentId, user_id: userId }) + .from(tableNames.USER_ACCESS); + + // If there is no entry for this user and role, then user definitely doesn't have access + if (_.isNil(result)) { + return false; + } + + const { accessRole: role } = result; + + return isRoleAuthorized(role, url, method); + } +} + +module.exports = UserAccess; diff --git a/src/api.v2/model/__mocks__/BasicModel.js b/src/api.v2/model/__mocks__/BasicModel.js new file mode 100644 index 000000000..9bab0e005 --- /dev/null +++ b/src/api.v2/model/__mocks__/BasicModel.js @@ -0,0 +1,13 @@ +const stub = { + create: jest.fn(), + findAll: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + findById: jest.fn(), + update: jest.fn(), + destroy: jest.fn(), +}; + +const BasicModel = jest.fn().mockImplementation(() => stub); + +module.exports = BasicModel; diff --git a/src/api.v2/model/__mocks__/Experiment.js b/src/api.v2/model/__mocks__/Experiment.js new file mode 100644 index 000000000..cfc4dd365 --- /dev/null +++ b/src/api.v2/model/__mocks__/Experiment.js @@ -0,0 +1,14 @@ +const BasicModel = require('./BasicModel')(); + +const stub = { + getAllExperiments: jest.fn(), + getExperimentData: jest.fn(), + updateSamplePosition: jest.fn(), + addSample: jest.fn(), + deleteSample: jest.fn(), + ...BasicModel, +}; + +const Experiment = jest.fn().mockImplementation(() => stub); + +module.exports = Experiment; diff --git a/src/api.v2/model/__mocks__/MetadataTrack.js b/src/api.v2/model/__mocks__/MetadataTrack.js new file mode 100644 index 000000000..7f459c38c --- /dev/null +++ b/src/api.v2/model/__mocks__/MetadataTrack.js @@ -0,0 +1,10 @@ +const BasicModel = require('./BasicModel')(); + +const stub = { + createNewSampleValues: jest.fn(), + ...BasicModel, +}; + +const MetadataTrack = jest.fn().mockImplementation(() => stub); + +module.exports = MetadataTrack; diff --git a/src/api.v2/model/__mocks__/Sample.js b/src/api.v2/model/__mocks__/Sample.js new file mode 100644 index 000000000..264d4d09d --- /dev/null +++ b/src/api.v2/model/__mocks__/Sample.js @@ -0,0 +1,9 @@ +const BasicModel = require('./BasicModel')(); + +const stub = { + ...BasicModel, +}; + +const Sample = jest.fn().mockImplementation(() => stub); + +module.exports = Sample; diff --git a/src/api.v2/model/__mocks__/UserAccess.js b/src/api.v2/model/__mocks__/UserAccess.js new file mode 100644 index 000000000..8e9e0445a --- /dev/null +++ b/src/api.v2/model/__mocks__/UserAccess.js @@ -0,0 +1,11 @@ +const BasicModel = require('./BasicModel')(); + +const stub = { + createNewExperimentPermissions: jest.fn(), + canAccessExperiment: jest.fn(), + ...BasicModel, +}; + +const UserAccess = jest.fn().mockImplementation(() => stub); + +module.exports = UserAccess; diff --git a/src/api.v2/model/experiment.js b/src/api.v2/model/experiment.js deleted file mode 100644 index 029b27f41..000000000 --- a/src/api.v2/model/experiment.js +++ /dev/null @@ -1,158 +0,0 @@ -const _ = require('lodash'); - -/* eslint-disable func-names */ -const generateBasicModelFunctions = require('../helpers/generateBasicModelFunctions'); -const sqlClient = require('../../sql/sqlClient'); -const { collapseKeyIntoArray } = require('../../sql/helpers'); - -const getLogger = require('../../utils/getLogger'); - -const { NotFoundError } = require('../../utils/responses'); - -const logger = getLogger('[ExperimentModel] - '); - -const experimentTable = 'experiment'; -const experimentExecutionTable = 'experiment_execution'; -const userAccessTable = 'user_access'; -const metadataTrackTable = 'metadata_track'; - -const experimentFields = [ - 'id', - 'name', - 'description', - 'samples_order', - 'notify_by_email', - 'processing_config', - 'created_at', - 'updated_at', -]; - -const basicModelFunctions = generateBasicModelFunctions({ - tableName: experimentTable, - selectableProps: experimentFields, -}); - -const getAllExperiments = async (userId) => { - const sql = sqlClient.get(); - - const fields = [ - 'id', - 'name', - 'description', - 'samples_order', - 'notify_by_email', - 'created_at', - 'updated_at', - ]; - - const aliasedExperimentFields = fields.map((field) => `e.${field}`); - function mainQuery() { - this.select([...aliasedExperimentFields, 'm.key']) - .from(userAccessTable) - .where('user_id', userId) - .join(`${experimentTable} as e`, 'e.id', `${userAccessTable}.experiment_id`) - .leftJoin(`${metadataTrackTable} as m`, 'e.id', 'm.experiment_id') - .as('mainQuery'); - } - - const result = await collapseKeyIntoArray(mainQuery, [...fields], 'key', 'metadataKeys', sql); - - return result; -}; - -const getExperimentData = async (experimentId) => { - const sql = sqlClient.get(); - - function mainQuery() { - this.select('*') - .from(experimentTable) - .leftJoin(experimentExecutionTable, `${experimentTable}.id`, `${experimentExecutionTable}.experiment_id`) - .where('id', experimentId) - .as('mainQuery'); - } - - const experimentExecutionFields = [ - 'params_hash', 'state_machine_arn', 'execution_arn', - ]; - - const pipelineExecutionKeys = experimentExecutionFields.reduce((acum, current) => { - acum.push(`'${current}'`); - acum.push(current); - - return acum; - }, []); - - const replaceNullsWithObject = (object, nullableKey) => ( - `COALESCE( - ${object} - FILTER( - WHERE ${nullableKey} IS NOT NULL - ), - '{}'::jsonb - )` - ); - - const result = await sql - .select([ - ...experimentFields, - sql.raw( - `${replaceNullsWithObject( - `jsonb_object_agg(pipeline_type, jsonb_build_object(${pipelineExecutionKeys.join(', ')}))`, - 'pipeline_type', - )} as pipelines`, - ), - ]) - .from(mainQuery) - .groupBy(experimentFields) - .first(); - - if (_.isEmpty(result)) { - throw new NotFoundError('Experiment not found'); - } - - return result; -}; - -const updateSamplePosition = async (experimentId, oldPosition, newPosition) => { - const sql = sqlClient.get(); - - // Sets samples_order as an array that has the sample in oldPosition moved to newPosition - - const trx = await sql.transaction(); - - try { - const result = await trx(experimentTable).update({ - samples_order: trx.raw(`( - SELECT jsonb_insert(samples_order - ${oldPosition}, '{${newPosition}}', samples_order -> ${oldPosition}, false) - FROM ( - SELECT (samples_order) - FROM experiment e - WHERE e.id = '${experimentId}' - ) samples_order - )`), - }).where('id', experimentId) - .returning(['samples_order']); - - const { samplesOrder = null } = result[0] || {}; - - if (_.isNil(samplesOrder) - || !_.inRange(oldPosition, 0, samplesOrder.length) - || !_.inRange(newPosition, 0, samplesOrder.length) - ) { - logger.log('Invalid positions or samples_order was broken, rolling back transaction'); - throw new Error('Invalid update parameters'); - } - - trx.commit(); - } catch (e) { - trx.rollback(); - throw e; - } -}; - -module.exports = { - getAllExperiments, - getExperimentData, - updateSamplePosition, - ...basicModelFunctions, -}; diff --git a/src/api.v2/model/inviteAccess.js b/src/api.v2/model/inviteAccess.js deleted file mode 100644 index 56bdaa2f6..000000000 --- a/src/api.v2/model/inviteAccess.js +++ /dev/null @@ -1,19 +0,0 @@ -const generateBasicModelFunctions = require('../helpers/generateBasicModelFunctions'); - -const tableName = 'invite_access'; - -const selectableProps = [ - 'user_email', - 'experiment_id', - 'access_role', - 'updated_at', -]; - -const basicModelFunctions = generateBasicModelFunctions({ - tableName, - selectableProps, -}); - -module.exports = { - ...basicModelFunctions, -}; diff --git a/src/api.v2/model/userAccess.js b/src/api.v2/model/userAccess.js deleted file mode 100644 index eda0840a1..000000000 --- a/src/api.v2/model/userAccess.js +++ /dev/null @@ -1,68 +0,0 @@ -const _ = require('lodash'); - -const config = require('../../config'); - -const generateBasicModelFunctions = require('../helpers/generateBasicModelFunctions'); -const { isRoleAuthorized } = require('../helpers/roles'); - -const AccessRole = require('../../utils/enums/AccessRole'); -const sqlClient = require('../../sql/sqlClient'); - -const userAccessTable = 'user_access'; - -const selectableProps = [ - 'user_id', - 'experiment_id', - 'access_role', - 'updated_at', -]; - -const getLogger = require('../../utils/getLogger'); - -const logger = getLogger('[UserAccessModel] - '); - -const basicModelFunctions = generateBasicModelFunctions({ - tableName: userAccessTable, - selectableProps, -}); - -const createNewExperimentPermissions = async (userId, experimentId) => { - logger.log('Setting up access permissions for experiment'); - - await basicModelFunctions.create( - { user_id: config.adminSub, experiment_id: experimentId, access_role: AccessRole.ADMIN }, - ); - - if (userId === config.adminSub) { - logger.log('User is the admin, so only creating admin access'); - return; - } - - await basicModelFunctions.create( - { user_id: userId, experiment_id: experimentId, access_role: AccessRole.OWNER }, - ); -}; - -const canAccessExperiment = async (userId, experimentId, url, method) => { - const sql = sqlClient.get(); - - const result = await sql(userAccessTable) - .first() - .where({ experiment_id: experimentId, user_id: userId }) - .from(userAccessTable); - - // If there is no entry for this user and role, then user definitely doesn't have access - if (_.isNil(result)) { - return false; - } - - const { accessRole: role } = result; - - return isRoleAuthorized(role, url, method); -}; - -module.exports = { - createNewExperimentPermissions, - canAccessExperiment, - ...basicModelFunctions, -}; diff --git a/src/api.v2/routes/sample.js b/src/api.v2/routes/sample.js new file mode 100644 index 000000000..9f09cba17 --- /dev/null +++ b/src/api.v2/routes/sample.js @@ -0,0 +1,17 @@ +const { + createSample, + deleteSample, +} = require('../controllers/sampleController'); + +const { expressAuthorizationMiddleware } = require('../middlewares/authMiddlewares'); + +module.exports = { + 'sample#createSample': [ + expressAuthorizationMiddleware, + (req, res, next) => createSample(req, res).catch(next), + ], + 'sample#deleteSample': [ + expressAuthorizationMiddleware, + (req, res, next) => deleteSample(req, res).catch(next), + ], +}; diff --git a/src/specs/api.v2.yaml b/src/specs/api.v2.yaml index 8bcb94b98..231ecc704 100644 --- a/src/specs/api.v2.yaml +++ b/src/specs/api.v2.yaml @@ -116,6 +116,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPError' + '403': + description: Forbidden request for this user. + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' '404': description: Not found error. content: @@ -182,6 +188,90 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPError' + '403': + description: Forbidden request for this user. + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + '404': + description: Not found error. + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + '/experiments/{experimentId}/samples/{sampleId}': + post: + summary: Create sample + operationId: createSample + x-eov-operation-id: sample#createSample + x-eov-operation-handler: routes/sample + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSample' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPSuccess' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + '401': + description: The request lacks authentication credentials. + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + '403': + description: Forbidden request for this user. + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + '404': + description: Not found error. + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + delete: + summary: Delete sample + operationId: deleteSample + x-eov-operation-id: sample#deleteSample + x-eov-operation-handler: routes/sample + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPSuccess' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + '401': + description: The request lacks authentication credentials. + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' + '403': + description: Forbidden request for this user. + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' '404': description: Not found error. content: @@ -228,6 +318,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPError' + '403': + description: Forbidden request for this user. + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPError' '404': description: Not found error. content: @@ -244,6 +340,8 @@ components: $ref: './models/experiment-bodies/ExperimentPatch.v2.yaml' GetAllExperiments: $ref: './models/experiment-bodies/GetAllExperiments.v2.yaml' + CreateSample: + $ref: './models/samples-bodies/CreateSample.v2.yaml' HTTPSuccess: $ref: './models/HTTPSuccess.v1.yaml' HTTPError: diff --git a/src/specs/models/experiment-bodies/CreateExperiment.v2.yaml b/src/specs/models/experiment-bodies/CreateExperiment.v2.yaml index 830e583d7..e5b68d7b6 100644 --- a/src/specs/models/experiment-bodies/CreateExperiment.v2.yaml +++ b/src/specs/models/experiment-bodies/CreateExperiment.v2.yaml @@ -1,6 +1,5 @@ title: Experiment Creation description: 'Data required to create a new experiment' - type: object properties: id: diff --git a/src/specs/models/experiment-bodies/ExperimentInfo.v2.yaml b/src/specs/models/experiment-bodies/ExperimentInfo.v2.yaml index ad39f1d37..85826772f 100644 --- a/src/specs/models/experiment-bodies/ExperimentInfo.v2.yaml +++ b/src/specs/models/experiment-bodies/ExperimentInfo.v2.yaml @@ -59,6 +59,7 @@ required: - name - description - samplesOrder + - processingConfig - notifyByEmail - pipelines - createdAt diff --git a/src/specs/models/samples-bodies/CreateSample.v2.yaml b/src/specs/models/samples-bodies/CreateSample.v2.yaml new file mode 100644 index 000000000..2953db0c8 --- /dev/null +++ b/src/specs/models/samples-bodies/CreateSample.v2.yaml @@ -0,0 +1,18 @@ +title: Create sample +description: 'Data required to create a new sample' +type: object +properties: + name: + type: string + sampleTechnology: + allOf: + - type: string + - oneOf: + - pattern: 10x + - pattern: rhapsody + +required: + - name + - sampleTechnology + +additionalProperties: false diff --git a/tests/api.v2/controllers/experimentController.test.js b/tests/api.v2/controllers/experimentController.test.js index 5b9da5c06..377e1bac1 100644 --- a/tests/api.v2/controllers/experimentController.test.js +++ b/tests/api.v2/controllers/experimentController.test.js @@ -1,6 +1,11 @@ // @ts-nocheck -const experimentModel = require('../../../src/api.v2/model/experiment'); -const userAccessModel = require('../../../src/api.v2/model/userAccess'); +const Experiment = require('../../../src/api.v2/model/Experiment'); +const UserAccess = require('../../../src/api.v2/model/UserAccess'); + +const { mockSqlClient, mockTrx } = require('../mocks/getMockSqlClient')(); + +const experimentInstance = Experiment(); +const userAccessInstance = UserAccess(); const mockExperiment = { id: 'mockExperimentId', @@ -12,13 +17,17 @@ const mockExperiment = { updated_at: '1900-03-26 21:06:00.573142+00', }; -jest.mock('../../../src/api.v2/model/experiment'); -jest.mock('../../../src/api.v2/model/userAccess'); +jest.mock('../../../src/api.v2/model/Experiment'); +jest.mock('../../../src/api.v2/model/UserAccess'); +jest.mock('../../../src/sql/sqlClient', () => ({ + get: jest.fn(() => mockSqlClient), +})); const getExperimentResponse = require('../mocks/data/getExperimentResponse.json'); const getAllExperimentsResponse = require('../mocks/data/getAllExperimentsResponse.json'); const experimentController = require('../../../src/api.v2/controllers/experimentController'); +const { OK } = require('../../../src/utils/responses'); const mockReqCreateExperiment = { params: { @@ -45,45 +54,67 @@ describe('experimentController', () => { it('getAllExperiments works correctly', async () => { const mockReq = { user: { sub: 'mockUserId' } }; - experimentModel.getAllExperiments.mockImplementationOnce( + experimentInstance.getAllExperiments.mockImplementationOnce( () => Promise.resolve(getAllExperimentsResponse), ); await experimentController.getAllExperiments(mockReq, mockRes); - expect(experimentModel.getAllExperiments).toHaveBeenCalledWith('mockUserId'); + expect(experimentInstance.getAllExperiments).toHaveBeenCalledWith('mockUserId'); expect(mockRes.json).toHaveBeenCalledWith(getAllExperimentsResponse); }); it('getExperiment works correctly', async () => { const mockReq = { params: { experimentId: getExperimentResponse.id } }; - experimentModel.getExperimentData.mockImplementationOnce( + experimentInstance.getExperimentData.mockImplementationOnce( () => Promise.resolve(getExperimentResponse), ); await experimentController.getExperiment(mockReq, mockRes); - expect(experimentModel.getExperimentData).toHaveBeenCalledWith(getExperimentResponse.id); + expect(experimentInstance.getExperimentData).toHaveBeenCalledWith(getExperimentResponse.id); expect(mockRes.json).toHaveBeenCalledWith(getExperimentResponse); }); it('createExperiment works correctly', async () => { - userAccessModel.createNewExperimentPermissions.mockImplementationOnce(() => Promise.resolve()); - experimentModel.create.mockImplementationOnce(() => Promise.resolve([mockExperiment])); + userAccessInstance.createNewExperimentPermissions.mockImplementationOnce( + () => Promise.resolve(), + ); + experimentInstance.create.mockImplementationOnce(() => Promise.resolve([mockExperiment])); await experimentController.createExperiment(mockReqCreateExperiment, mockRes); - expect(experimentModel.create).toHaveBeenCalledWith({ + // Used with transactions + expect(Experiment).toHaveBeenCalledWith(mockTrx); + expect(UserAccess).toHaveBeenCalledWith(mockTrx); + + // Not used without transactions + expect(Experiment).not.toHaveBeenCalledWith(mockSqlClient); + expect(UserAccess).not.toHaveBeenCalledWith(mockSqlClient); + + expect(experimentInstance.create).toHaveBeenCalledWith({ id: mockExperiment.id, name: 'mockName', description: 'mockDescription', }); - expect(userAccessModel.createNewExperimentPermissions).toHaveBeenCalledWith('mockSub', mockExperiment.id); + expect(userAccessInstance.createNewExperimentPermissions).toHaveBeenCalledWith('mockSub', mockExperiment.id); + + expect(experimentInstance.create).toHaveBeenCalledTimes(1); + expect(userAccessInstance.createNewExperimentPermissions).toHaveBeenCalledTimes(1); - expect(experimentModel.create).toHaveBeenCalledTimes(1); - expect(userAccessModel.createNewExperimentPermissions).toHaveBeenCalledTimes(1); + expect(mockRes.json).toHaveBeenCalledWith(OK()); + }); + + it('createExperiment errors out if the transaction failed', async () => { + mockSqlClient.transaction.mockImplementationOnce(() => Promise.reject(new Error())); + + await expect( + experimentController.createExperiment(mockReqCreateExperiment, mockRes), + ).rejects.toThrow(); + + expect(mockRes.json).not.toHaveBeenCalled(); }); it('patchExperiment works correctly', async () => { @@ -96,14 +127,16 @@ describe('experimentController', () => { }, }; - experimentModel.update.mockImplementationOnce(() => Promise.resolve()); + experimentInstance.update.mockImplementationOnce(() => Promise.resolve()); await experimentController.patchExperiment(mockReq, mockRes); - expect(experimentModel.update).toHaveBeenCalledWith( + expect(experimentInstance.update).toHaveBeenCalledWith( mockExperiment.id, { description: 'mockDescription' }, ); + + expect(mockRes.json).toHaveBeenCalledWith(OK()); }); it('updateSamplePosition works correctly', async () => { @@ -114,15 +147,17 @@ describe('experimentController', () => { body: { newPosition: 1, oldPosition: 5 }, }; - experimentModel.updateSamplePosition.mockImplementationOnce(() => Promise.resolve()); + experimentInstance.updateSamplePosition.mockImplementationOnce(() => Promise.resolve()); await experimentController.updateSamplePosition(mockReq, mockRes); - expect(experimentModel.updateSamplePosition).toHaveBeenCalledWith( + expect(experimentInstance.updateSamplePosition).toHaveBeenCalledWith( mockExperiment.id, 5, 1, ); + + expect(mockRes.json).toHaveBeenCalledWith(OK()); }); it('updateSamplePosition skips reordering if possible', async () => { @@ -135,6 +170,6 @@ describe('experimentController', () => { await experimentController.updateSamplePosition(mockReq, mockRes); - expect(experimentModel.updateSamplePosition).not.toHaveBeenCalled(); + expect(experimentInstance.updateSamplePosition).not.toHaveBeenCalled(); }); }); diff --git a/tests/api.v2/controllers/sampleController.test.js b/tests/api.v2/controllers/sampleController.test.js new file mode 100644 index 000000000..d8b9db81f --- /dev/null +++ b/tests/api.v2/controllers/sampleController.test.js @@ -0,0 +1,126 @@ +// @ts-nocheck +const Experiment = require('../../../src/api.v2/model/Experiment'); +const Sample = require('../../../src/api.v2/model/Sample'); +const MetadataTrack = require('../../../src/api.v2/model/MetadataTrack'); + +const experimentInstance = Experiment(); +const sampleInstance = Sample(); +const metadataTrackInstance = MetadataTrack(); +const { mockSqlClient, mockTrx } = require('../mocks/getMockSqlClient')(); + +const { OK } = require('../../../src/utils/responses'); + +jest.mock('../../../src/api.v2/model/Experiment'); +jest.mock('../../../src/api.v2/model/Sample'); +jest.mock('../../../src/api.v2/model/MetadataTrack'); + +jest.mock('../../../src/sql/sqlClient', () => ({ + get: jest.fn(() => mockSqlClient), +})); + +const sampleController = require('../../../src/api.v2/controllers/sampleController'); + +const mockRes = { + json: jest.fn(), +}; + +const mockExperimentId = 'mockExperimentId'; +const mockSampleId = 'mockSampleId'; +const mockSampleTechnology = '10x'; +const mockSampleName = 'mockSampleName'; + +describe('sampleController', () => { + beforeEach(async () => { + jest.clearAllMocks(); + }); + + it('createSample works correctly', async () => { + const mockReq = { + params: { experimentId: mockExperimentId, sampleId: mockSampleId }, + body: { name: mockSampleName, sampleTechnology: mockSampleTechnology }, + }; + + sampleInstance.create.mockImplementationOnce(() => Promise.resolve()); + experimentInstance.addSample.mockImplementationOnce(() => Promise.resolve()); + metadataTrackInstance.createNewSampleValues.mockImplementationOnce(() => Promise.resolve()); + + await sampleController.createSample(mockReq, mockRes); + + expect(mockSqlClient.transaction).toHaveBeenCalled(); + + // Used with transactions + expect(Sample).toHaveBeenCalledWith(mockTrx); + expect(Experiment).toHaveBeenCalledWith(mockTrx); + expect(MetadataTrack).toHaveBeenCalledWith(mockTrx); + + // Not used without transactions + expect(Sample).not.toHaveBeenCalledWith(mockSqlClient); + expect(Experiment).not.toHaveBeenCalledWith(mockSqlClient); + expect(MetadataTrack).not.toHaveBeenCalledWith(mockSqlClient); + + expect(sampleInstance.create).toHaveBeenCalledWith( + { + experiment_id: mockExperimentId, + id: mockSampleId, + name: mockSampleName, + sample_technology: mockSampleTechnology, + }, + ); + expect(experimentInstance.addSample).toHaveBeenCalledWith(mockExperimentId, mockSampleId); + expect(metadataTrackInstance.createNewSampleValues) + .toHaveBeenCalledWith(mockExperimentId, mockSampleId); + + expect(mockRes.json).toHaveBeenCalledWith(OK()); + }); + + it('createSample errors out if the transaction fails', async () => { + const mockReq = { + params: { experimentId: mockExperimentId, sampleId: mockSampleId }, + body: { name: mockSampleName, sampleTechnology: mockSampleTechnology }, + }; + + mockSqlClient.transaction.mockImplementationOnce(() => Promise.reject(new Error())); + + await expect(sampleController.createSample(mockReq, mockRes)).rejects.toThrow(); + + expect(mockSqlClient.transaction).toHaveBeenCalled(); + + expect(mockRes.json).not.toHaveBeenCalled(); + }); + + it('deleteSample works correctly', async () => { + const mockReq = { params: { experimentId: mockExperimentId, sampleId: mockSampleId } }; + + sampleInstance.destroy.mockImplementationOnce(() => Promise.resolve()); + experimentInstance.deleteSample.mockImplementationOnce(() => Promise.resolve()); + + await sampleController.deleteSample(mockReq, mockRes); + + expect(mockSqlClient.transaction).toHaveBeenCalled(); + + // Used with transactions + expect(Experiment).toHaveBeenCalledWith(mockTrx); + expect(Sample).toHaveBeenCalledWith(mockTrx); + + // Not used without transactions + expect(Experiment).not.toHaveBeenCalledWith(mockSqlClient); + expect(Sample).not.toHaveBeenCalledWith(mockSqlClient); + + expect(sampleInstance.destroy).toHaveBeenCalledWith(mockSampleId); + expect(experimentInstance.deleteSample).toHaveBeenCalledWith(mockExperimentId, mockSampleId); + + expect(mockRes.json).toHaveBeenCalledWith(OK()); + }); + + it('deleteSample errors out if the transaction fails', async () => { + const mockReq = { params: { experimentId: mockExperimentId, sampleId: mockSampleId } }; + + mockSqlClient.transaction.mockImplementationOnce(() => Promise.reject(new Error())); + + await expect(sampleController.deleteSample(mockReq, mockRes)).rejects.toThrow(); + + expect(mockSqlClient.transaction).toHaveBeenCalled(); + + expect(mockRes.json).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/api.v2/middlewares/authMiddlewares.test.js b/tests/api.v2/middlewares/authMiddlewares.test.js index a1899150f..ca1923ed1 100644 --- a/tests/api.v2/middlewares/authMiddlewares.test.js +++ b/tests/api.v2/middlewares/authMiddlewares.test.js @@ -7,9 +7,9 @@ const { const { UnauthorizedError, UnauthenticatedError } = require('../../../src/utils/responses'); const fake = require('../../test-utils/constants'); -const userAccessModel = require('../../../src/api.v2/model/userAccess'); +const UserAccessModel = require('../../../src/api.v2/model/UserAccess')(); -jest.mock('../../../src/api.v2/model/userAccess'); +jest.mock('../../../src/api.v2/model/UserAccess'); describe('Tests for authorization/authentication middlewares', () => { beforeEach(() => { @@ -17,20 +17,20 @@ describe('Tests for authorization/authentication middlewares', () => { }); it('Authorized user can proceed', async () => { - userAccessModel.canAccessExperiment.mockImplementationOnce(() => true); + UserAccessModel.canAccessExperiment.mockImplementationOnce(() => true); const result = await authorize(fake.USER.sub, 'sockets', null, fake.EXPERIMENT_ID); expect(result).toEqual(true); }); it('Unauthorized user cannot proceed', async () => { - userAccessModel.canAccessExperiment.mockImplementationOnce(() => false); + UserAccessModel.canAccessExperiment.mockImplementationOnce(() => false); await expect(authorize(fake.USER.sub, 'sockets', null, fake.EXPERIMENT_ID)).rejects; }); it('Express middleware can authorize correct users', async () => { - userAccessModel.canAccessExperiment.mockImplementationOnce(() => true); + UserAccessModel.canAccessExperiment.mockImplementationOnce(() => true); const req = { params: { experimentId: fake.EXPERIMENT_ID }, @@ -45,7 +45,7 @@ describe('Tests for authorization/authentication middlewares', () => { expect(next).toBeCalled(); - expect(userAccessModel.canAccessExperiment).toHaveBeenCalledWith( + expect(UserAccessModel.canAccessExperiment).toHaveBeenCalledWith( 'allowed-user-id', 'experimentid11111111111111111111', '/v2/experiments', @@ -54,7 +54,7 @@ describe('Tests for authorization/authentication middlewares', () => { }); it('Express middleware can reject incorrect users', async () => { - userAccessModel.canAccessExperiment.mockImplementationOnce(() => false); + UserAccessModel.canAccessExperiment.mockImplementationOnce(() => false); const req = { params: { experimentId: fake.EXPERIMENT_ID }, @@ -69,7 +69,7 @@ describe('Tests for authorization/authentication middlewares', () => { expect(next).toBeCalledWith(expect.any(UnauthorizedError)); - expect(userAccessModel.canAccessExperiment).toHaveBeenCalledWith( + expect(UserAccessModel.canAccessExperiment).toHaveBeenCalledWith( 'allowed-user-id', 'experimentid11111111111111111111', '/v2/experiments', @@ -78,7 +78,7 @@ describe('Tests for authorization/authentication middlewares', () => { }); it('Express middleware can reject unauthenticated requests', async () => { - userAccessModel.canAccessExperiment.mockImplementationOnce(() => true); + UserAccessModel.canAccessExperiment.mockImplementationOnce(() => true); const req = { params: { experimentId: fake.EXPERIMENT_ID }, diff --git a/tests/api.v2/mocks/getMockSqlClient.js b/tests/api.v2/mocks/getMockSqlClient.js index 69301c0df..f7224c1f5 100644 --- a/tests/api.v2/mocks/getMockSqlClient.js +++ b/tests/api.v2/mocks/getMockSqlClient.js @@ -4,17 +4,21 @@ const _ = require('lodash'); module.exports = () => { const queries = { select: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + del: jest.fn().mockReturnThis(), from: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), update: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), groupBy: jest.fn().mockReturnThis(), first: jest.fn().mockReturnThis(), - raw: jest.fn(), - returning: jest.fn(), + raw: jest.fn().mockReturnThis(), + returning: jest.fn().mockReturnThis(), join: jest.fn().mockReturnThis(), leftJoin: jest.fn().mockReturnThis(), as: jest.fn().mockReturnThis(), + into: jest.fn().mockReturnThis(), + timeout: jest.fn().mockReturnThis(), }; const queriesInTrx = _.cloneDeep(queries); @@ -27,7 +31,14 @@ module.exports = () => { const mockSqlClient = jest.fn(() => queriesInSqlClient); _.merge(mockSqlClient, { - transaction: jest.fn(() => Promise.resolve(mockTrx)), + transaction: jest.fn((param) => { + if (!param || !(param instanceof Function)) { + return mockTrx; + } + + // Received a function to run within the transaction + return param(mockTrx); + }), ...queriesInSqlClient, }); diff --git a/tests/api.v2/model/BasicModel.test.js b/tests/api.v2/model/BasicModel.test.js new file mode 100644 index 000000000..b1c3069d5 --- /dev/null +++ b/tests/api.v2/model/BasicModel.test.js @@ -0,0 +1,94 @@ +// Disabled ts because it doesn't recognize jest mocks +// @ts-nocheck +const { mockSqlClient } = require('../mocks/getMockSqlClient')(); + +jest.mock('../../../src/sql/sqlClient', () => ({ + get: jest.fn(() => mockSqlClient), +})); + +const BasicModel = require('../../../src/api.v2/model/BasicModel'); + +const mockTableName = 'mockTableName'; + +const mockRowId = 'mockId'; +const mockRowName = 'mockName'; + +describe('model/BasicModel', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('create works correctly', async () => { + await new BasicModel(mockSqlClient, mockTableName, ['id', 'name']).create({ id: mockRowId, name: mockRowName }); + + expect(mockSqlClient.insert).toHaveBeenCalledWith({ id: mockRowId, name: mockRowName }); + expect(mockSqlClient.returning).toHaveBeenCalledWith(['id', 'name']); + expect(mockSqlClient.into).toHaveBeenCalledWith(mockTableName); + expect(mockSqlClient.timeout).toHaveBeenCalledWith(4000); + }); + + it('findAll works correctly', async () => { + await new BasicModel(mockSqlClient, mockTableName, ['id', 'name']).findAll(); + + expect(mockSqlClient.select).toHaveBeenCalledWith(['id', 'name']); + expect(mockSqlClient.from).toHaveBeenCalledWith(mockTableName); + expect(mockSqlClient.timeout).toHaveBeenCalledWith(4000); + }); + + it('find works correctly', async () => { + await new BasicModel(mockSqlClient, mockTableName, ['id', 'name']).find({ id: 'mockId' }); + + expect(mockSqlClient.select).toHaveBeenCalledWith(['id', 'name']); + expect(mockSqlClient.from).toHaveBeenCalledWith(mockTableName); + expect(mockSqlClient.where).toHaveBeenCalledWith({ id: 'mockId' }); + expect(mockSqlClient.timeout).toHaveBeenCalledWith(4000); + }); + + it('findOne works correctly if result is an array', async () => { + const mockFind = jest.spyOn(BasicModel.prototype, 'find') + .mockImplementationOnce(() => Promise.resolve(['firstResult', 'secondResult'])); + + const result = await new BasicModel(mockSqlClient, mockTableName, ['id', 'name']).findOne({ id: 'mockId' }); + + expect(mockFind).toHaveBeenCalledWith({ id: 'mockId' }); + expect(result).toEqual('firstResult'); + }); + + it('findOne works correctly if result isn\'t an array', async () => { + const mockFind = jest.spyOn(BasicModel.prototype, 'find') + .mockImplementationOnce(() => Promise.resolve('onlyResult')); + + const result = await new BasicModel(mockSqlClient, mockTableName, ['id', 'name']).findOne({ id: 'mockId' }); + + expect(mockFind).toHaveBeenCalledWith({ id: 'mockId' }); + expect(result).toEqual('onlyResult'); + }); + + it('findById works correctly', async () => { + await new BasicModel(mockSqlClient, mockTableName, ['id', 'name']).findById('mockId'); + + expect(mockSqlClient.select).toHaveBeenCalledWith(['id', 'name']); + expect(mockSqlClient.from).toHaveBeenCalledWith(mockTableName); + expect(mockSqlClient.where).toHaveBeenCalledWith({ id: 'mockId' }); + expect(mockSqlClient.timeout).toHaveBeenCalledWith(4000); + }); + + it('update works correctly', async () => { + await new BasicModel(mockSqlClient, mockTableName, ['id', 'name']).update('mockId', { name: 'newNameUpdated' }); + + expect(mockSqlClient.update).toHaveBeenCalledWith({ name: 'newNameUpdated' }); + expect(mockSqlClient.from).toHaveBeenCalledWith(mockTableName); + expect(mockSqlClient.where).toHaveBeenCalledWith({ id: 'mockId' }); + expect(mockSqlClient.returning).toHaveBeenCalledWith(['id', 'name']); + expect(mockSqlClient.timeout).toHaveBeenCalledWith(4000); + }); + + it('destroy works correctly', async () => { + await new BasicModel(mockSqlClient, mockTableName, ['id', 'name']).destroy('mockId'); + + expect(mockSqlClient.del).toHaveBeenCalled(); + expect(mockSqlClient.from).toHaveBeenCalledWith(mockTableName); + expect(mockSqlClient.where).toHaveBeenCalledWith({ id: 'mockId' }); + expect(mockSqlClient.timeout).toHaveBeenCalledWith(4000); + }); +}); diff --git a/tests/api.v2/model/experiment.test.js b/tests/api.v2/model/Experiment.test.js similarity index 75% rename from tests/api.v2/model/experiment.test.js rename to tests/api.v2/model/Experiment.test.js index 1a9265c32..e3b13d6e4 100644 --- a/tests/api.v2/model/experiment.test.js +++ b/tests/api.v2/model/Experiment.test.js @@ -7,9 +7,6 @@ const validSamplesOrderResult = ['sampleId1', 'sampleId2', 'sampleId3', 'sampleI const { mockSqlClient, mockTrx } = require('../mocks/getMockSqlClient')(); -jest.mock('../../../src/api.v2/helpers/generateBasicModelFunctions', - () => jest.fn(() => ({ hasFakeBasicModelFunctions: true }))); - jest.mock('../../../src/sql/sqlClient', () => ({ get: jest.fn(() => mockSqlClient), })); @@ -19,30 +16,23 @@ jest.mock('../../../src/sql/helpers', () => ({ collapseKeyIntoArray: jest.fn(), })); -const experiment = require('../../../src/api.v2/model/experiment'); +const Experiment = require('../../../src/api.v2/model/Experiment'); const mockExperimentId = 'mockExperimentId'; +const mockSampleId = 'mockSampleId'; -describe('model/experiment', () => { +describe('model/Experiment', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('Returns the correct generateBasicModelFunctions', async () => { - expect(experiment).toEqual( - expect.objectContaining({ - hasFakeBasicModelFunctions: true, - }), - ); - }); - it('getAllExperiments works correctly', async () => { const queryResult = 'result'; helpers.collapseKeyIntoArray.mockReturnValueOnce( Promise.resolve(queryResult), ); - const expectedResult = await experiment.getAllExperiments('mockUserId'); + const expectedResult = await new Experiment().getAllExperiments('mockUserId'); expect(queryResult).toEqual(expectedResult); @@ -75,8 +65,8 @@ describe('model/experiment', () => { it('getExperimentData works correctly', async () => { const experimentFields = [ 'id', 'name', 'description', - 'samples_order', 'notify_by_email', - 'processing_config', 'created_at', 'updated_at', + 'samples_order', 'processing_config', 'notify_by_email', + 'created_at', 'updated_at', ]; const queryResult = 'result'; @@ -86,7 +76,7 @@ describe('model/experiment', () => { const mockCollapsedObject = 'collapsedObject'; mockSqlClient.raw.mockImplementationOnce(() => mockCollapsedObject); - const expectedResult = await experiment.getExperimentData(mockExperimentId); + const expectedResult = await new Experiment().getExperimentData(mockExperimentId); expect(expectedResult).toEqual(queryResult); @@ -115,7 +105,7 @@ describe('model/experiment', () => { helpers.collapseKeysIntoObject.mockReturnValueOnce(mockSqlClient); mockSqlClient.first.mockReturnValueOnce({}); - await expect(experiment.getExperimentData(mockExperimentId)).rejects.toThrow(new Error('Experiment not found')); + await expect(new Experiment().getExperimentData(mockExperimentId)).rejects.toThrow(new Error('Experiment not found')); }); it('updateSamplePosition works correctly if valid params are passed', async () => { @@ -124,7 +114,7 @@ describe('model/experiment', () => { ); mockTrx.raw.mockImplementationOnce(() => 'resultOfSql.raw'); - await experiment.updateSamplePosition(mockExperimentId, 0, 1); + await new Experiment().updateSamplePosition(mockExperimentId, 0, 1); expect(mockTrx).toHaveBeenCalledWith('experiment'); @@ -141,7 +131,7 @@ describe('model/experiment', () => { mockTrx.returning.mockImplementationOnce(() => Promise.resolve([{ samplesOrder: null }])); mockTrx.raw.mockImplementationOnce(() => 'resultOfSql.raw'); - await expect(experiment.updateSamplePosition(mockExperimentId, 0, 1)).rejects.toThrow('Invalid update parameters'); + await expect(new Experiment().updateSamplePosition(mockExperimentId, 0, 1)).rejects.toThrow('Invalid update parameters'); expect(mockTrx).toHaveBeenCalledWith('experiment'); @@ -160,7 +150,7 @@ describe('model/experiment', () => { ); mockTrx.raw.mockImplementationOnce(() => 'resultOfSql.raw'); - await expect(experiment.updateSamplePosition(mockExperimentId, 0, 10000)).rejects.toThrow('Invalid update parameters'); + await expect(new Experiment().updateSamplePosition(mockExperimentId, 0, 10000)).rejects.toThrow('Invalid update parameters'); expect(mockTrx).toHaveBeenCalledWith('experiment'); @@ -172,4 +162,26 @@ describe('model/experiment', () => { expect(mockTrx.commit).not.toHaveBeenCalled(); expect(mockTrx.rollback).toHaveBeenCalled(); }); + + it('addSample works correctly', async () => { + mockSqlClient.where.mockImplementationOnce(() => { Promise.resolve(); }); + mockSqlClient.raw.mockImplementationOnce(() => 'RawSqlCommand'); + + await new Experiment().addSample(mockExperimentId, mockSampleId); + + expect(mockSqlClient.update).toHaveBeenCalledWith({ samples_order: 'RawSqlCommand' }); + expect(mockSqlClient.raw).toHaveBeenCalledWith('samples_order || \'["mockSampleId"]\'::jsonb'); + expect(mockSqlClient.where).toHaveBeenCalledWith('id', 'mockExperimentId'); + }); + + it('deleteSample works correctly', async () => { + mockSqlClient.where.mockImplementationOnce(() => { Promise.resolve(); }); + mockSqlClient.raw.mockImplementationOnce(() => 'RawSqlCommand'); + + await new Experiment().deleteSample(mockExperimentId, mockSampleId); + + expect(mockSqlClient.update).toHaveBeenCalledWith({ samples_order: 'RawSqlCommand' }); + expect(mockSqlClient.raw).toHaveBeenCalledWith('samples_order - \'mockSampleId\''); + expect(mockSqlClient.where).toHaveBeenCalledWith('id', 'mockExperimentId'); + }); }); diff --git a/tests/api.v2/model/MetadataTrack.test.js b/tests/api.v2/model/MetadataTrack.test.js new file mode 100644 index 000000000..f547f1e33 --- /dev/null +++ b/tests/api.v2/model/MetadataTrack.test.js @@ -0,0 +1,39 @@ +// @ts-nocheck +const { mockSqlClient } = require('../mocks/getMockSqlClient')(); + +jest.mock('../../../src/sql/sqlClient', () => ({ + get: jest.fn(() => mockSqlClient), +})); + +const MetadataTrack = require('../../../src/api.v2/model/MetadataTrack'); + +describe('model/userAccess', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('createNewExperimentPermissions works correctly when experiment has metadata tracks', async () => { + const mockExperimentId = 'mockExperimentId'; + const mockSampleId = 'mockSampleId'; + + mockSqlClient.where.mockImplementationOnce(() => Promise.resolve([{ id: 'track1Id' }, { id: 'track2Id' }])); + + await new MetadataTrack().createNewSampleValues(mockExperimentId, mockSampleId); + + expect(mockSqlClient.insert).toHaveBeenCalledWith([ + { metadata_track_id: 'track1Id', sample_id: 'mockSampleId', value: 'N.A.' }, + { metadata_track_id: 'track2Id', sample_id: 'mockSampleId', value: 'N.A.' }, + ]); + }); + + it('createNewExperimentPermissions doesn\'t do anything when experiment has no metadata tracks', async () => { + const mockExperimentId = 'mockExperimentId'; + const mockSampleId = 'mockSampleId'; + + mockSqlClient.where.mockImplementationOnce(() => Promise.resolve([])); + + await new MetadataTrack().createNewSampleValues(mockExperimentId, mockSampleId); + + expect(mockSqlClient.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/api.v2/model/userAccess.test.js b/tests/api.v2/model/UserAccess.test.js similarity index 77% rename from tests/api.v2/model/userAccess.test.js rename to tests/api.v2/model/UserAccess.test.js index 3497de174..0d204cb07 100644 --- a/tests/api.v2/model/userAccess.test.js +++ b/tests/api.v2/model/UserAccess.test.js @@ -3,22 +3,15 @@ const roles = require('../../../src/api.v2/helpers/roles'); const { mockSqlClient } = require('../mocks/getMockSqlClient')(); -const mockCreate = jest.fn(); -jest.mock('../../../src/api.v2/helpers/generateBasicModelFunctions', () => jest.fn(() => ( - { - hasFakeBasicModelFunctions: true, - create: mockCreate, - } -))); - jest.mock('../../../src/api.v2/helpers/roles'); jest.mock('../../../src/sql/sqlClient', () => ({ get: jest.fn(() => mockSqlClient), })); -const userAccess = require('../../../src/api.v2/model/userAccess'); +const BasicModel = require('../../../src/api.v2/model/BasicModel'); +const UserAccess = require('../../../src/api.v2/model/UserAccess'); const mockUserAccessCreateResults = [ [{ @@ -40,22 +33,15 @@ describe('model/userAccess', () => { jest.clearAllMocks(); }); - it('Returns the correct generateBasicModelFunctions', async () => { - expect(userAccess).toEqual( - expect.objectContaining({ - hasFakeBasicModelFunctions: true, - }), - ); - }); - it('createNewExperimentPermissions works correctly', async () => { const userId = 'userId'; const experimentId = 'experimentId'; - mockCreate + + const mockCreate = jest.spyOn(BasicModel.prototype, 'create') .mockImplementationOnce(() => Promise.resolve([mockUserAccessCreateResults[0]])) .mockImplementationOnce(() => Promise.resolve([mockUserAccessCreateResults[1]])); - await userAccess.createNewExperimentPermissions(userId, experimentId); + await new UserAccess().createNewExperimentPermissions(userId, experimentId); expect(mockCreate).toHaveBeenCalledWith({ access_role: 'admin', experiment_id: 'experimentId', user_id: 'mockAdminSub' }); expect(mockCreate).toHaveBeenCalledWith({ access_role: 'owner', experiment_id: 'experimentId', user_id: 'userId' }); @@ -65,9 +51,11 @@ describe('model/userAccess', () => { it('createNewExperimentPermissions works correctly when creator is admin', async () => { const userId = 'mockAdminSub'; const experimentId = 'experimentId'; - mockCreate.mockImplementationOnce(() => Promise.resolve([mockUserAccessCreateResults[0]])); - await userAccess.createNewExperimentPermissions(userId, experimentId); + const mockCreate = jest.spyOn(BasicModel.prototype, 'create') + .mockImplementationOnce(() => Promise.resolve([mockUserAccessCreateResults[0]])); + + await new UserAccess().createNewExperimentPermissions(userId, experimentId); expect(mockCreate).toHaveBeenCalledWith({ access_role: 'admin', experiment_id: 'experimentId', user_id: 'mockAdminSub' }); expect(mockCreate).toHaveBeenCalledTimes(1); @@ -76,23 +64,25 @@ describe('model/userAccess', () => { it('createNewExperimentPermissions fails if admin creation failed', async () => { const userId = 'userId'; const experimentId = 'experimentId'; - mockCreate.mockImplementationOnce(() => Promise.reject(new Error('A happy sql error :)'))); - await expect(userAccess.createNewExperimentPermissions(userId, experimentId)).rejects.toThrow('A happy sql error :)'); + const mockCreate = jest.spyOn(BasicModel.prototype, 'create') + .mockImplementationOnce(() => Promise.reject(new Error('A happy sql error :)'))); - expect(mockCreate).toHaveBeenCalledWith({ access_role: 'admin', experiment_id: 'experimentId', user_id: 'mockAdminSub' }); + await expect(new UserAccess().createNewExperimentPermissions(userId, experimentId)).rejects.toThrow('A happy sql error :)'); + expect(mockCreate).toHaveBeenCalledWith({ access_role: 'admin', experiment_id: 'experimentId', user_id: 'mockAdminSub' }); expect(mockCreate).toHaveBeenCalledTimes(1); }); it('createNewExperimentPermissions fails if owner creation failed', async () => { const userId = 'userId'; const experimentId = 'experimentId'; - mockCreate + + const mockCreate = jest.spyOn(BasicModel.prototype, 'create') .mockImplementationOnce(() => Promise.resolve([mockUserAccessCreateResults[0]])) .mockImplementationOnce(() => Promise.reject(new Error('A happy sql error :)'))); - await expect(userAccess.createNewExperimentPermissions(userId, experimentId)).rejects.toThrow('A happy sql error :)'); + await expect(new UserAccess().createNewExperimentPermissions(userId, experimentId)).rejects.toThrow('A happy sql error :)'); expect(mockCreate).toHaveBeenCalledWith({ access_role: 'admin', experiment_id: 'experimentId', user_id: 'mockAdminSub' }); expect(mockCreate).toHaveBeenCalledWith({ access_role: 'owner', experiment_id: 'experimentId', user_id: 'userId' }); @@ -107,10 +97,10 @@ describe('model/userAccess', () => { mockSqlClient.from.mockImplementationOnce(() => ({ accessRole: 'roleThatIsOk' })); - // @ts-ignore roles.isRoleAuthorized.mockImplementationOnce(() => true); - const result = await userAccess.canAccessExperiment(userId, experimentId, url, method); + const result = await new UserAccess().canAccessExperiment(userId, experimentId, url, method); + expect(mockSqlClient.first).toHaveBeenCalled(); expect(mockSqlClient.from).toHaveBeenCalledWith('user_access'); @@ -119,7 +109,6 @@ describe('model/userAccess', () => { ); expect(roles.isRoleAuthorized).toHaveBeenCalledWith('roleThatIsOk', url, method); - expect(result).toEqual(true); }); @@ -131,7 +120,7 @@ describe('model/userAccess', () => { mockSqlClient.from.mockImplementationOnce(() => undefined); - const result = await userAccess.canAccessExperiment(userId, experimentId, url, method); + const result = await new UserAccess().canAccessExperiment(userId, experimentId, url, method); expect(mockSqlClient.first).toHaveBeenCalled(); expect(mockSqlClient.from).toHaveBeenCalledWith('user_access'); @@ -152,10 +141,9 @@ describe('model/userAccess', () => { mockSqlClient.from.mockImplementationOnce(() => ({ accessRole: 'roleThatIsNotOk' })); - // @ts-ignore roles.isRoleAuthorized.mockImplementationOnce(() => false); - const result = await userAccess.canAccessExperiment(userId, experimentId, url, method); + const result = await new UserAccess().canAccessExperiment(userId, experimentId, url, method); expect(mockSqlClient.first).toHaveBeenCalled(); expect(mockSqlClient.from).toHaveBeenCalledWith('user_access'); diff --git a/tests/api.v2/model/__snapshots__/Experiment.test.js.snap b/tests/api.v2/model/__snapshots__/Experiment.test.js.snap new file mode 100644 index 000000000..e8fcfd911 --- /dev/null +++ b/tests/api.v2/model/__snapshots__/Experiment.test.js.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`model/Experiment getExperimentData works correctly 1`] = ` +Array [ + "COALESCE( + jsonb_object_agg(pipeline_type, jsonb_build_object('params_hash', params_hash, 'state_machine_arn', state_machine_arn, 'execution_arn', execution_arn)) + FILTER( + WHERE pipeline_type IS NOT NULL + ), + '{}'::jsonb + ) as pipelines", +] +`; + +exports[`model/Experiment updateSamplePosition rolls back if the parameters are invalid 1`] = ` +Array [ + "( + SELECT jsonb_insert(samples_order - 0, '{10000}', samples_order -> 0, false) + FROM ( + SELECT (samples_order) + FROM experiment e + WHERE e.id = 'mockExperimentId' + ) samples_order + )", +] +`; + +exports[`model/Experiment updateSamplePosition rolls back if the result is invalid 1`] = ` +Array [ + "( + SELECT jsonb_insert(samples_order - 0, '{1}', samples_order -> 0, false) + FROM ( + SELECT (samples_order) + FROM experiment e + WHERE e.id = 'mockExperimentId' + ) samples_order + )", +] +`; + +exports[`model/Experiment updateSamplePosition works correctly if valid params are passed 1`] = ` +Array [ + "( + SELECT jsonb_insert(samples_order - 0, '{1}', samples_order -> 0, false) + FROM ( + SELECT (samples_order) + FROM experiment e + WHERE e.id = 'mockExperimentId' + ) samples_order + )", +] +`; diff --git a/tests/api.v2/model/__snapshots__/experiment.test.js.snap b/tests/api.v2/model/__snapshots__/experiment.test.js.snap deleted file mode 100644 index ccb54dbab..000000000 --- a/tests/api.v2/model/__snapshots__/experiment.test.js.snap +++ /dev/null @@ -1,52 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`model/experiment getExperimentData works correctly 1`] = ` -Array [ - "COALESCE( - jsonb_object_agg(pipeline_type, jsonb_build_object('params_hash', params_hash, 'state_machine_arn', state_machine_arn, 'execution_arn', execution_arn)) - FILTER( - WHERE pipeline_type IS NOT NULL - ), - '{}'::jsonb - ) as pipelines", -] -`; - -exports[`model/experiment updateSamplePosition rolls back if the parameters are invalid 1`] = ` -Array [ - "( - SELECT jsonb_insert(samples_order - 0, '{10000}', samples_order -> 0, false) - FROM ( - SELECT (samples_order) - FROM experiment e - WHERE e.id = 'mockExperimentId' - ) samples_order - )", -] -`; - -exports[`model/experiment updateSamplePosition rolls back if the result is invalid 1`] = ` -Array [ - "( - SELECT jsonb_insert(samples_order - 0, '{1}', samples_order -> 0, false) - FROM ( - SELECT (samples_order) - FROM experiment e - WHERE e.id = 'mockExperimentId' - ) samples_order - )", -] -`; - -exports[`model/experiment updateSamplePosition works correctly if valid params are passed 1`] = ` -Array [ - "( - SELECT jsonb_insert(samples_order - 0, '{1}', samples_order -> 0, false) - FROM ( - SELECT (samples_order) - FROM experiment e - WHERE e.id = 'mockExperimentId' - ) samples_order - )", -] -`; diff --git a/tests/api.v2/model/inviteAccess.test.js b/tests/api.v2/model/inviteAccess.test.js deleted file mode 100644 index 9e1992259..000000000 --- a/tests/api.v2/model/inviteAccess.test.js +++ /dev/null @@ -1,27 +0,0 @@ -const generateBasicModelFunctions = require('../../../src/api.v2/helpers/generateBasicModelFunctions'); - -jest.mock('../../../src/api.v2/helpers/generateBasicModelFunctions', - () => jest.fn(() => ({ hasFakeBasicModelFunctions: true }))); - -const inviteAccess = require('../../../src/api.v2/model/inviteAccess'); - -describe('model/inviteAccess', () => { - it('Returns the correct generateBasicModelFunctions', async () => { - expect(generateBasicModelFunctions).toHaveBeenCalledTimes(1); - expect(generateBasicModelFunctions).toHaveBeenCalledWith({ - tableName: 'invite_access', - selectableProps: [ - 'user_email', - 'experiment_id', - 'access_role', - 'updated_at', - ], - }); - - expect(inviteAccess).toEqual( - expect.objectContaining({ - hasFakeBasicModelFunctions: true, - }), - ); - }); -}); diff --git a/tests/api.v2/routes/sample.test.js b/tests/api.v2/routes/sample.test.js new file mode 100644 index 000000000..11c118f0a --- /dev/null +++ b/tests/api.v2/routes/sample.test.js @@ -0,0 +1,109 @@ +// @ts-nocheck +const express = require('express'); +const request = require('supertest'); +const expressLoader = require('../../../src/loaders/express'); + +const { OK } = require('../../../src/utils/responses'); + +const sampleController = require('../../../src/api.v2/controllers/sampleController'); + +jest.mock('../../../src/api.v2/controllers/sampleController', () => ({ + createSample: jest.fn(), + deleteSample: jest.fn(), +})); + +jest.mock('../../../src/api.v2/middlewares/authMiddlewares'); + +const experimentId = 'experimentId'; +const sampleId = 'sampleId'; + +describe('tests for experiment route', () => { + let app = null; + + beforeEach(async () => { + const mockApp = await expressLoader(express()); + app = mockApp.app; + }); + + afterEach(() => { + /** + * Most important since b'coz of caching, the mocked implementations sometimes does not reset + */ + jest.resetModules(); + jest.restoreAllMocks(); + }); + + it('Creating a new sample works', async (done) => { + sampleController.createSample.mockImplementationOnce((req, res) => { + res.json(OK()); + return Promise.resolve(); + }); + + const sampleData = { + name: 'sampleName', + sampleTechnology: '10x', + }; + + request(app) + .post(`/v2/experiments/${experimentId}/samples/${sampleId}`) + .send(sampleData) + .expect(200) + .end((err) => { + if (err) { + return done(err); + } + // there is no point testing for the values of the response body + // - if something is wrong, the schema validator will catch it + return done(); + }); + }); + + it('Creating a new sample fails if request body is invalid', async (done) => { + sampleController.createSample.mockImplementationOnce((req, res) => { + res.json(OK()); + return Promise.resolve(); + }); + + const invalidSampleData = { + name: 'sampleName', + sampleTechnology: 'Invalidtechnology', + }; + + request(app) + .post(`/v2/experiments/${experimentId}/samples/${sampleId}`) + .send(invalidSampleData) + .expect(400) + .end((err) => { + if (err) { + return done(err); + } + // there is no point testing for the values of the response body + // - if something is wrong, the schema validator will catch it + return done(); + }); + }); + + it('Deleting a sample works', async (done) => { + sampleController.deleteSample.mockImplementationOnce((req, res) => { + res.json(OK()); + return Promise.resolve(); + }); + + const invalidExperimentData = { + description: 'experimentDescription', + }; + + request(app) + .delete(`/v2/experiments/${experimentId}/samples/${sampleId}`) + .send(invalidExperimentData) + .expect(200) + .end((err) => { + if (err) { + return done(err); + } + // there is no point testing for the values of the response body + // - if something is wrong, the schema validator will catch it + return done(); + }); + }); +}); diff --git a/tests/utils/authMiddlewares.test.js b/tests/utils/authMiddlewares.test.js index 454bd7b05..0f0889764 100644 --- a/tests/utils/authMiddlewares.test.js +++ b/tests/utils/authMiddlewares.test.js @@ -11,12 +11,6 @@ const { mockDynamoBatchGetItem, } = require('../test-utils/mockAWSServices'); -const userAccessModel = require('../../src/api.v2/model/userAccess'); - -jest.mock('../../src/api.v2/model/userAccess', () => ({ - canAccessExperiment: jest.fn(), -})); - describe('Tests for authorization/authentication middlewares', () => { // Sample experiment permission data. const data = {