diff --git a/src/index.ts b/src/index.ts index bb8e047..4c0beca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,7 @@ app.use('/', require('./routes/material')); app.use('/', require('./routes/template')); app.use('/', require('./routes/user')); app.use('/', require('./routes/condition')); +app.use('/', require('./routes/measurement')); // static files app.use('/static', express.static('static')); diff --git a/src/models/measurement.ts b/src/models/measurement.ts new file mode 100644 index 0000000..401103b --- /dev/null +++ b/src/models/measurement.ts @@ -0,0 +1,12 @@ +import mongoose from 'mongoose'; +import ConditionModel from './condition'; +import MeasurementTemplateModel from './measurement_template'; + +const MeasurementSchema = new mongoose.Schema({ + condition_id: {type: mongoose.Schema.Types.ObjectId, ref: ConditionModel}, + values: mongoose.Schema.Types.Mixed, + status: Number, + measurement_template: {type: mongoose.Schema.Types.ObjectId, ref: MeasurementTemplateModel} +}); + +export default mongoose.model('measurement', MeasurementSchema); \ No newline at end of file diff --git a/src/routes/condition.spec.ts b/src/routes/condition.spec.ts index 5884b2e..bc7f0a2 100644 --- a/src/routes/condition.spec.ts +++ b/src/routes/condition.spec.ts @@ -1,7 +1,7 @@ import should from 'should/as-function'; import ConditionModel from '../models/condition'; import TestHelper from "../test/helper"; - +// TODO: status describe('/condition', () => { let server; diff --git a/src/routes/condition.ts b/src/routes/condition.ts index 687ea2a..517e623 100644 --- a/src/routes/condition.ts +++ b/src/routes/condition.ts @@ -49,7 +49,7 @@ router.put('/condition/' + IdValidate.parameter(), async (req, res, next) => { if (!await treatmentCheck(condition, 'change', res, next)) return; console.log(condition); - ConditionModel.findByIdAndUpdate(req.params.id, condition, {new: true}).lean().exec((err, data) => { + await ConditionModel.findByIdAndUpdate(req.params.id, condition, {new: true}).lean().exec((err, data) => { if (err) return next(err); res.json(ConditionValidate.output(data)); }); @@ -64,7 +64,7 @@ router.delete('/condition/' + IdValidate.parameter(), (req, res, next) => { res.status(404).json({status: 'Not found'}); } if (!await sampleIdCheck(data, req, res, next)) return; - ConditionModel.findByIdAndDelete(req.params.id).lean().exec(async err => { + await ConditionModel.findByIdAndDelete(req.params.id).lean().exec(async err => { if (err) return next(err); res.json({status: 'OK'}); }); @@ -81,7 +81,7 @@ router.post('/condition/new', async (req, res, next) => { if (!await numberCheck(condition, res, next)) return; if (!await treatmentCheck(condition, 'new', res, next)) return; - new ConditionModel(condition).save((err, data) => { + await new ConditionModel(condition).save((err, data) => { if (err) return next(err); res.json(ConditionValidate.output(data.toObject())); }); @@ -113,7 +113,7 @@ async function numberCheck (condition, res, next) { // validate number, returns async function treatmentCheck (condition, param, res, next) { const treatmentData = await TreatmentTemplateModel.findById(condition.treatment_template).lean().exec().catch(err => {next(err); return false;}) as any; - if (!treatmentData) { // sample_id not found + if (!treatmentData) { // template not found res.status(400).json({status: 'Treatment template not available'}); return false } diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts index 1e7e7ff..59bdd4a 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -1,7 +1,7 @@ import should from 'should/as-function'; import MaterialModel from '../models/material'; import TestHelper from "../test/helper"; - +// TODO: status describe('/material', () => { let server; diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts new file mode 100644 index 0000000..c1ed0fa --- /dev/null +++ b/src/routes/measurement.spec.ts @@ -0,0 +1,260 @@ +import should from 'should/as-function'; +import MeasurementModel from '../models/measurement'; +import TestHelper from "../test/helper"; + +describe('/measurement', () => { + let server; + before(done => TestHelper.before(done)); + beforeEach(done => server = TestHelper.beforeEach(server, done)); + afterEach(done => TestHelper.afterEach(server, done)); + + describe('GET /mesurement/{id}', () => { + it('returns the right measurement', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/800000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 200, + res: {_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} + }); + }); + it('returns the measurement for an API key', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/800000000000000000000001', + auth: {key: 'janedoe'}, + httpStatus: 200, + res: {_id: '800000000000000000000001', condition_id: '700000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'} + }); + }); + it('rejects an invalid id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/8000000000h0000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects an unknown id', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/000000000000000000000001', + auth: {basic: 'janedoe'}, + httpStatus: 404 + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'get', + url: '/measurement/800000000000000000000001', + httpStatus: 401 + }); + }); + }); + + describe('POST /measurement/new', () => { + it('returns the right measurement', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'condition_id', 'values', 'measurement_template'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('condition_id', '700000000000000000000001'); + should(res.body).have.property('measurement_template', '300000000000000000000002'); + should(res.body).have.property('values'); + should(res.body.values).have.property('weight %', 0.8); + should(res.body.values).have.property('standard deviation', 0.1); + done(); + }); + }); + it('stores the measurement', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 200, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }).end((err, res) => { + if (err) return done(err); + MeasurementModel.findById(res.body._id).lean().exec((err, data: any) => { + should(data).have.only.keys('_id', 'condition_id', 'values', 'measurement_template', '__v'); + should(data.condition_id.toString()).be.eql('700000000000000000000001'); + should(data.measurement_template.toString()).be.eql('300000000000000000000002'); + should(data).have.property('values'); + should(data.values).have.property('weight %', 0.8); + should(data.values).have.property('standard deviation', 0.1); + done(); + }); + }); + }); + it('rejects an invalid condition id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000h00000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"condition_id" with value "700000000000h00000000001" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects a condition id not available', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '000000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Condition id not available'} + }); + }); + it('rejects an invalid measurement_template id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '30000000000h000000000002'}, + res: {status: 'Invalid body format', details: '"measurement_template" with value "30000000000h000000000002" fails to match the required pattern: /[0-9a-f]{24}/'} + }); + }); + it('rejects a measurement_template not available', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '000000000000000000000002'}, + res: {status: 'Measurement template not available'} + }); + }); + it('rejects not specified values', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1, xx: 44}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"xx" is not allowed'} + }); + }); + it('rejects missing values', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"standard deviation" is required'} + }); + }); + it('rejects a value not in the value range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {val1: 4}, measurement_template: '300000000000000000000003'}, + res: {status: 'Invalid body format', details: '"val1" must be one of [1, 2, 3]'} + }); + }); + it('rejects a value below minimum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': -1, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"weight %" must be larger than or equal to 0'} + }); + }); + it('rejects a value above maximum range', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 2}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"standard deviation" must be less than or equal to 0.5'} + }); + }); + it('rejects a missing condition id', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'}, + res: {status: 'Invalid body format', details: '"condition_id" is required'} + }); + }); + it('rejects a missing measurement_template', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 400, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}}, + res: {status: 'Invalid body format', details: '"measurement_template" is required'} + }); + }); + it('rejects adding a measurement to the sample of another user for a write user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'janedoe'}, + httpStatus: 403, + req: {condition_id: '700000000000000000000003', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }); + }); + it('accepts adding a measurement to the sample of another user for a maintain/admin user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'admin'}, + httpStatus: 200, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }).end((err, res) => { + if (err) return done(err); + should(res.body).have.only.keys('_id', 'condition_id', 'values', 'measurement_template'); + should(res.body).have.property('_id').be.type('string'); + should(res.body).have.property('condition_id', '700000000000000000000001'); + should(res.body).have.property('measurement_template', '300000000000000000000002'); + should(res.body).have.property('values'); + should(res.body.values).have.property('weight %', 0.8); + should(res.body.values).have.property('standard deviation', 0.1); + done(); + }); + }); + it('rejects an API key', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {key: 'janedoe'}, + httpStatus: 401, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }); + }); + it('rejects requests from a read user', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + auth: {basic: 'user'}, + httpStatus: 403, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }); + }); + it('rejects unauthorized requests', done => { + TestHelper.request(server, done, { + method: 'post', + url: '/measurement/new', + httpStatus: 401, + req: {condition_id: '700000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'} + }); + }); + }); +}); \ No newline at end of file diff --git a/src/routes/measurement.ts b/src/routes/measurement.ts new file mode 100644 index 0000000..8dda6cd --- /dev/null +++ b/src/routes/measurement.ts @@ -0,0 +1,68 @@ +import express from 'express'; + +import MeasurementModel from '../models/measurement'; +import ConditionModel from '../models/condition'; +import MeasurementTemplateModel from '../models/measurement_template'; +import MeasurementValidate from './validate/measurement'; +import IdValidate from './validate/id'; +import res400 from './validate/res400'; +import ParametersValidate from './validate/parameters'; + + +const router = express.Router(); + +router.get('/measurement/' + IdValidate.parameter(), (req, res, next) => { + if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; + + MeasurementModel.findById(req.params.id).lean().exec((err, data) => { + if (err) return next(err); + if (!data) { + return res.status(404).json({status: 'Not found'}); + } + + res.json(MeasurementValidate.output(data)); + }); +}); + +router.post('/measurement/new', async (req, res, next) => { + if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return; + + const {error, value: measurement} = MeasurementValidate.input(req.body, 'new'); + if (error) return res400(error, res); + + if (!await conditionIdCheck(measurement, req, res, next)) return; + if (!await templateCheck(measurement, 'new', res, next)) return; + + await new MeasurementModel(measurement).save((err, data) => { + if (err) return next(err); + res.json(MeasurementValidate.output(data.toObject())); + }); +}); + + +module.exports = router; + + +async function conditionIdCheck (measurement, req, res, next) { // validate condition_id, returns false if invalid + const sampleData = await ConditionModel.findById(measurement.condition_id).populate('sample_id').lean().exec().catch(err => {next(err); return false;}) as any; + if (!sampleData) { // sample_id not found + res.status(400).json({status: 'Condition id not available'}); + return false + } + if (sampleData.sample_id.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return false; // sample does not belong to user + return true; +} + +async function templateCheck (measurement, param, res, next) { // validate measurement_template and values + const templateData = await MeasurementTemplateModel.findById(measurement.measurement_template).lean().exec().catch(err => {next(err); return false;}) as any; + if (!templateData) { // template not found + res.status(400).json({status: 'Measurement template not available'}); + return false + } + + // validate values + const {error, value: ignore} = ParametersValidate.input(measurement.values, templateData.parameters, param); + console.log(error); + if (error) {res400(error, res); return false;} + return true; +} \ No newline at end of file diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 28acff9..d74703d 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -4,6 +4,8 @@ import NoteModel from '../models/note'; import NoteFieldModel from '../models/note_field'; import TestHelper from "../test/helper"; // TODO: generate sample number +// TODO: think again which parameters are required at POST +// TODO: status describe('/sample', () => { let server; diff --git a/src/routes/validate/condition.ts b/src/routes/validate/condition.ts index 10d90f5..f130076 100644 --- a/src/routes/validate/condition.ts +++ b/src/routes/validate/condition.ts @@ -4,8 +4,6 @@ import IdValidate from './id'; export default class ConditionValidate { private static condition = { - sample_id: IdValidate.get(), - number: Joi.string() .max(128), @@ -14,20 +12,19 @@ export default class ConditionValidate { .try( Joi.string().max(128), Joi.number(), - Joi.boolean() + Joi.boolean(), + Joi.array() ) - ), - - treatment_template: IdValidate.get() + ) } static input (data, param) { if (param === 'new') { return Joi.object({ - sample_id: this.condition.sample_id.required(), + sample_id: IdValidate.get().required(), number: this.condition.number.required(), parameters: this.condition.parameters.required(), - treatment_template: this.condition.treatment_template.required() + treatment_template: IdValidate.get().required() }).validate(data); } else if (param === 'change') { @@ -45,10 +42,10 @@ export default class ConditionValidate { data = IdValidate.stringify(data); const {value, error} = Joi.object({ _id: IdValidate.get(), - sample_id: this.condition.sample_id, + sample_id: IdValidate.get(), number: this.condition.number, parameters: this.condition.parameters, - treatment_template: this.condition.treatment_template + treatment_template: IdValidate.get() }).validate(data, {stripUnknown: true}); return error !== undefined? null : value; } diff --git a/src/routes/validate/measurement.ts b/src/routes/validate/measurement.ts new file mode 100644 index 0000000..0efaaea --- /dev/null +++ b/src/routes/validate/measurement.ts @@ -0,0 +1,46 @@ +import Joi from '@hapi/joi'; + +import IdValidate from './id'; + +export default class MeasurementValidate { + private static measurement = { + values: Joi.object() + .pattern(/.*/, Joi.alternatives() + .try( + Joi.string().max(128), + Joi.number(), + Joi.boolean(), + Joi.array() + ) + ) + }; + + static input (data, param) { + if (param === 'new') { + return Joi.object({ + condition_id: IdValidate.get().required(), + values: this.measurement.values.required(), + measurement_template: IdValidate.get().required() + }).validate(data); + } + else if (param === 'change') { + return Joi.object({ + values: this.measurement.values + }).validate(data); + } + else { + return{error: 'No parameter specified!', value: {}}; + } + } + + static output (data) { + data = IdValidate.stringify(data); + const {value, error} = Joi.object({ + _id: IdValidate.get(), + condition_id: IdValidate.get(), + values: this.measurement.values, + measurement_template: IdValidate.get() + }).validate(data, {stripUnknown: true}); + return error !== undefined? null : value; + } +} \ No newline at end of file diff --git a/src/test/db.json b/src/test/db.json index 64079ef..06ac3d6 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -229,6 +229,22 @@ "__v": 0 } ], + "measurements": [ + { + "_id": {"$oid":"800000000000000000000001"}, + "condition_id": {"$oid":"700000000000000000000001"}, + "values": { + "dpt": [ + [3997.12558,98.00555], + [3995.08519,98.03253], + [3993.04480,98.02657] + ] + }, + "status": 10, + "measurement_template": {"$oid":"300000000000000000000001"}, + "__v": 0 + } + ], "treatment_templates": [ { "_id": {"$oid":"200000000000000000000001"}, @@ -272,7 +288,9 @@ "parameters": [ { "name": "dpt", - "range": {} + "range": { + "type": "array" + } } ], "__v": 0 @@ -297,6 +315,19 @@ } ], "__v": 0 + }, + { + "_id": {"$oid":"300000000000000000000003"}, + "name": "mt 3", + "parameters": [ + { + "name": "val1", + "range": { + "values": [1,2,3] + } + } + ], + "__v": 0 } ], "users": [