{ }
+ interface MediaType extends core.MediaType { }
+ interface NextFunction extends core.NextFunction { }
+ interface Request extends core.Request
{ }
+ interface RequestHandler
extends core.RequestHandler
{ }
+ interface RequestParamHandler extends core.RequestParamHandler { }
+ export interface Response extends core.Response { }
+ interface Router extends core.Router { }
+ interface Send extends core.Send { }
+}
+
+export = e;
diff --git a/src/db.ts b/src/db.ts
new file mode 100644
index 0000000..2bab005
--- /dev/null
+++ b/src/db.ts
@@ -0,0 +1,158 @@
+import mongoose from 'mongoose';
+import cfenv from 'cfenv';
+import _ from 'lodash';
+import ChangelogModel from './models/changelog';
+
+
+// database urls, prod db url is retrieved automatically
+const TESTING_URL = 'mongodb://localhost/dfopdb_test';
+const DEV_URL = 'mongodb://localhost/dfopdb';
+const debugging = true;
+
+if (process.env.NODE_ENV !== 'production' && debugging) {
+ mongoose.set('debug', true); // enable mongoose debug
+}
+
+export default class db {
+ private static state = { // db object and current mode (test, dev, prod)
+ db: null,
+ mode: null,
+ };
+
+ static connect (mode = '', done: Function = () => {}) { // set mode to test for unit/integration tests, otherwise skip parameters. done is also only needed for testing
+ if (this.state.db) return done(); // db is already connected
+
+ // find right connection url
+ let connectionString: string = "";
+ if (mode === 'test') { // testing
+ connectionString = TESTING_URL;
+ this.state.mode = 'test';
+ }
+ else if(process.env.NODE_ENV === 'production') {
+ let services = cfenv.getAppEnv().getServices();
+ for (let service in services) {
+ if(services[service].tags.indexOf("mongodb") >= 0) {
+ connectionString = services[service]["credentials"].uri;
+ }
+ }
+ this.state.mode = 'prod';
+ }
+ else {
+ connectionString = DEV_URL;
+ this.state.mode = 'dev';
+ }
+
+ // connect to db
+ mongoose.connect(connectionString, {useNewUrlParser: true, useUnifiedTopology: true, useCreateIndex: true, connectTimeoutMS: 10000}, err => {
+ if (err) done(err);
+ });
+ mongoose.connection.on('error', console.error.bind(console, 'connection error:'));
+ mongoose.connection.on('connected', () => { // evaluation connection behaviour on prod
+ if (process.env.NODE_ENV !== 'test') { // Do not interfere with testing
+ console.info('Database connected');
+ }
+ });
+ mongoose.connection.on('disconnected', () => { // reset state on disconnect
+ if (process.env.NODE_ENV !== 'test') { // Do not interfere with testing
+ console.info('Database disconnected');
+ // this.state.db = 0; // prod database connects and disconnects automatically
+ }
+ });
+ process.on('SIGINT', () => { // close connection when app is terminated
+ if (!this.state.db) { // database still connected
+ mongoose.connection.close(() => {
+ console.info('Mongoose default connection disconnected through app termination');
+ process.exit(0);
+ });
+ }
+ });
+ mongoose.connection.once('open', () => {
+ mongoose.set('useFindAndModify', false);
+ console.info(process.env.NODE_ENV === 'test' ? '' : `Connected to ${connectionString}`);
+ this.state.db = mongoose.connection;
+ done();
+ });
+ }
+
+ static disconnect (done) {
+ mongoose.connection.close(() => {
+ console.info(process.env.NODE_ENV === 'test' ? '' : `Disconnected from database`);
+ this.state.db = 0;
+ done();
+ });
+ }
+
+ static getState () {
+ return this.state;
+ }
+
+ static drop (done: Function = () => {}) { // drop all collections of connected db (only dev and test for safety reasons ;)
+ if (!this.state.db || this.state.mode === 'prod') return done(); // no db connection or prod db
+ this.state.db.db.listCollections().toArray((err, collections) => { // get list of all collections
+ if (collections.length === 0) { // there are no collections to drop
+ return done();
+ }
+ else {
+ let dropCounter = 0; // count number of dropped collections to know when to return done()
+ collections.forEach(collection => { // drop each collection
+ this.state.db.dropCollection(collection.name, () => {
+ if (++ dropCounter >= collections.length) { // all collections dropped
+ done();
+ }
+ });
+ });
+ }
+ });
+ }
+
+ static loadJson (json, done: Function = () => {}) { // insert given JSON data into db, uses core mongodb methods
+ if (!this.state.db || !json.hasOwnProperty('collections') || json.collections.length === 0) { // no db connection or nothing to load
+ return done();
+ }
+
+ let loadCounter = 0; // count number of loaded collections to know when to return done()
+ Object.keys(json.collections).forEach(collectionName => { // create each collection
+ json.collections[collectionName] = this.oidResolve(json.collections[collectionName]);
+ this.state.db.createCollection(collectionName, (err, collection) => {
+ collection.insertMany(json.collections[collectionName], () => { // insert JSON data
+ if (++ loadCounter >= Object.keys(json.collections).length) { // all collections loaded
+ done();
+ }
+ });
+ });
+ });
+ }
+
+ // changelog entry
+ static log(req, thisOrCollection, conditions = null, data = null) { // expects (req, this (from query helper)) or (req, collection, conditions, data)
+ if (! (conditions || data)) { // (req, this)
+ data = thisOrCollection._update ? _.cloneDeep(thisOrCollection._update) : {}; // replace undefined with {}
+ Object.keys(data).forEach(key => {
+ if (key[0] === '$') {
+ data[key.substr(1)] = data[key];
+ delete data[key];
+ }
+ });
+ new ChangelogModel({action: req.method + ' ' + req.url, collectionName: thisOrCollection._collection.collectionName, conditions: thisOrCollection._conditions, data: data, user_id: req.authDetails.id ? req.authDetails.id : null}).save(err => {
+ if (err) console.error(err);
+ });
+ }
+ else { // (req, collection, conditions, data)
+ new ChangelogModel({action: req.method + ' ' + req.url, collectionName: thisOrCollection, conditions: conditions, data: data, user_id: req.authDetails.id ? req.authDetails.id : null}).save(err => {
+ if (err) console.error(err);
+ });
+ }
+ }
+
+ private static oidResolve (object: any) { // resolve $oid fields to actual ObjectIds recursively
+ Object.keys(object).forEach(key => {
+ if (object[key] !== null && object[key].hasOwnProperty('$oid')) { // found oid, replace
+ object[key] = mongoose.Types.ObjectId(object[key].$oid);
+ }
+ else if (typeof object[key] === 'object' && object[key] !== null) { // deeper into recursion
+ object[key] = this.oidResolve(object[key]);
+ }
+ });
+ return object;
+ }
+};
diff --git a/src/globals.ts b/src/globals.ts
new file mode 100644
index 0000000..81f80b8
--- /dev/null
+++ b/src/globals.ts
@@ -0,0 +1,17 @@
+const globals = {
+ levels: [ // access levels
+ 'read',
+ 'write',
+ 'maintain',
+ 'dev',
+ 'admin'
+ ],
+
+ status: { // document statuses
+ deleted: -1,
+ new: 0,
+ validated: 10,
+ }
+};
+
+export default globals;
\ No newline at end of file
diff --git a/src/helpers/authorize.ts b/src/helpers/authorize.ts
new file mode 100644
index 0000000..03d344b
--- /dev/null
+++ b/src/helpers/authorize.ts
@@ -0,0 +1,105 @@
+import basicAuth from 'basic-auth';
+import bcrypt from 'bcryptjs';
+import UserModel from '../models/user';
+
+
+// appends req.auth(res, ['levels'], method = 'all')
+// which returns sends error message and returns false if unauthorized, otherwise true
+// req.authDetails returns eg. {methods: ['basic'], username: 'johndoe', level: 'write'}
+
+module.exports = async (req, res, next) => {
+ let givenMethod = ''; // authorization method given by client, basic taken preferred
+ let user = {name: '', level: '', id: '', location: ''}; // user object
+
+ // test authentications
+ const userBasic = await basic(req, next);
+
+ if (userBasic) { // basic available
+ givenMethod = 'basic';
+ user = userBasic;
+ }
+ else { // if basic not available, test key
+ const userKey = await key(req, next);
+ if (userKey) {
+ givenMethod = 'key';
+ user = userKey;
+ }
+ }
+
+ req.auth = (res, levels, method = 'all') => {
+ if (givenMethod === method || (method === 'all' && givenMethod !== '')) { // method is available
+ if (levels.indexOf(user.level) > -1) { // level is available
+ return true;
+ }
+ else {
+ res.status(403).json({status: 'Forbidden'});
+ return false;
+ }
+ }
+ else {
+ res.status(401).json({status: 'Unauthorized'});
+ return false;
+ }
+ }
+
+ req.authDetails = {
+ method: givenMethod,
+ username: user.name,
+ level: user.level,
+ id: user.id,
+ location: user.location
+ };
+
+ next();
+}
+
+
+function basic (req, next): any { // checks basic auth and returns changed user object
+ return new Promise(resolve => {
+ const auth = basicAuth(req);
+ if (auth !== undefined) { // basic auth available
+ UserModel.find({name: auth.name}).lean().exec( (err, data: any) => { // find user
+ if (err) return next(err);
+ if (data.length === 1) { // one user found
+ bcrypt.compare(auth.pass, data[0].pass, (err, res) => { // check password
+ if (err) return next(err);
+ if (res === true) { // password correct
+ resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString(), location: data[0].location});
+ }
+ else {
+ resolve(null);
+ }
+ });
+ }
+ else {
+ resolve(null);
+ }
+ });
+ }
+ else {
+ resolve(null);
+ }
+ });
+}
+
+function key (req, next): any { // checks API key and returns changed user object
+ return new Promise(resolve => {
+ if (req.query.key !== undefined) { // key available
+ UserModel.find({key: req.query.key}).lean().exec( (err, data: any) => { // find user
+ if (err) return next(err);
+ if (data.length === 1) { // one user found
+ resolve({level: data[0].level, name: data[0].name, id: data[0]._id.toString(), location: data[0].location});
+ if (!/^\/api/m.test(req.url)){
+ delete req.query.key; // delete query parameter to avoid interference with later validation
+ }
+ }
+ else {
+ resolve(null);
+ }
+ });
+ }
+ else {
+ resolve(null);
+ }
+ });
+}
\ No newline at end of file
diff --git a/src/helpers/csv.ts b/src/helpers/csv.ts
new file mode 100644
index 0000000..38c487a
--- /dev/null
+++ b/src/helpers/csv.ts
@@ -0,0 +1,34 @@
+import {parseAsync} from 'json2csv';
+
+export default function csv(input: any[], f: (err, data) => void) {
+ parseAsync(input.map(e => flatten(e)), {includeEmptyRows: true})
+ .then(csv => f(null, csv))
+ .catch(err => f(err, null));
+}
+
+function flatten (data) { // flatten object: {a: {b: true}} -> {a.b: true}
+ const result = {};
+ function recurse (cur, prop) {
+ if (Object(cur) !== cur || Object.keys(cur).length === 0) {
+ result[prop] = cur;
+ }
+ else if (Array.isArray(cur)) {
+ let l = 0;
+ for(let i = 0, l = cur.length; i < l; i++)
+ recurse(cur[i], prop + "[" + i + "]");
+ if (l == 0)
+ result[prop] = [];
+ }
+ else {
+ let isEmpty = true;
+ for (let p in cur) {
+ isEmpty = false;
+ recurse(cur[p], prop ? prop+"."+p : p);
+ }
+ if (isEmpty && prop)
+ result[prop] = {};
+ }
+ }
+ recurse(data, '');
+ return result;
+}
\ No newline at end of file
diff --git a/src/helpers/mail.ts b/src/helpers/mail.ts
new file mode 100644
index 0000000..8ec71c8
--- /dev/null
+++ b/src/helpers/mail.ts
@@ -0,0 +1,64 @@
+import axios from 'axios';
+
+// sends an email using the BIC service
+
+export default (mailAddress, subject, content, f) => { // callback, executed empty or with error
+ if (process.env.NODE_ENV === 'production') {
+ const mailService = JSON.parse(process.env.VCAP_SERVICES).Mail[0];
+ axios({
+ method: 'post',
+ url: mailService.credentials.uri + '/email',
+ auth: {username: mailService.credentials.username, password: mailService.credentials.password},
+ data: {
+ recipients: [{to: mailAddress}],
+ subject: {content: subject},
+ body: {
+ content: content,
+ contentType: "text/html"
+ },
+ from: {
+ eMail: "definma@bosch-iot.com",
+ password: "PlasticsOfFingerprintDigital"
+ }
+ }
+ })
+ .then(() => {
+ f();
+ })
+ .catch((err) => {
+ f(err);
+ });
+ }
+ else if (process.env.NODE_ENV === 'test') {
+ console.info('Sending mail to ' + mailAddress + ': -- ' + subject + ' -- ' + content);
+ f();
+ }
+ else { // dev
+ axios({
+ method: 'get',
+ url: 'https://digital-fingerprint-of-plastics-mail-test.apps.de1.bosch-iot-cloud.com/api',
+ data: {
+ method: 'post',
+ url: '/email',
+ data: {
+ recipients: [{to: mailAddress}],
+ subject: {content: subject},
+ body: {
+ content: content,
+ contentType: "text/html"
+ },
+ from: {
+ eMail: "dfop-test@bosch-iot.com",
+ password: "PlasticsOfFingerprintDigital"
+ }
+ }
+ }
+ })
+ .then(() => {
+ f();
+ })
+ .catch((err) => {
+ f(err);
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/index.ts b/src/index.ts
index 09fb57f..d6ea865 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,37 +1,21 @@
-import cfenv from 'cfenv';
import express from 'express';
-import mongoose from 'mongoose';
-import swagger from 'swagger-ui-express';
-import jsonRefParser, {JSONSchema} from '@apidevtools/json-schema-ref-parser';
+import bodyParser from 'body-parser';
+import compression from 'compression';
+import contentFilter from 'content-filter';
+import mongoSanitize from 'mongo-sanitize';
+import helmet from 'helmet';
+import cors from 'cors';
+import api from './api';
+import db from './db';
+// TODO: working demo branch
// tell if server is running in debug or production environment
-console.log(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : '===== DEVELOPMENT =====');
-
-
-// get mongodb address from server, otherwise set to localhost
-let connectionString: string = "";
-if(process.env.NODE_ENV === 'production') {
- let services = cfenv.getAppEnv().getServices();
- for (let service in services) {
- if(services[service].tags.indexOf("mongodb") >= 0) {
- connectionString = services[service]["credentials"].uri;
- }
- }
-}
-else {
- connectionString = 'mongodb://localhost/dfopdb';
-}
-mongoose.connect(connectionString, {useNewUrlParser: true, useUnifiedTopology: true});
-
-// connect to mongodb
-let db = mongoose.connection;
-db.on('error', console.error.bind(console, 'connection error:'));
-db.once('open', () => {
- console.log(`Connected to ${connectionString}`);
-});
+console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT =====');
+// mongodb connection
+db.connect();
// create Express app
const app = express();
@@ -40,20 +24,68 @@ app.disable('x-powered-by');
// get port from environment, defaults to 3000
const port = process.env.PORT || 3000;
+//middleware
+app.use(helmet());
+app.use(contentFilter()); // filter URL query attacks
+app.use(express.json({ limit: '5mb'}));
+app.use(express.urlencoded({ extended: false, limit: '5mb' }));
+app.use(compression()); // compress responses
+app.use(bodyParser.json());
+app.use((req, res, next) => { // filter body query attacks
+ req.body = mongoSanitize(req.body);
+ next();
+});
+app.use((err, req, res, ignore) => { // bodyParser error handling
+ res.status(400).send({status: 'Invalid JSON body'});
+});
+app.use((req, res, next) => { // no database connection error
+ if (db.getState().db) {
+ next();
+ }
+ else {
+ console.error('No database connection');
+ res.status(500).send({status: 'Internal server error'});
+ }
+});
+app.use(cors()); // CORS headers
+app.use(require('./helpers/authorize')); // handle authentication
+
+// redirect /api routes for Angular proxy in development
+if (process.env.NODE_ENV !== 'production') {
+ app.use('/api/:url([^]+)', (req, res) => {
+ req.url = '/' + req.params.url;
+ app.handle(req, res);
+ });
+}
+
+
// require routes
app.use('/', require('./routes/root'));
+app.use('/', require('./routes/sample'));
+app.use('/', require('./routes/material'));
+app.use('/', require('./routes/template'));
+app.use('/', require('./routes/user'));
+app.use('/', require('./routes/measurement'));
+
+// static files
+app.use('/static', express.static('static'));
// Swagger UI
-let oasDoc: JSONSchema = {};
-jsonRefParser.bundle('oas/oas.yaml', (err, doc) => {
- if(err) throw err;
- oasDoc = doc;
- oasDoc.paths = oasDoc.paths.allOf.reduce((s, e) => Object.assign(s, e));
- swagger.setup(oasDoc, {defaultModelsExpandDepth: -1, customCss: '.swagger-ui .topbar { display: none }'});
+app.use('/api-doc', api.serve(), api.setup());
+
+app.use((req, res) => { // 404 error handling
+ res.status(404).json({status: 'Not found'});
});
-app.use('/api', swagger.serve, swagger.setup(oasDoc, {defaultModelsExpandDepth: -1, customCss: '.swagger-ui .topbar { display: none }'}));
+
+app.use((err, req, res, ignore) => { // internal server error handling
+ console.error(err);
+ res.status(500).json({status: 'Internal server error'});
+});
+
// hook up server to port
-app.listen(port, () => {
- console.log(`Listening on http;//localhost:${port}`);
+const server = app.listen(port, () => {
+ console.info(process.env.NODE_ENV === 'test' ? '' : `Listening on http://localhost:${port}`);
});
+
+module.exports = server;
\ No newline at end of file
diff --git a/src/models/changelog.ts b/src/models/changelog.ts
new file mode 100644
index 0000000..75600c4
--- /dev/null
+++ b/src/models/changelog.ts
@@ -0,0 +1,11 @@
+import mongoose from 'mongoose';
+
+const ChangelogSchema = new mongoose.Schema({
+ action: String,
+ collectionName: String,
+ conditions: Object,
+ data: Object,
+ user_id: mongoose.Schema.Types.ObjectId
+}, {minimize: false});
+
+export default mongoose.model>('changelog', ChangelogSchema);
\ No newline at end of file
diff --git a/src/models/condition_template.ts b/src/models/condition_template.ts
new file mode 100644
index 0000000..ca61da2
--- /dev/null
+++ b/src/models/condition_template.ts
@@ -0,0 +1,20 @@
+import mongoose from 'mongoose';
+import db from '../db';
+
+const ConditionTemplateSchema = new mongoose.Schema({
+ first_id: mongoose.Schema.Types.ObjectId,
+ name: String,
+ version: Number,
+ parameters: [new mongoose.Schema({
+ name: String,
+ range: mongoose.Schema.Types.Mixed
+ } ,{ _id : false })]
+}, {minimize: false}); // to allow empty objects
+
+// changelog query helper
+ConditionTemplateSchema.query.log = function > (req) {
+ db.log(req, this);
+ return this;
+}
+
+export default mongoose.model>('condition_template', ConditionTemplateSchema);
\ No newline at end of file
diff --git a/src/models/material.ts b/src/models/material.ts
new file mode 100644
index 0000000..d7d5eb9
--- /dev/null
+++ b/src/models/material.ts
@@ -0,0 +1,28 @@
+import mongoose from 'mongoose';
+import MaterialSupplierModel from '../models/material_suppliers';
+import MaterialGroupsModel from '../models/material_groups';
+import db from '../db';
+
+const MaterialSchema = new mongoose.Schema({
+ name: {type: String, index: {unique: true}},
+ supplier_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialSupplierModel},
+ group_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialGroupsModel},
+ mineral: Number,
+ glass_fiber: Number,
+ carbon_fiber: Number,
+ numbers: [{
+ color: String,
+ number: String
+ }],
+ status: Number
+}, {minimize: false});
+
+// changelog query helper
+MaterialSchema.query.log = function > (req) {
+ db.log(req, this);
+ return this;
+}
+MaterialSchema.index({supplier_id: 1});
+MaterialSchema.index({group_id: 1});
+
+export default mongoose.model>('material', MaterialSchema);
\ No newline at end of file
diff --git a/src/models/material_groups.ts b/src/models/material_groups.ts
new file mode 100644
index 0000000..00be706
--- /dev/null
+++ b/src/models/material_groups.ts
@@ -0,0 +1,14 @@
+import mongoose from 'mongoose';
+import db from '../db';
+
+const MaterialGroupsSchema = new mongoose.Schema({
+ name: {type: String, index: {unique: true}}
+});
+
+// changelog query helper
+MaterialGroupsSchema.query.log = function > (req) {
+ db.log(req, this);
+ return this;
+}
+
+export default mongoose.model>('material_groups', MaterialGroupsSchema);
\ No newline at end of file
diff --git a/src/models/material_suppliers.ts b/src/models/material_suppliers.ts
new file mode 100644
index 0000000..5c47e3b
--- /dev/null
+++ b/src/models/material_suppliers.ts
@@ -0,0 +1,14 @@
+import mongoose from 'mongoose';
+import db from '../db';
+
+const MaterialSuppliersSchema = new mongoose.Schema({
+ name: {type: String, index: {unique: true}}
+});
+
+// changelog query helper
+MaterialSuppliersSchema.query.log = function > (req) {
+ db.log(req, this);
+ return this;
+}
+
+export default mongoose.model>('material_suppliers', MaterialSuppliersSchema);
\ No newline at end of file
diff --git a/src/models/measurement.ts b/src/models/measurement.ts
new file mode 100644
index 0000000..55267ec
--- /dev/null
+++ b/src/models/measurement.ts
@@ -0,0 +1,23 @@
+import mongoose from 'mongoose';
+import SampleModel from './sample';
+import MeasurementTemplateModel from './measurement_template';
+import db from '../db';
+
+
+
+const MeasurementSchema = new mongoose.Schema({
+ sample_id: {type: mongoose.Schema.Types.ObjectId, ref: SampleModel},
+ values: mongoose.Schema.Types.Mixed,
+ measurement_template: {type: mongoose.Schema.Types.ObjectId, ref: MeasurementTemplateModel},
+ status: Number
+}, {minimize: false});
+
+// changelog query helper
+MeasurementSchema.query.log = function > (req) {
+ db.log(req, this);
+ return this;
+}
+MeasurementSchema.index({sample_id: 1});
+MeasurementSchema.index({measurement_template: 1});
+
+export default mongoose.model>('measurement', MeasurementSchema);
\ No newline at end of file
diff --git a/src/models/measurement_template.ts b/src/models/measurement_template.ts
new file mode 100644
index 0000000..b34e847
--- /dev/null
+++ b/src/models/measurement_template.ts
@@ -0,0 +1,20 @@
+import mongoose from 'mongoose';
+import db from '../db';
+
+const MeasurementTemplateSchema = new mongoose.Schema({
+ first_id: mongoose.Schema.Types.ObjectId,
+ name: String,
+ version: Number,
+ parameters: [new mongoose.Schema({
+ name: String,
+ range: mongoose.Schema.Types.Mixed
+ } ,{ _id : false })]
+}, {minimize: false}); // to allow empty objects
+
+// changelog query helper
+MeasurementTemplateSchema.query.log = function > (req) {
+ db.log(req, this);
+ return this;
+}
+
+export default mongoose.model>('measurement_template', MeasurementTemplateSchema);
\ No newline at end of file
diff --git a/src/models/note.ts b/src/models/note.ts
new file mode 100644
index 0000000..5d02502
--- /dev/null
+++ b/src/models/note.ts
@@ -0,0 +1,19 @@
+import mongoose from 'mongoose';
+import db from '../db';
+
+const NoteSchema = new mongoose.Schema({
+ comment: String,
+ sample_references: [{
+ sample_id: mongoose.Schema.Types.ObjectId,
+ relation: String
+ }],
+ custom_fields: mongoose.Schema.Types.Mixed
+});
+
+// changelog query helper
+NoteSchema.query.log = function > (req) {
+ db.log(req, this);
+ return this;
+}
+
+export default mongoose.model>('note', NoteSchema);
\ No newline at end of file
diff --git a/src/models/note_field.ts b/src/models/note_field.ts
new file mode 100644
index 0000000..733ba02
--- /dev/null
+++ b/src/models/note_field.ts
@@ -0,0 +1,15 @@
+import mongoose from 'mongoose';
+import db from '../db';
+
+const NoteFieldSchema = new mongoose.Schema({
+ name: {type: String, index: {unique: true}},
+ qty: Number
+});
+
+// changelog query helper
+NoteFieldSchema.query.log = function > (req) {
+ db.log(req, this);
+ return this;
+}
+
+export default mongoose.model>('note_field', NoteFieldSchema);
\ No newline at end of file
diff --git a/src/models/sample.ts b/src/models/sample.ts
new file mode 100644
index 0000000..8eec7bd
--- /dev/null
+++ b/src/models/sample.ts
@@ -0,0 +1,29 @@
+import mongoose from 'mongoose';
+
+import MaterialModel from './material';
+import NoteModel from './note';
+import UserModel from './user';
+import db from '../db';
+
+const SampleSchema = new mongoose.Schema({
+ number: {type: String, index: {unique: true}},
+ type: String,
+ color: String,
+ batch: String,
+ condition: mongoose.Schema.Types.Mixed,
+ material_id: {type: mongoose.Schema.Types.ObjectId, ref: MaterialModel},
+ note_id: {type: mongoose.Schema.Types.ObjectId, ref: NoteModel},
+ user_id: {type: mongoose.Schema.Types.ObjectId, ref: UserModel},
+ status: Number
+}, {minimize: false});
+
+// changelog query helper
+SampleSchema.query.log = function > (req) {
+ db.log(req, this);
+ return this;
+}
+SampleSchema.index({material_id: 1});
+SampleSchema.index({note_id: 1});
+SampleSchema.index({user_id: 1});
+
+export default mongoose.model>('sample', SampleSchema);
\ No newline at end of file
diff --git a/src/models/user.ts b/src/models/user.ts
new file mode 100644
index 0000000..1e50d0c
--- /dev/null
+++ b/src/models/user.ts
@@ -0,0 +1,20 @@
+import mongoose from 'mongoose';
+import db from '../db';
+
+const UserSchema = new mongoose.Schema({
+ name: {type: String, index: {unique: true}},
+ email: String,
+ pass: String,
+ key: String,
+ level: String,
+ location: String,
+ device_name: String
+});
+
+// changelog query helper
+UserSchema.query.log = function > (req) {
+ db.log(req, this);
+ return this;
+}
+
+export default mongoose.model>('user', UserSchema);
\ No newline at end of file
diff --git a/src/routes/material.spec.ts b/src/routes/material.spec.ts
new file mode 100644
index 0000000..e412615
--- /dev/null
+++ b/src/routes/material.spec.ts
@@ -0,0 +1,1051 @@
+import should from 'should/as-function';
+import _ from 'lodash';
+import MaterialModel from '../models/material';
+import MaterialGroupModel from '../models/material_groups';
+import MaterialSupplierModel from '../models/material_suppliers';
+import TestHelper from "../test/helper";
+import globals from '../globals';
+
+
+
+describe('/material', () => {
+ let server;
+ before(done => TestHelper.before(done));
+ beforeEach(done => server = TestHelper.beforeEach(server, done));
+ afterEach(done => TestHelper.afterEach(server, done));
+ after(done => TestHelper.after(done));
+
+ describe('GET /materials', () => {
+ it('returns all materials', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/materials',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === globals.status.validated).length);
+ should(res.body).matchEach(material => {
+ should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers');
+ should(material).have.property('_id').be.type('string');
+ should(material).have.property('name').be.type('string');
+ should(material).have.property('supplier').be.type('string');
+ should(material).have.property('group').be.type('string');
+ should(material).have.property('mineral').be.type('number');
+ should(material).have.property('glass_fiber').be.type('number');
+ should(material).have.property('carbon_fiber').be.type('number');
+ should(material.numbers).matchEach(number => {
+ should(number).have.only.keys('color', 'number');
+ should(number).have.property('color').be.type('string');
+ should(number).have.property('number').be.type('string');
+ });
+ });
+ done();
+ });
+ });
+ it('works with an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/materials',
+ auth: {key: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === globals.status.validated).length);
+ should(res.body).matchEach(material => {
+ should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers');
+ should(material).have.property('_id').be.type('string');
+ should(material).have.property('name').be.type('string');
+ should(material).have.property('supplier').be.type('string');
+ should(material).have.property('group').be.type('string');
+ should(material).have.property('mineral').be.type('number');
+ should(material).have.property('glass_fiber').be.type('number');
+ should(material).have.property('carbon_fiber').be.type('number');
+ should(material.numbers).matchEach(number => {
+ should(number).have.only.keys('color', 'number');
+ should(number).have.property('color').be.type('string');
+ should(number).have.property('number').be.type('string');
+ });
+ });
+ done();
+ });
+ });
+ it('allows filtering by state', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/materials?status=new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status === globals.status.new).length);
+ should(res.body).matchEach(material => {
+ should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers');
+ should(material).have.property('_id').be.type('string');
+ should(material).have.property('name').be.type('string');
+ should(material).have.property('supplier').be.type('string');
+ should(material).have.property('group').be.type('string');
+ should(material).have.property('mineral').be.type('number');
+ should(material).have.property('glass_fiber').be.type('number');
+ should(material).have.property('carbon_fiber').be.type('number');
+ should(material.numbers).matchEach(number => {
+ should(number).have.only.keys('color', 'number');
+ should(number).have.property('color').be.type('string');
+ should(number).have.property('number').be.type('string');
+ });
+ });
+ done();
+ });
+ });
+ it('rejects an invalid state name', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/materials?status=xxx',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: '"status" must be one of [validated, new, all]'}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/materials',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('GET /materials/{state}', () => {
+ it('returns all new materials', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/materials/new',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ let asyncCounter = res.body.length;
+ should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status ===globals.status.new).length);
+ should(res.body).matchEach(material => {
+ should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers');
+ should(material).have.property('_id').be.type('string');
+ should(material).have.property('name').be.type('string');
+ should(material).have.property('supplier').be.type('string');
+ should(material).have.property('group').be.type('string');
+ should(material).have.property('mineral').be.type('number');
+ should(material).have.property('glass_fiber').be.type('number');
+ should(material).have.property('carbon_fiber').be.type('number');
+ should(material.numbers).matchEach(number => {
+ should(number).have.only.keys('color', 'number');
+ should(number).have.property('color').be.type('string');
+ should(number).have.property('number').be.type('string');
+ });
+ MaterialModel.findById(material._id).lean().exec((err, data) => {
+ should(data).have.property('status',globals.status.new);
+ if (--asyncCounter === 0) {
+ done();
+ }
+ });
+ });
+ });
+ });
+ it('returns all deleted materials', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/materials/deleted',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ let asyncCounter = res.body.length;
+ should(res.body).have.lengthOf(json.collections.materials.filter(e => e.status ===globals.status.deleted).length);
+ should(res.body).matchEach(material => {
+ should(material).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers');
+ should(material).have.property('_id').be.type('string');
+ should(material).have.property('name').be.type('string');
+ should(material).have.property('supplier').be.type('string');
+ should(material).have.property('group').be.type('string');
+ should(material).have.property('mineral').be.type('number');
+ should(material).have.property('glass_fiber').be.type('number');
+ should(material).have.property('carbon_fiber').be.type('number');
+ should(material.numbers).matchEach(number => {
+ should(number).have.only.keys('color', 'number');
+ should(number).have.property('color').be.type('string');
+ should(number).have.property('number').be.type('string');
+ });
+ MaterialModel.findById(material._id).lean().exec((err, data) => {
+ should(data).have.property('status',globals.status.deleted);
+ if (--asyncCounter === 0) {
+ done();
+ }
+ });
+ });
+ });
+ });
+ it('rejects requests from a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/materials/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/materials/deleted',
+ auth: {key: 'admin'},
+ httpStatus: 401
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/materials/new',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('GET /material/{id}', () => {
+ it('returns the right material', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/100000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]}
+ });
+ });
+ it('returns the right material for an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/100000000000000000000003',
+ auth: {key: 'admin'},
+ httpStatus: 200,
+ res: {_id: '100000000000000000000003', name: 'PA GF 50 black (2706)', supplier: 'Akro-Plastic', group: 'PA66+PA6I/6T', mineral: 0, glass_fiber: 0, carbon_fiber: 0, numbers: []}
+ });
+ });
+ it('returns a material with a color without number', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/100000000000000000000007',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ res: {_id: '100000000000000000000007', name: 'Ultramid A4H', supplier: 'BASF', group: 'PA66', mineral: 0, glass_fiber: 0, carbon_fiber: 0, numbers: [{color: 'black', number: ''}]}
+ });
+ });
+ it('returns a deleted material for a maintain/admin user', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/100000000000000000000008',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ res: {_id: '100000000000000000000008', name: 'Latamid 66 H 2 G 30', supplier: 'LATI', group: 'PA66', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'blue', number: '5513943509'}]}
+ });
+ });
+ it('returns 403 for a write user when requesting a deleted material', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/100000000000000000000008',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403
+ });
+ });
+ it('rejects an invalid id', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/10000000000000000000000x',
+ auth: {key: 'admin'},
+ httpStatus: 404
+ });
+ });
+ it('rejects an unknown id', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/100000000000000000000111',
+ auth: {key: 'janedoe'},
+ httpStatus: 404
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/100000000000000000000001',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('PUT /material/{id}', () => {
+ it('returns the right material', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {},
+ res: {_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]}
+ });
+ });
+ it('keeps unchanged properties', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]});
+ MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.property('status',globals.status.validated);
+ MaterialGroupModel.find({name: 'PA46'}).lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ should(data[0]._id.toString()).be.eql('900000000000000000000001');
+ MaterialSupplierModel.find({name: 'DSM'}).lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ should(data[0]._id.toString()).be.eql('110000000000000000000001');
+ done();
+ });
+ });
+ });
+ });
+ });
+ it('keeps only one unchanged property', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {name: 'Stanyl TW 200 F8'}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({_id: '100000000000000000000001', name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}, {color: 'natural', number: '5514263422'}]});
+ MaterialModel.findById('100000000000000000000001').lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.property('status',globals.status.validated);
+ done();
+ });
+ });
+ });
+ it('changes the given properties', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]});
+ MaterialModel.findById('100000000000000000000001').lean().exec((err, data:any) => {
+ if (err) return done(err);
+ data._id = data._id.toString();
+ data.group_id = data.group_id.toString();
+ data.supplier_id = data.supplier_id.toString();
+ data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}});
+ should(data).be.eql({_id: '100000000000000000000001', name: 'UltramidTKR4355G7_2', supplier_id: '110000000000000000000002', group_id: '900000000000000000000002', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: 0, __v: 0});
+ MaterialGroupModel.find({name: 'PA6/6T'}).lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ should(data[0]._id.toString()).be.eql('900000000000000000000002');
+ MaterialSupplierModel.find({name: 'BASF'}).lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ should(data[0]._id.toString()).be.eql('110000000000000000000002');
+ done();
+ });
+ });
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {name: 'UltramidTKR4355G7_2', supplier: 'BASF', group: 'PA6/6T', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}]},
+ log: {
+ collection: 'materials',
+ dataAdd: {
+ group_id: '900000000000000000000002',
+ supplier_id: '110000000000000000000002',
+ status: 0
+ },
+ dataIgn: ['supplier', 'group']
+ }
+ });
+ });
+ it('accepts a color without number', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000007',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {numbers: [{color: 'black', number: ''}, {color: 'natural', number: ''}]},
+ res: {_id: '100000000000000000000007', name: 'Ultramid A4H', supplier: 'BASF', group: 'PA66', mineral: 0, glass_fiber: 0, carbon_fiber: 0, numbers: [{color: 'black', number: ''}, {color: 'natural', number: ''}]}
+ });
+ })
+ it('rejects already existing material names', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {name: 'Ultramid T KR 4355 G7'},
+ res: {status: 'Material name already taken'}
+ });
+ });
+ it('rejects a wrong mineral property', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {mineral: 'x'},
+ res: {status: 'Invalid body format', details: '"mineral" must be a number'}
+ });
+ });
+ it('rejects a wrong glass_fiber property', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {glass_fiber: 'x'},
+ res: {status: 'Invalid body format', details: '"glass_fiber" must be a number'}
+ });
+ });
+ it('rejects a wrong carbon_fiber property', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {carbon_fiber: 'x'},
+ res: {status: 'Invalid body format', details: '"carbon_fiber" must be a number'}
+ });
+ });
+ it('rejects a wrong color name property', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {numbers: [{colorxx: 'black', number: '55'}]},
+ res: {status: 'Invalid body format', details: '"numbers[0].color" is required'}
+ });
+ });
+ it('rejects an invalid id', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/10000000000000000000000x',
+ auth: {basic: 'admin'},
+ httpStatus: 404,
+ req: {},
+ });
+ });
+ it('rejects editing a deleted material', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000008',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000002',
+ auth: {key: 'admin'},
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ it('rejects requests from a read user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000002',
+ auth: {basic: 'user'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('returns 404 for an unknown material', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000111',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404,
+ req: {}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/100000000000000000000001',
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ });
+
+ describe('DELETE /material/{id}', () => {
+ it('sets the status to deleted', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/material/100000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({status: 'OK'});
+ MaterialModel.findById('100000000000000000000002').lean().exec((err, data: any) => {
+ if (err) return done(err);
+ data._id = data._id.toString();
+ data.group_id = data.group_id.toString();
+ data.supplier_id = data.supplier_id.toString();
+ data.numbers = data.numbers.map(e => {return {color: e.color, number: e.number}});
+ should(data).be.eql({_id: '100000000000000000000002', name: 'Ultramid T KR 4355 G7', supplier_id: '110000000000000000000002', group_id: '900000000000000000000002', mineral: 0, glass_fiber: 35, carbon_fiber: 0, numbers: [{color: 'black', number: '5514212901'}, {color: 'signalviolet', number: '5514612901'}], status: -1, __v: 0}
+ );
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/material/100000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ log: {
+ collection: 'materials',
+ dataAdd: { status: -1}
+ }
+ });
+ });
+ it('rejects deleting a material referenced by samples', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/material/100000000000000000000004',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ res: {status: 'Material still in use'}
+ })
+ });
+ it('rejects an invalid id', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/material/10000000000000000000000x',
+ auth: {basic: 'admin'},
+ httpStatus: 404
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/material/100000000000000000000002',
+ auth: {key: 'admin'},
+ httpStatus: 401
+ });
+ });
+ it('rejects requests from a read user', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/material/100000000000000000000002',
+ auth: {basic: 'user'},
+ httpStatus: 403
+ });
+ });
+ it('returns 404 for an unknown id', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/material/100000000000000000000111',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/material/100000000000000000000001',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('PUT /material/restore/{id}', () => {
+ it('sets the status', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/restore/100000000000000000000008',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).be.eql({status: 'OK'});
+ MaterialModel.findById('100000000000000000000008').lean().exec((err, data: any) => {
+ if (err) return done(err);
+ should(data).have.property('status',globals.status.new);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/restore/100000000000000000000008',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {},
+ log: {
+ collection: 'materials',
+ dataAdd: {
+ status: 0
+ }
+ }
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/restore/100000000000000000000008',
+ auth: {key: 'admin'},
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ it('rejects a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/restore/100000000000000000000008',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('returns 404 for an unknown sample', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/restore/000000000000000000000008',
+ auth: {basic: 'admin'},
+ httpStatus: 404,
+ req: {}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/restore/100000000000000000000008',
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ });
+
+ describe('PUT /material/validate/{id}', () => {
+ it('sets the status', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/validate/100000000000000000000007',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).be.eql({status: 'OK'});
+ MaterialModel.findById('100000000000000000000007').lean().exec((err, data: any) => {
+ if (err) return done(err);
+ should(data).have.property('status',globals.status.validated);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/validate/100000000000000000000007',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {},
+ log: {
+ collection: 'materials',
+ dataAdd: {
+ status: 10
+ }
+ }
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/validate/100000000000000000000007',
+ auth: {key: 'admin'},
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ it('rejects a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/validate/100000000000000000000007',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('returns 404 for an unknown sample', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/validate/000000000000000000000007',
+ auth: {basic: 'admin'},
+ httpStatus: 404,
+ req: {}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/material/validate/100000000000000000000007',
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ });
+
+ describe('POST /material/new', () => {
+ it('returns the right material', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '05515798402'}]}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('name', 'Crastin CE 2510');
+ should(res.body).have.property('supplier', 'Du Pont');
+ should(res.body).have.property('group', 'PBT');
+ should(res.body).have.property('mineral', 0);
+ should(res.body).have.property('glass_fiber', 30);
+ should(res.body).have.property('carbon_fiber', 0);
+ should(res.body.numbers).matchEach(number => {
+ should(number).have.only.keys('color', 'number');
+ should(number).have.property('color', 'black');
+ should(number).have.property('number', '05515798402');
+ });
+ done();
+ });
+ });
+ it('stores the material', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []}
+ }).end(err => {
+ if (err) return done (err);
+ MaterialModel.find({name: 'Crastin CE 2510'}).lean().exec((err, materialData: any) => {
+ if (err) return done (err);
+ should(materialData).have.lengthOf(1);
+ should(materialData[0]).have.only.keys('_id', 'name', 'supplier_id', 'group_id', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v');
+ should(materialData[0]).have.property('name', 'Crastin CE 2510');
+ should(materialData[0]).have.property('mineral', 0);
+ should(materialData[0]).have.property('glass_fiber', 30);
+ should(materialData[0]).have.property('carbon_fiber', 0);
+ should(materialData[0]).have.property('status',globals.status.new);
+ should(materialData[0].numbers).have.lengthOf(0);
+ MaterialGroupModel.findById(materialData[0].group_id).lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.property('name', 'PBT')
+ MaterialSupplierModel.findById(materialData[0].supplier_id).lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.property('name', 'Du Pont');
+ done();
+ });
+ });
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []},
+ log: {
+ collection: 'materials',
+ dataAdd: {status: 0},
+ dataIgn: ['group_id', 'supplier_id', 'group', 'supplier']
+ }
+ });
+ });
+ it('accepts a color without number', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: ''}]}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).have.only.keys('_id', 'name', 'supplier', 'group', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('name', 'Crastin CE 2510');
+ should(res.body).have.property('supplier', 'Du Pont');
+ should(res.body).have.property('group', 'PBT');
+ should(res.body).have.property('mineral', 0);
+ should(res.body).have.property('glass_fiber', 30);
+ should(res.body).have.property('carbon_fiber', 0);
+ should(res.body.numbers).matchEach(number => {
+ should(number).have.only.keys('color', 'number');
+ should(number).have.property('color', 'black');
+ should(number).have.property('number', '');
+ });
+ MaterialModel.find({name: 'Crastin CE 2510'}).lean().exec((err, data: any) => {
+ if (err) return done (err);
+ should(data).have.lengthOf(1);
+ should(data[0]).have.only.keys('_id', 'name', 'supplier_id', 'group_id', 'mineral', 'glass_fiber', 'carbon_fiber', 'numbers', 'status', '__v');
+ should(data[0]).have.property('_id');
+ should(data[0]).have.property('name', 'Crastin CE 2510');
+ should(data[0]).have.property('mineral', 0);
+ should(data[0]).have.property('glass_fiber', 30);
+ should(data[0]).have.property('carbon_fiber', 0);
+ should(data[0]).have.property('status',globals.status.new);
+ should(_.omit(data[0].numbers[0], '_id')).be.eql({color: 'black', number: ''});
+ done();
+ });
+ });
+ });
+ it('rejects already existing material names', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {name: 'Stanyl TW 200 F8', supplier: 'DSM', group: 'PA46', mineral: 0, glass_fiber: 40, carbon_fiber: 0, numbers: [{color: 'black', number: '5514263423'}]},
+ res: {status: 'Material name already taken'}
+ });
+ });
+ it('rejects a missing name', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]},
+ res: {status: 'Invalid body format', details: '"name" is required'}
+ });
+ });
+ it('rejects a missing supplier', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {name: 'Crastin CE 2510', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]},
+ res: {status: 'Invalid body format', details: '"supplier" is required'}
+ });
+ });
+ it('rejects a missing group', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]},
+ res: {status: 'Invalid body format', details: '"group" is required'}
+ });
+ });
+ it('rejects a missing mineral property', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]},
+ res: {status: 'Invalid body format', details: '"mineral" is required'}
+ });
+ });
+ it('rejects a missing glass_fiber property', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, carbon_fiber: 0, numbers: [{color: 'black', number: '5515798402'}]},
+ res: {status: 'Invalid body format', details: '"glass_fiber" is required'}
+ });
+ });
+ it('rejects a missing carbon_fiber property', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, numbers: [{color: 'black', number: '5515798402'}]},
+ res: {status: 'Invalid body format', details: '"carbon_fiber" is required'}
+ });
+ });
+ it('rejects a missing numbers array', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0},
+ res: {status: 'Invalid body format', details: '"numbers" is required'}
+ });
+ });
+ it('rejects a missing color name', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{number: '5515798402'}]},
+ res: {status: 'Invalid body format', details: '"numbers[0].color" is required'}
+ });
+ });
+ it('rejects a missing color number', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black'}]},
+ res: {status: 'Invalid body format', details: '"numbers[0].number" is required'}
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {key: 'janedoe'},
+ httpStatus: 401,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []}
+ });
+ });
+ it('rejects requests from a read user', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ auth: {basic: 'user'},
+ httpStatus: 403,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/material/new',
+ httpStatus: 401,
+ req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: []}
+ });
+ });
+ });
+
+ describe('GET /material/groups', () => {
+ it('returns all groups', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/groups',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.material_groups.length);
+ should(res.body[0]).be.eql(json.collections.material_groups[0].name);
+ should(res.body).matchEach(group => {
+ should(group).be.type('string');
+ });
+ done();
+ });
+ });
+ it('works with an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/groups',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.material_groups.length);
+ should(res.body[0]).be.eql(json.collections.material_groups[0].name);
+ should(res.body).matchEach(group => {
+ should(group).be.type('string');
+ });
+ done();
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/groups',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('GET /material/suppliers', () => {
+ it('returns all suppliers', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/suppliers',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.material_suppliers.length);
+ should(res.body[0]).be.eql(json.collections.material_suppliers[0].name);
+ should(res.body).matchEach(supplier => {
+ should(supplier).be.type('string');
+ });
+ done();
+ });
+ });
+ it('works with an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/suppliers',
+ auth: {key: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.material_suppliers.length);
+ should(res.body[0]).be.eql(json.collections.material_suppliers[0].name);
+ should(res.body).matchEach(supplier => {
+ should(supplier).be.type('string');
+ });
+ done();
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/material/suppliers',
+ httpStatus: 401
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/routes/material.ts b/src/routes/material.ts
new file mode 100644
index 0000000..3f34e3a
--- /dev/null
+++ b/src/routes/material.ts
@@ -0,0 +1,223 @@
+import express from 'express';
+import _ from 'lodash';
+
+import MaterialValidate from './validate/material';
+import MaterialModel from '../models/material'
+import SampleModel from '../models/sample';
+import MaterialGroupModel from '../models/material_groups';
+import MaterialSupplierModel from '../models/material_suppliers';
+import IdValidate from './validate/id';
+import res400 from './validate/res400';
+import mongoose from 'mongoose';
+import globals from '../globals';
+import db from '../db';
+
+
+
+const router = express.Router();
+
+router.get('/materials', (req, res, next) => {
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return;
+
+ const {error, value: filters} = MaterialValidate.query(req.query);
+ if (error) return res400(error, res);
+
+ let conditions;
+
+ if (filters.hasOwnProperty('status')) {
+ if(filters.status === 'all') {
+ conditions = {$or: [{status: globals.status.validated}, {status: globals.status.new}]}
+ }
+ else {
+ conditions = {status: globals.status[filters.status]};
+ }
+ }
+ else { // default
+ conditions = {status: globals.status.validated};
+ }
+
+ MaterialModel.find(conditions).populate('group_id').populate('supplier_id').lean().exec((err, data) => {
+ if (err) return next(err);
+
+ res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors
+ });
+});
+
+router.get('/materials/:state(new|deleted)', (req, res, next) => {
+ if (!req.auth(res, ['maintain', 'admin'], 'basic')) return;
+
+ MaterialModel.find({status: globals.status[req.params.state]}).populate('group_id').populate('supplier_id').lean().exec((err, data) => {
+ if (err) return next(err);
+
+ res.json(_.compact(data.map(e => MaterialValidate.output(e)))); // validate all and filter null values from validation errors
+ });
+});
+
+router.get('/material/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return;
+
+ MaterialModel.findById(req.params.id).populate('group_id').populate('supplier_id').lean().exec((err, data: any) => {
+ if (err) return next(err);
+
+ if (!data) {
+ return res.status(404).json({status: 'Not found'});
+ }
+
+ if (data.status === globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted materials only available for maintain/admin
+ res.json(MaterialValidate.output(data));
+ });
+});
+
+router.put('/material/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ let {error, value: material} = MaterialValidate.input(req.body, 'change');
+ if (error) return res400(error, res);
+
+ MaterialModel.findById(req.params.id).lean().exec(async (err, materialData: any) => {
+ if (!materialData) {
+ return res.status(404).json({status: 'Not found'});
+ }
+ if (materialData.status === globals.status.deleted) {
+ return res.status(403).json({status: 'Forbidden'});
+ }
+ if (material.hasOwnProperty('name') && material.name !== materialData.name) {
+ if (!await nameCheck(material, res, next)) return;
+ }
+ if (material.hasOwnProperty('group')) {
+ material = await groupResolve(material, req, next);
+ if (!material) return;
+ }
+ if (material.hasOwnProperty('supplier')) {
+ material = await supplierResolve(material, req, next);
+ if (!material) return;
+ }
+
+ // check for changes
+ if (!_.isEqual(_.pick(IdValidate.stringify(materialData), _.keys(material)), IdValidate.stringify(material))) {
+ material.status = globals.status.new; // set status to new
+ }
+
+ await MaterialModel.findByIdAndUpdate(req.params.id, material, {new: true}).log(req).populate('group_id').populate('supplier_id').lean().exec((err, data) => {
+ if (err) return next(err);
+ res.json(MaterialValidate.output(data));
+ });
+ });
+});
+
+router.delete('/material/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ // check if there are still samples referencing this material
+ SampleModel.find({'material_id': new mongoose.Types.ObjectId(req.params.id)}).lean().exec((err, data) => {
+ if (err) return next(err);
+ if (data.length) {
+ return res.status(400).json({status: 'Material still in use'});
+ }
+ MaterialModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).log(req).populate('group_id').populate('supplier_id').lean().exec((err, data) => {
+ if (err) return next(err);
+ if (data) {
+ res.json({status: 'OK'});
+ }
+ else {
+ res.status(404).json({status: 'Not found'});
+ }
+ });
+ });
+});
+
+router.put('/material/restore/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['maintain', 'admin'], 'basic')) return;
+
+ setStatus(globals.status.new, req, res, next);
+});
+
+router.put('/material/validate/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['maintain', 'admin'], 'basic')) return;
+
+ setStatus(globals.status.validated, req, res, next);
+});
+
+router.post('/material/new', async (req, res, next) => {
+ if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ let {error, value: material} = MaterialValidate.input(req.body, 'new');
+ if (error) return res400(error, res);
+
+ if (!await nameCheck(material, res, next)) return;
+ material = await groupResolve(material, req, next);
+ if (!material) return;
+ material = await supplierResolve(material, req, next);
+ if (!material) return;
+
+
+ material.status = globals.status.new; // set status to new
+ await new MaterialModel(material).save(async (err, data) => {
+ if (err) return next(err);
+ db.log(req, 'materials', {_id: data._id}, data.toObject());
+ await data.populate('group_id').populate('supplier_id').execPopulate().catch(err => next(err));
+ if (data instanceof Error) return;
+ res.json(MaterialValidate.output(data.toObject()));
+ });
+});
+
+router.get('/material/groups', (req, res, next) => {
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return;
+
+ MaterialGroupModel.find().lean().exec((err, data: any) => {
+ if (err) return next(err);
+
+ res.json(_.compact(data.map(e => MaterialValidate.outputGroups(e.name)))); // validate all and filter null values from validation errors
+ });
+});
+
+router.get('/material/suppliers', (req, res, next) => {
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return;
+
+ MaterialSupplierModel.find().lean().exec((err, data: any) => {
+ if (err) return next(err);
+
+ res.json(_.compact(data.map(e => MaterialValidate.outputSuppliers(e.name)))); // validate all and filter null values from validation errors
+ });
+});
+
+
+module.exports = router;
+
+
+async function nameCheck (material, res, next) { // check if name was already taken
+ const materialData = await MaterialModel.findOne({name: material.name}).lean().exec().catch(err => next(err)) as any;
+ if (materialData instanceof Error) return false;
+ if (materialData) { // could not find material_id
+ res.status(400).json({status: 'Material name already taken'});
+ return false;
+ }
+ return true;
+}
+
+async function groupResolve (material, req, next) {
+ const groupData = await MaterialGroupModel.findOneAndUpdate({name: material.group}, {name: material.group}, {upsert: true, new: true}).log(req).lean().exec().catch(err => next(err)) as any;
+ if (groupData instanceof Error) return false;
+ material.group_id = groupData._id;
+ delete material.group;
+ return material;
+}
+
+async function supplierResolve (material, req, next) {
+ const supplierData = await MaterialSupplierModel.findOneAndUpdate({name: material.supplier}, {name: material.supplier}, {upsert: true, new: true}).log(req).lean().exec().catch(err => next(err)) as any;
+ if (supplierData instanceof Error) return false;
+ material.supplier_id = supplierData._id;
+ delete material.supplier;
+ return material;
+}
+
+function setStatus (status, req, res, next) { // set measurement status
+ MaterialModel.findByIdAndUpdate(req.params.id, {status: status}).log(req).lean().exec((err, data) => {
+ if (err) return next(err);
+
+ if (!data) {
+ return res.status(404).json({status: 'Not found'});
+ }
+ res.json({status: 'OK'});
+ });
+}
\ No newline at end of file
diff --git a/src/routes/measurement.spec.ts b/src/routes/measurement.spec.ts
new file mode 100644
index 0000000..dd43520
--- /dev/null
+++ b/src/routes/measurement.spec.ts
@@ -0,0 +1,790 @@
+import should from 'should/as-function';
+import MeasurementModel from '../models/measurement';
+import TestHelper from "../test/helper";
+import globals from '../globals';
+
+
+describe('/measurement', () => {
+ let server;
+ before(done => TestHelper.before(done));
+ beforeEach(done => server = TestHelper.beforeEach(server, done));
+ afterEach(done => TestHelper.afterEach(server, done));
+ after(done => TestHelper.after(done));
+
+ describe('GET /measurement/{id}', () => {
+ it('returns the right measurement', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/measurement/800000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ res: {_id: '800000000000000000000001', sample_id: '400000000000000000000001', 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', sample_id: '400000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'}
+ });
+ });
+ it('returns deleted measurements for a maintain/admin user', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/measurement/800000000000000000000004',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ res: {_id: '800000000000000000000004', sample_id: '400000000000000000000003', values: {val1: 1}, measurement_template: '300000000000000000000003'}
+ });
+ });
+ it('rejects requests for deleted measurements from a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/measurement/800000000000000000000004',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403
+ });
+ });
+ 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('PUT /measurement/{id}', () => {
+ it('returns the right measurement', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {},
+ res: {_id: '800000000000000000000001', sample_id: '400000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'}
+ });
+ });
+ it('keeps unchanged values', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({_id: '800000000000000000000001', sample_id: '400000000000000000000001', values: {dpt: [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]}, measurement_template: '300000000000000000000001'});
+ MeasurementModel.findById('800000000000000000000001').lean().exec((err, data: any) => {
+ if (err) return done(err);
+ should(data).have.property('status',globals.status.validated);
+ done();
+ });
+ });
+ });
+ it('keeps only one unchanged value', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {values: {'weight %': 0.5}}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({_id: '800000000000000000000002', sample_id: '400000000000000000000002', values: {'weight %': 0.5, 'standard deviation': 0.2}, measurement_template: '300000000000000000000002'});
+ MeasurementModel.findById('800000000000000000000002').lean().exec((err, data: any) => {
+ if (err) return done(err);
+ should(data).have.property('status',globals.status.validated);
+ done();
+ });
+ });
+ });
+ it('changes the given values', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {values: {dpt: [[1,2],[3,4],[5,6]]}}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({_id: '800000000000000000000001', sample_id: '400000000000000000000001', values: {dpt: [[1,2],[3,4],[5,6]]}, measurement_template: '300000000000000000000001'});
+ MeasurementModel.findById('800000000000000000000001').lean().exec((err, data: any) => {
+ should(data).have.only.keys('_id', 'sample_id', 'values', 'measurement_template', 'status', '__v');
+ should(data.sample_id.toString()).be.eql('400000000000000000000001');
+ should(data.measurement_template.toString()).be.eql('300000000000000000000001');
+ should(data).have.property('status',globals.status.new);
+ should(data).have.property('values');
+ should(data.values).have.property('dpt', [[1,2],[3,4],[5,6]]);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {values: {dpt: [[1,2],[3,4],[5,6]]}},
+ log: {
+ collection: 'measurements',
+ dataAdd: {
+ measurement_template: '300000000000000000000001',
+ sample_id: '400000000000000000000001',
+ status: 0
+ }
+ }
+ });
+ });
+ it('allows changing only one value', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {values: {'weight %': 0.9}},
+ res: {_id: '800000000000000000000002', sample_id: '400000000000000000000002', values: {'weight %': 0.9, 'standard deviation': 0.2}, measurement_template: '300000000000000000000002'}
+ });
+ });
+ it('allows keeping empty values empty', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000005',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {values: {'weight %': 0.9}},
+ res: {_id: '800000000000000000000005', sample_id: '400000000000000000000002', values: {'weight %': 0.9, 'standard deviation': null}, measurement_template: '300000000000000000000002'}
+ });
+ });
+ it('rejects not specified values', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {values: {'weight %': 0.9, 'standard deviation': 0.3, xx: 44}},
+ res: {status: 'Invalid body format', details: '"xx" is not allowed'}
+ });
+ });
+ it('rejects a value not in the value range', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000003',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {values: {val1: 4}},
+ res: {status: 'Invalid body format', details: '"val1" must be one of [1, 2, 3, null]'}
+ });
+ });
+ it('rejects a value below minimum range', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {values: {'weight %': -1, 'standard deviation': 0.3}},
+ 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: 'put',
+ url: '/measurement/800000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {values: {'weight %': 0.9, 'standard deviation': 3}},
+ res: {status: 'Invalid body format', details: '"standard deviation" must be less than or equal to 0.5'}
+ });
+ });
+ it('rejects a new measurement template', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {values: {'weight %': 0.9, 'standard deviation': 0.3}, measurement_template: '300000000000000000000001'},
+ res: {status: 'Invalid body format', details: '"measurement_template" is not allowed'}
+ });
+ });
+ it('rejects a new sample id', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {values: {'weight %': 0.9, 'standard deviation': 0.3}, sample_id: '400000000000000000000002'},
+ res: {status: 'Invalid body format', details: '"sample_id" is not allowed'}
+ });
+ });
+ it('rejects editing a measurement for a write user who did not create this measurement', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000003',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {values: {val1: 2}}
+ });
+ });
+ it('accepts editing a measurement of another user for a maintain/admin user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000002',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {values: {'weight %': 0.9, 'standard deviation': 0.3}},
+ res: {_id: '800000000000000000000002', sample_id: '400000000000000000000002', values: {'weight %': 0.9, 'standard deviation': 0.3}, measurement_template: '300000000000000000000002'}
+ });
+ });
+ it('rejects an invalid id', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000h00000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404
+ });
+ });
+ it('rejects an unknown id', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/000000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404
+ });
+ });
+ it('rejects editing a deleted measurement', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000004',
+ auth: {basic: 'admin'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000002',
+ auth: {key: 'janedoe'},
+ httpStatus: 401,
+ req: {values: {'weight %': 0.9, 'standard deviation': 0.3}},
+ });
+ });
+ it('rejects requests from a read user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000002',
+ auth: {basic: 'user'},
+ httpStatus: 403,
+ req: {values: {'weight %': 0.9, 'standard deviation': 0.3}},
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/800000000000000000000002',
+ httpStatus: 401,
+ req: {values: {'weight %': 0.9, 'standard deviation': 0.3}},
+ });
+ });
+ });
+
+ describe('DELETE /measurement/{id}', () => {
+ it('sets the status to deleted', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/measurement/800000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({status: 'OK'});
+ MeasurementModel.findById('800000000000000000000001').lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.property('status',globals.status.deleted);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/measurement/800000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ log: {
+ collection: 'measurements',
+ dataAdd: {
+ status: -1
+ }
+ }
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/measurement/800000000000000000000001',
+ auth: {key: 'janedoe'},
+ httpStatus: 401,
+ });
+ });
+ it('rejects requests from a read user', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/measurement/800000000000000000000001',
+ auth: {basic: 'user'},
+ httpStatus: 403,
+ });
+ });
+ it('rejects deleting a measurement for a write user who did not create this measurement', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/measurement/800000000000000000000003',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ });
+ });
+ it('accepts deleting a measurement of another user for a maintain/admin user', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/measurement/800000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ res: {status: 'OK'}
+ });
+ });
+ it('rejects an invalid id', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/measurement/800000000h00000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404,
+ });
+ });
+ it('rejects an unknown id', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/measurement/000000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404,
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/measurement/800000000000000000000001',
+ httpStatus: 401,
+ });
+ });
+ });
+
+ describe('PUT /measurement/restore/{id}', () => {
+ it('sets the status', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/restore/800000000000000000000004',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).be.eql({status: 'OK'});
+ MeasurementModel.findById('800000000000000000000004').lean().exec((err, data: any) => {
+ if (err) return done(err);
+ should(data).have.property('status',globals.status.new);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/restore/800000000000000000000004',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {},
+ log: {
+ collection: 'measurements',
+ dataAdd: {
+ status: 0
+ }
+ }
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/restore/800000000000000000000004',
+ auth: {key: 'admin'},
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ it('rejects a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/restore/800000000000000000000004',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('returns 404 for an unknown sample', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/restore/000000000000000000000004',
+ auth: {basic: 'admin'},
+ httpStatus: 404,
+ req: {}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/restore/800000000000000000000004',
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ });
+
+ describe('PUT /measurement/validate/{id}', () => {
+ it('sets the status', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/validate/800000000000000000000003',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).be.eql({status: 'OK'});
+ MeasurementModel.findById('800000000000000000000003').lean().exec((err, data: any) => {
+ if (err) return done(err);
+ should(data).have.property('status',globals.status.validated);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/validate/800000000000000000000003',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {},
+ log: {
+ collection: 'measurements',
+ dataAdd: {
+ status: 10
+ }
+ }
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/validate/800000000000000000000003',
+ auth: {key: 'admin'},
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ it('rejects a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/validate/800000000000000000000003',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('returns 404 for an unknown sample', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/validate/000000000000000000000003',
+ auth: {basic: 'admin'},
+ httpStatus: 404,
+ req: {}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/measurement/validate/800000000000000000000003',
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ });
+
+ 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: {sample_id: '400000000000000000000001', 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', 'sample_id', 'values', 'measurement_template');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('sample_id', '400000000000000000000001');
+ 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: {sample_id: '400000000000000000000001', 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) => {
+ if (err) return done(err);
+ should(data).have.only.keys('_id', 'sample_id', 'values', 'measurement_template', 'status', '__v');
+ should(data.sample_id.toString()).be.eql('400000000000000000000001');
+ should(data.measurement_template.toString()).be.eql('300000000000000000000002');
+ should(data).have.property('status', 0);
+ should(data).have.property('values');
+ should(data.values).have.property('weight %', 0.8);
+ should(data.values).have.property('standard deviation', 0.1);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/measurement/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'},
+ log: {
+ collection: 'measurements',
+ dataAdd: {
+ status: 0
+ }
+ }
+ });
+ });
+ it('rejects an invalid sample id', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/measurement/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {sample_id: '400000000000h00000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'},
+ res: {status: 'Invalid body format', details: '"sample_id" with value "400000000000h00000000001" fails to match the required pattern: /[0-9a-f]{24}/'}
+ });
+ });
+ it('rejects a sample id not available', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/measurement/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {sample_id: '000000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1}, measurement_template: '300000000000000000000002'},
+ res: {status: 'Sample 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: {sample_id: '400000000000000000000001', 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: {sample_id: '400000000000000000000001', 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: {sample_id: '400000000000000000000001', values: {'weight %': 0.8, 'standard deviation': 0.1, xx: 44}, measurement_template: '300000000000000000000002'},
+ res: {status: 'Invalid body format', details: '"xx" is not allowed'}
+ });
+ });
+ it('accepts missing values', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/measurement/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {sample_id: '400000000000000000000001', values: {'weight %': 0.8}, measurement_template: '300000000000000000000002'}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).have.only.keys('_id', 'sample_id', 'values', 'measurement_template');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('sample_id', '400000000000000000000001');
+ 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', null);
+ done();
+ });
+ });
+ it('rejects no values', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/measurement/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {sample_id: '400000000000000000000001', values: {}, measurement_template: '300000000000000000000002'},
+ res: {status: 'At least one value 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: {sample_id: '400000000000000000000001', values: {val2: 5}, measurement_template: '300000000000000000000004'},
+ res: {status: 'Invalid body format', details: '"val2" must be one of [1, 2, 3, 4, null]'}
+ });
+ });
+ it('rejects a value below minimum range', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/measurement/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {sample_id: '400000000000000000000001', 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: {sample_id: '400000000000000000000001', 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 sample 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: '"sample_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: {sample_id: '400000000000000000000001', 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: {sample_id: '400000000000000000000003', 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: {sample_id: '400000000000000000000001', 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', 'sample_id', 'values', 'measurement_template');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('sample_id', '400000000000000000000001');
+ 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 old version of a measurement template', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/measurement/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {sample_id: '400000000000000000000001', values: {val1: 2}, measurement_template: '300000000000000000000003'},
+ res: {status: 'Old template version not allowed'}
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/measurement/new',
+ auth: {key: 'janedoe'},
+ httpStatus: 401,
+ req: {sample_id: '400000000000000000000001', 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: {sample_id: '400000000000000000000001', 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: {sample_id: '400000000000000000000001', 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..47af305
--- /dev/null
+++ b/src/routes/measurement.ts
@@ -0,0 +1,169 @@
+import express from 'express';
+import _ from 'lodash';
+
+import MeasurementModel from '../models/measurement';
+import MeasurementTemplateModel from '../models/measurement_template';
+import SampleModel from '../models/sample';
+import MeasurementValidate from './validate/measurement';
+import IdValidate from './validate/id';
+import res400 from './validate/res400';
+import ParametersValidate from './validate/parameters';
+import globals from '../globals';
+import db from '../db';
+
+
+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: any) => {
+ if (err) return next(err);
+ if (!data) {
+ return res.status(404).json({status: 'Not found'});
+ }
+ if (data.status ===globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted measurements only available for maintain/admin
+
+ res.json(MeasurementValidate.output(data));
+ });
+});
+
+router.put('/measurement/' + IdValidate.parameter(), async (req, res, next) => {
+ if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ const {error, value: measurement} = MeasurementValidate.input(req.body, 'change');
+ if (error) return res400(error, res);
+
+ const data = await MeasurementModel.findById(req.params.id).lean().exec().catch(err => {next(err);}) as any;
+ if (data instanceof Error) return;
+ if (!data) {
+ return res.status(404).json({status: 'Not found'});
+ }
+ if (data.status === globals.status.deleted) {
+ return res.status(403).json({status: 'Forbidden'});
+ }
+
+ // add properties needed for sampleIdCheck
+ measurement.measurement_template = data.measurement_template;
+ measurement.sample_id = data.sample_id;
+ if (!await sampleIdCheck(measurement, req, res, next)) return;
+
+ // check for changes
+ if (measurement.values) { // fill not changed values from database
+ measurement.values = _.assign({}, data.values, measurement.values);
+ if (!_.isEqual(measurement.values, data.values)) {
+ measurement.status = globals.status.new; // set status to new
+ }
+ }
+
+ if (!await templateCheck(measurement, 'change', res, next)) return;
+ await MeasurementModel.findByIdAndUpdate(req.params.id, measurement, {new: true}).log(req).lean().exec((err, data) => {
+ if (err) return next(err);
+ res.json(MeasurementValidate.output(data));
+ });
+});
+
+router.delete('/measurement/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ MeasurementModel.findById(req.params.id).lean().exec(async (err, data) => {
+ if (err) return next(err);
+ if (!data) {
+ return res.status(404).json({status: 'Not found'});
+ }
+ if (!await sampleIdCheck(data, req, res, next)) return;
+ await MeasurementModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).log(req).lean().exec(err => {
+ if (err) return next(err);
+ return res.json({status: 'OK'});
+ });
+ });
+});
+
+router.put('/measurement/restore/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['maintain', 'admin'], 'basic')) return;
+
+ setStatus(globals.status.new, req, res, next);
+});
+
+router.put('/measurement/validate/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['maintain', 'admin'], 'basic')) return;
+
+ setStatus(globals.status.validated, req, res, next);
+});
+
+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 sampleIdCheck(measurement, req, res, next)) return;
+ measurement.values = await templateCheck(measurement, 'new', res, next);
+ if (!measurement.values) return;
+
+ measurement.status = 0;
+ await new MeasurementModel(measurement).save((err, data) => {
+ if (err) return next(err);
+ db.log(req, 'measurements', {_id: data._id}, data.toObject());
+ res.json(MeasurementValidate.output(data.toObject()));
+ });
+});
+
+
+module.exports = router;
+
+
+async function sampleIdCheck (measurement, req, res, next) { // validate sample_id, returns false if invalid or user has no access for this sample
+ const sampleData = await SampleModel.findById(measurement.sample_id).lean().exec().catch(err => {next(err); return false;}) as any;
+ if (!sampleData) { // sample_id not found
+ res.status(400).json({status: 'Sample id not available'});
+ return false
+ }
+ if (sampleData.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, returns values, true if values are {} or false if invalid, param for 'new'/'change'
+ 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
+ }
+
+ // fill not given values for new measurements
+ if (param === 'new') {
+ // get all template versions and check if given is latest
+ const templateVersions = await MeasurementTemplateModel.find({first_id: templateData.first_id}).sort({version: -1}).lean().exec().catch(err => next(err)) as any;
+ if (templateVersions instanceof Error) return false;
+ if (measurement.measurement_template !== templateVersions[0]._id.toString()) { // template not latest
+ res.status(400).json({status: 'Old template version not allowed'});
+ return false;
+ }
+
+ if (Object.keys(measurement.values).length === 0) {
+ res.status(400).json({status: 'At least one value is required'});
+ return false
+ }
+ const fillValues = {}; // initialize not given values with null
+ templateData.parameters.forEach(parameter => {
+ fillValues[parameter.name] = null;
+ });
+ measurement.values = _.assign({}, fillValues, measurement.values);
+ }
+
+ // validate values
+ const {error, value} = ParametersValidate.input(measurement.values, templateData.parameters, 'null');
+ if (error) {res400(error, res); return false;}
+ return value || true;
+}
+
+function setStatus (status, req, res, next) { // set measurement status
+ MeasurementModel.findByIdAndUpdate(req.params.id, {status: status}).log(req).lean().exec((err, data) => {
+ if (err) return next(err);
+
+ if (!data) {
+ return res.status(404).json({status: 'Not found'});
+ }
+ res.json({status: 'OK'});
+ });
+}
\ No newline at end of file
diff --git a/src/routes/root.spec.ts b/src/routes/root.spec.ts
index cfec79c..68531a5 100644
--- a/src/routes/root.spec.ts
+++ b/src/routes/root.spec.ts
@@ -1,19 +1,256 @@
-import supertest from 'supertest';
+import TestHelper from "../test/helper";
import should from 'should/as-function';
+import db from '../db';
-let server = supertest.agent('http://localhost:3000');
+describe('/', () => {
+ let server;
+ before(done => TestHelper.before(done));
+ beforeEach(done => server = TestHelper.beforeEach(server, done));
+ afterEach(done => TestHelper.afterEach(server, done));
+ after(done => TestHelper.after(done));
-describe('Testing /', () => {
- it('returns the message object', done => {
- server
- .get('/')
- .expect('Content-type', /json/)
- .expect(200)
- .end(function(err, res) {
- should(res.statusCode).equal(200);
- should(res.body).be.eql({message: 'API server up and running!'});
+ describe('GET /', () => {
+ it('returns the root message', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/',
+ httpStatus: 200,
+ res: {status: 'API server up and running!'}
+ });
+ });
+ });
+
+ describe('GET /changelog/{timestamp}/{page}/{pagesize}', () => {
+ it('returns the first page', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/changelog/1979-07-28T06:04:51.000Z/0/2',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).have.lengthOf(2);
+ should(res.body[0].date).be.eql('1979-07-28T06:04:51.000Z');
+ should(res.body[1].date).be.eql('1979-07-28T06:04:50.000Z');
+ should(res.body).matchEach(log => {
+ should(log).have.only.keys('date', 'action', 'collection', 'conditions', 'data');
+ should(log).have.property('action', 'PUT /sample/400000000000000000000001');
+ should(log).have.property('collection', 'samples');
+ should(log).have.property('conditions', {_id: '400000000000000000000001'});
+ should(log).have.property('data', {type: 'part', status: 0});
+ });
done();
});
+ });
+ it('returns another page', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/changelog/1979-07-28T06:04:51.000Z/1/2',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).have.lengthOf(1);
+ should(res.body[0].date).be.eql('1979-07-28T06:04:49.000Z');
+ should(res.body).matchEach(log => {
+ should(log).have.only.keys('date', 'action', 'collection', 'conditions', 'data');
+ should(log).have.property('action', 'PUT /sample/400000000000000000000001');
+ should(log).have.property('collection', 'samples');
+ should(log).have.property('conditions', {_id: '400000000000000000000001'});
+ should(log).have.property('data', {type: 'part', status: 0});
+ done();
+ });
+ });
+ });
+ it('returns an empty array for a page with no results', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/changelog/1979-07-28T06:04:51.000Z/10/2',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).have.lengthOf(0);
+ done();
+ });
+ });
+ it('rejects timestamps pre unix epoch', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/changelog/1879-07-28T06:04:51.000Z/10/2',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: '"timestamp" must be larger than or equal to "1970-01-01T00:00:00.000Z"'}
+ });
+ });
+ it('rejects invalid timestamps', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/changelog/1979-14-28T06:04:51.000Z/10/2',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: '"timestamp" must be in ISO 8601 date format'}
+ });
+ });
+ it('rejects negative page numbers', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/changelog/1979-07-28T06:04:51.000Z/-10/2',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: '"page" must be larger than or equal to 0'}
+ });
+ });
+ it('rejects negative pagesizes', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/changelog/1979-07-28T06:04:51.000Z/10/-2',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: '"pagesize" must be larger than or equal to 0'}
+ });
+ });
+ it('rejects request from a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/changelog/1979-07-28T06:04:51.000Z/10/2',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403
+ });
+ });
+ it('rejects requests from an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/changelog/1979-07-28T06:04:51.000Z/10/2',
+ auth: {key: 'admin'},
+ httpStatus: 401
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/changelog/1979-07-28T06:04:51.000Z/10/2',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('Unknown routes', () => {
+ it('return a 404 message', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/unknownroute',
+ httpStatus: 404
+ });
+ });
+ });
+
+ describe('An unauthorized request', () => {
+ it('returns a 401 message', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/authorized',
+ httpStatus: 401
+ });
+ });
+ it('does not work with correct username', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/authorized',
+ auth: {basic: {name: 'admin', pass: 'Abc123!!'}},
+ httpStatus: 401
+ });
+ });
+ it('does not work with incorrect username', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/authorized',
+ auth: {basic: {name: 'adminxx', pass: 'Abc123!!'}},
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('An authorized request', () => {
+ it('works with an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/authorized',
+ auth: {key: 'admin'},
+ httpStatus: 200,
+ res: {status: 'Authorization successful', method: 'key'}
+ });
+ });
+ it('works with basic auth', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/authorized',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ res: {status: 'Authorization successful', method: 'basic'}
+ });
+ });
+ });
+
+ describe('An invalid JSON body', () => {
+ it('is rejected', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/',
+ httpStatus: 400,
+ reqType: 'json',
+ req: '{"xxx"}',
+ res: {status: 'Invalid JSON body'}
+ });
+
+ });
+ });
+
+ describe('A not connected database', () => { // RUN AS LAST OR RECONNECT DATABASE!!
+ it('resolves to an 500 error', done => {
+ db.disconnect(() => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/',
+ httpStatus: 500
+ });
+ });
+ });
});
});
+
+describe('The /api/{url} redirect', () => {
+ let server;
+ let counter = 0; // count number of current test method
+ before(done => {
+ process.env.port = '2999';
+ db.connect('test', done);
+ });
+ beforeEach(done => {
+ process.env.NODE_ENV = counter === 1 ? 'production' : 'test';
+ counter ++;
+ server = TestHelper.beforeEach(server, done);
+ });
+ afterEach(done => TestHelper.afterEach(server, done));
+ after(done => TestHelper.after(done));
+
+
+ it('returns the right method', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/api/authorized',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ res: {status: 'Authorization successful', method: 'basic'}
+ });
+ });
+ it('is disabled in production', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/api/authorized',
+ auth: {basic: 'admin'},
+ httpStatus: 404
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/routes/root.ts b/src/routes/root.ts
index 896f360..1547844 100644
--- a/src/routes/root.ts
+++ b/src/routes/root.ts
@@ -1,9 +1,35 @@
import express from 'express';
+import globals from '../globals';
+import RootValidate from './validate/root';
+import res400 from './validate/res400';
+import ChangelogModel from '../models/changelog';
+import mongoose from 'mongoose';
+import _ from 'lodash';
const router = express.Router();
router.get('/', (req, res) => {
- res.json({message: 'API server up and running!'});
+ res.json({status: 'API server up and running!'});
+});
+
+router.get('/authorized', (req, res) => {
+ if (!req.auth(res, globals.levels)) return;
+ res.json({status: 'Authorization successful', method: req.authDetails.method});
+});
+
+// TODO: evaluate exact changelog functionality (restoring, delting after time, etc.)
+router.get('/changelog/:timestamp/:page?/:pagesize?', (req, res, next) => {
+ if (!req.auth(res, ['maintain', 'admin'], 'basic')) return;
+
+ const {error, value: options} = RootValidate.changelogParams({timestamp: req.params.timestamp, page: req.params.page, pagesize: req.params.pagesize});
+ if (error) return res400(error, res);
+
+ const id = new mongoose.Types.ObjectId(Math.floor(new Date(options.timestamp).getTime() / 1000).toString(16) + '0000000000000000');
+ ChangelogModel.find({_id: {$lte: id}}).sort({_id: -1}).skip(options.page * options.pagesize).limit(options.pagesize).lean().exec((err, data) => {
+ if (err) return next(err);
+
+ res.json(_.compact(data.map(e => RootValidate.changelogOutput(e)))); // validate all and filter null values from validation errors
+ });
});
module.exports = router;
diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts
new file mode 100644
index 0000000..7dc5f24
--- /dev/null
+++ b/src/routes/sample.spec.ts
@@ -0,0 +1,1930 @@
+import should from 'should/as-function';
+import SampleModel from '../models/sample';
+import NoteModel from '../models/note';
+import NoteFieldModel from '../models/note_field';
+import MeasurementModel from '../models/measurement';
+import TestHelper from "../test/helper";
+import globals from '../globals';
+import mongoose from 'mongoose';
+
+// TODO: generate output for ML in format DPT -> data, implement filtering, field selection
+// TODO: generate csv
+// TODO: write script for data import
+// TODO: allowed types: tension rod, part, granulate, other
+
+describe('/sample', () => {
+ let server;
+ before(done => TestHelper.before(done));
+ beforeEach(done => server = TestHelper.beforeEach(server, done));
+ afterEach(done => TestHelper.afterEach(server, done));
+ after(done => TestHelper.after(done));
+
+ // TODO: sort, added date filter, has measurements/condition filter
+ // TODO: check if conditions work in sort/fields/filters
+ // TODO: test for numbers as strings in glass_fiber
+ describe('GET /samples', () => {
+ it('returns all samples', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.validated).length);
+ should(res.body).matchEach(sample => {
+ should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added');
+ should(sample).have.property('_id').be.type('string');
+ should(sample).have.property('number').be.type('string');
+ should(sample).have.property('type').be.type('string');
+ should(sample).have.property('color').be.type('string');
+ should(sample).have.property('batch').be.type('string');
+ should(sample).have.property('condition').be.type('object');
+ should(sample.condition).have.property('condition_template').be.type('string');
+ should(sample).have.property('material_id').be.type('string');
+ should(sample).have.property('note_id');
+ should(sample).have.property('user_id').be.type('string');
+ should(sample).have.property('added').be.type('string');
+ });
+ done();
+ });
+ });
+ it('works with an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples',
+ auth: {key: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.validated).length);
+ should(res.body).matchEach(sample => {
+ should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added');
+ should(sample).have.property('_id').be.type('string');
+ should(sample).have.property('number').be.type('string');
+ should(sample).have.property('type').be.type('string');
+ should(sample).have.property('color').be.type('string');
+ should(sample).have.property('batch').be.type('string');
+ should(sample).have.property('condition').be.type('object');
+ should(sample.condition).have.property('condition_template').be.type('string');
+ should(sample).have.property('material_id').be.type('string');
+ should(sample).have.property('note_id');
+ should(sample).have.property('user_id').be.type('string');
+ should(sample).have.property('added').be.type('string');
+ });
+ done();
+ });
+ });
+ it('allows filtering by state', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.new).length);
+ should(res.body).matchEach(sample => {
+ should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added');
+ should(sample).have.property('_id').be.type('string');
+ should(sample).have.property('number').be.type('string');
+ should(sample).have.property('type').be.type('string');
+ should(sample).have.property('color').be.type('string');
+ should(sample).have.property('batch').be.type('string');
+ should(sample).have.property('condition').be.type('object');
+ should(sample).have.property('material_id').be.type('string');
+ should(sample).have.property('note_id');
+ should(sample).have.property('user_id').be.type('string');
+ should(sample).have.property('added').be.type('string');
+ });
+ done();
+ });
+ });
+ it('uses the given page size', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&page-size=3',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).have.lengthOf(3);
+ done();
+ });
+ });
+ it('returns results starting from first-id', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&from-id=400000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body[0]).have.property('_id', '400000000000000000000002');
+ should(res.body[1]).have.property('_id', '400000000000000000000003');
+ done();
+ });
+ });
+ it('returns the right page number', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&to-page=2&page-size=2',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body[0]).have.property('_id', '400000000000000000000006');
+ done();
+ });
+ });
+ it('works with negative page numbers', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&to-page=-1&page-size=2&from-id=400000000000000000000004',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body[0]).have.property('_id', '400000000000000000000002');
+ should(res.body[1]).have.property('_id', '400000000000000000000003');
+ done();
+ });
+ });
+ it('returns an empty array for a page number out of range', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&to-page=100&page-size=2',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).have.lengthOf(0);
+ should(res.body).be.eql([]);
+ done();
+ });
+ });
+ it('returns an empty array for a page number out of negative range', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&to-page=-100&page-size=3&from-id=400000000000000000000004',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).have.lengthOf(0);
+ should(res.body).be.eql([]);
+ done();
+ });
+ });
+ it('sorts the samples ascending', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&sort=color-asc',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body[0]).have.property('color', 'black');
+ should(res.body[res.body.length - 1]).have.property('color', 'natural');
+ done();
+ });
+ });
+ it('sorts the samples descending', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&sort=number-desc',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body[0]).have.property('number', 'Rng36');
+ should(res.body[1]).have.property('number', '33');
+ should(res.body[res.body.length - 1]).have.property('number', '1');
+ done();
+ });
+ });
+ it('sorts the samples correctly in combination with paging', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&sort=color-asc&page-size=2&from-id=400000000000000000000006',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body[0]).have.property('_id', '400000000000000000000006');
+ should(res.body[1]).have.property('_id', '400000000000000000000002');
+ done();
+ });
+ });
+ it('sorts the samples correctly in combination with going pages backward', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&sort=color-desc&page-size=2&from-id=400000000000000000000004&to-page=-1',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body[0]).have.property('_id', '400000000000000000000002');
+ should(res.body[1]).have.property('_id', '400000000000000000000006');
+ done();
+ });
+ });
+ it('sorts the samples correctly for material keys', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&sort=material.name-desc',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body[0]).have.property('_id', '400000000000000000000002');
+ should(res.body[1]).have.property('_id', '400000000000000000000006');
+ should(res.body[2]).have.property('_id', '400000000000000000000001');
+ done();
+ });
+ });
+ it('adds the specified measurements', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&fields[]=number&fields[]=measurements.kf',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body.find(e => e.number === '1')).have.property('kf', {'weight %': null, 'standard deviation': null});
+ should(res.body.find(e => e.number === 'Rng36')).have.property('kf', {'weight %': 0.6, 'standard deviation': null});
+ done();
+ });
+ });
+ it('multiplies the sample information for each spectrum', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&fields[]=number&fields[]=measurements.spectrum',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).have.lengthOf(2);
+ should(res.body[0]).have.property('spectrum', [[3997.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]);
+ should(res.body[1]).have.property('spectrum', [[3996.12558,98.00555],[3995.08519,98.03253],[3993.04480,98.02657]]);
+ done();
+ });
+ });
+ it('filters a sample property', done => { // TODO: implement filters
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&fields[]=number&fields[]=type&filters[]=%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22type%22%2C%22values%22%3A%5B%22part%22%5D%7D',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.samples.filter(e => e.type === 'part').length);
+ should(res.body).matchEach(sample => {
+ should(sample).have.property('type', 'part');
+ });
+ done();
+ });
+ });
+ it('filters a material property', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&fields[]=number&fields[]=material.name&filters[]=%7B%22mode%22%3A%22in%22%2C%22field%22%3A%22material.name%22%2C%22values%22%3A%5B%22Schulamid%2066%20GF%2025%20H%22%2C%22Stanyl%20TW%20200%20F8%22%5D%7D',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.samples.filter(e => e.material_id == '100000000000000000000004' || e.material_id == '100000000000000000000001').length);
+ should(res.body).matchEach(sample => {
+ should(sample.material.name).be.equalOneOf('Schulamid 66 GF 25 H', 'Stanyl TW 200 F8');
+ });
+ done();
+ });
+ });
+ it('filters by measurement value', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&fields[]=number&fields[]=material.name&fields[]=measurements.kf.weight%20%25&filters[]=%7B%22mode%22%3A%22gt%22%2C%22field%22%3A%22measurements.kf.weight%20%25%22%2C%22values%22%3A%5B0.5%5D%7D',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.measurements.filter(e => e.measurement_template == '300000000000000000000002' && e.values['weight %'] > 0.5).length);
+ should(res.body).matchEach(sample => {
+ should(sample.kf['weight %']).be.above(0.5);
+ });
+ done();
+ });
+ });
+ it('filters by measurement value not in the fields', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&fields[]=number&fields[]=material.name&filters[]=%7B%22mode%22%3A%22gt%22%2C%22field%22%3A%22measurements.kf.weight%20%25%22%2C%22values%22%3A%5B0.5%5D%7D',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.measurements.filter(e => e.measurement_template == '300000000000000000000002' && e.values['weight %'] > 0.5).length);
+ should(res.body[0]).have.property('number', 'Rng36');
+ done();
+ });
+ });
+ it('filters multiple properties', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&fields[]=number&fields[]=material.glass_fiber&fields[]=batch&filters[]=%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22material.glass_fiber%22%2C%22values%22%3A%5B33%5D%7D&filters[]=%7B%22mode%22%3A%22lte%22%2C%22field%22%3A%22number%22%2C%22values%22%3A%5B%22Rng33%22%5D%7D&filters[]=%7B%22mode%22%3A%22nin%22%2C%22field%22%3A%22batch%22%2C%22values%22%3A%5B%221704-005%22%5D%7D',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).have.lengthOf(1);
+ should(res.body[0]).be.eql({number: '32', material: {glass_fiber: 33}, batch: '1653000308'});
+ done();
+ });
+ }); // TODO: do measurement pipeline, check if it works with UI
+ it('rejects an invalid JSON string as a filters parameter', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&fields[]=number&fields[]=material.glass_fiber&fields[]=batch&filters[]=xx',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: 'Invalid JSON string for filter parameter'}
+ });
+ });
+ it('rejects an invalid filter mode', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&fields[]=number&fields[]=material.glass_fiber&fields[]=batch&filters[]=%7B%22mode%22%3A%22xx%22%2C%22field%22%3A%22batch%22%2C%22values%22%3A%5B%221704-005%22%5D%7D',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: '"filters[0].mode" must be one of [eq, ne, lt, lte, gt, gte, in, nin]'}
+ });
+ });
+ it('rejects an filter field not existing', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&fields[]=number&fields[]=material.glass_fiber&fields[]=batch&filters[]=%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22xx%22%2C%22values%22%3A%5B%221704-005%22%5D%7D',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: '"filters[0].field" with value "xx" fails to match the required pattern: /^(_id|color|number|type|batch|added|material\\.name|material\\.supplier|material\\.group|material\\.mineral|material\\.glass_fiber|material\\.carbon_fiber|material\\.number|measurements\\.(?!spectrum).+|condition|material_id|material|note_id|user_id|material\\._id|material\\.numbers|measurements\\.spectrum)$/m'}
+ });
+ });
+ it('rejects unknown measurement names', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&fields[]=number&fields[]=measurements.xx',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: 'Measurement key not found'}
+ });
+ });
+ it('returns a correct csv file if specified', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&page-size=2&csv=true',
+ contentType: /text\/csv/,
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.text).be.eql('"_id","number","type","color","batch","condition.material","condition.weeks","condition.condition_template","material_id","note_id","user_id","added"\r\n' +
+ '"400000000000000000000001","1","granulate","black","","copper",3,"200000000000000000000001","100000000000000000000004",,"000000000000000000000002","2004-01-10T13:37:04.000Z"\r\n' +
+ '"400000000000000000000002","21","granulate","natural","1560237365","copper",3,"200000000000000000000001","100000000000000000000001","500000000000000000000001","000000000000000000000002","2004-01-10T13:37:04.000Z"');
+ done();
+ });
+ });
+ it('returns only the fields specified', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&page-size=1&fields[]=number&fields[]=condition&fields[]=color&fields[]=material.name&fields[]=material.mineral',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ res: [{number: '1', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, color: 'black', material: {name: 'Schulamid 66 GF 25 H', mineral: 0}}]
+ });
+ });
+ it('rejects a from-id not in the database', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?from-id=5ea0450ed851c30a90e70894&sort=color-asc',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: 'from-id not found'}
+ });
+ });
+ it('rejects an invalid fields parameter', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&page-size=1&fields=number',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: '"fields" must be an array'}
+ });
+ });
+ it('rejects an unknown field name', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=all&page-size=1&fields[]=xx',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: '"fields[0]" with value "xx" fails to match the required pattern: /^(_id|color|number|type|batch|added|material\\.name|material\\.supplier|material\\.group|material\\.mineral|material\\.glass_fiber|material\\.carbon_fiber|material\\.number|measurements\\.(?!spectrum).+|condition|material_id|material|note_id|user_id|material\\._id|material\\.numbers|measurements\\.spectrum)$/m'}
+ });
+ });
+ it('rejects a negative page size', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?page-size=-3',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: '"page-size" must be larger than or equal to 1'}
+ });
+ });
+ it('rejects an invalid from-id', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?from-id=40000000000h000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: '"from-id" with value "40000000000h000000000002" fails to match the required pattern: /[0-9a-f]{24}/'}
+ });
+ });
+ it('rejects a to-page without page-size', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?to-page=3',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: '"to-page" missing required peer "page-size"'}
+ });
+ });
+ it('rejects an invalid state name', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples?status=xxx',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ res: {status: 'Invalid body format', details: '"status" must be one of [validated, new, all]'}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('GET /samples/{state}', () => {
+ it('returns all new samples', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples/new',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ let asyncCounter = res.body.length;
+ should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status ===globals.status.new).length);
+ should(res.body).matchEach(sample => {
+ should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added');
+ should(sample).have.property('_id').be.type('string');
+ should(sample).have.property('number').be.type('string');
+ should(sample).have.property('type').be.type('string');
+ should(sample).have.property('color').be.type('string');
+ should(sample).have.property('batch').be.type('string');
+ should(sample).have.property('condition').be.type('object');
+ if (Object.keys(sample.condition).length > 0) {
+ should(sample.condition).have.property('condition_template').be.type('string');
+ }
+ should(sample).have.property('material_id').be.type('string');
+ should(sample).have.property('note_id');
+ should(sample).have.property('user_id').be.type('string');
+ should(sample).have.property('added').be.type('string');
+ SampleModel.findById(sample._id).lean().exec((err, data) => {
+ should(data).have.property('status',globals.status.new);
+ if (--asyncCounter === 0) {
+ done();
+ }
+ });
+ });
+ });
+ });
+ it('returns all deleted samples', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples/deleted',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ let asyncCounter = res.body.length;
+ should(res.body).have.lengthOf(json.collections.samples.filter(e => e.status === -1).length);
+ should(res.body).matchEach(sample => {
+ should(sample).have.only.keys('_id', 'number', 'type', 'color', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added');
+ should(sample).have.property('_id').be.type('string');
+ should(sample).have.property('number').be.type('string');
+ should(sample).have.property('type').be.type('string');
+ should(sample).have.property('color').be.type('string');
+ should(sample).have.property('batch').be.type('string');
+ should(sample).have.property('condition').be.type('object');
+ should(sample.condition).have.property('condition_template').be.type('string');
+ should(sample.condition).have.property('condition_template').be.type('string');
+ should(sample.condition).have.property('condition_template').be.type('string');
+ should(sample).have.property('material_id').be.type('string');
+ should(sample).have.property('note_id');
+ should(sample).have.property('user_id').be.type('string');
+ should(sample).have.property('added').be.type('string');
+ SampleModel.findById(sample._id).lean().exec((err, data) => {
+ should(data).have.property('status',globals.status.deleted);
+ if (--asyncCounter === 0) {
+ done();
+ }
+ });
+ });
+ });
+ });
+ it('rejects requests from a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples/new',
+ auth: {key: 'admin'},
+ httpStatus: 401
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples/new',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('GET /samples/count', () => {
+ it('returns the correct number of samples', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples/count',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body.count).be.eql(json.collections.samples.length);
+ done();
+ });
+ });
+ it('works with an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples/count',
+ auth: {key: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body.count).be.eql(json.collections.samples.length);
+ done();
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/samples/count',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('GET /sample/{id}', () => {
+ it('returns the right sample', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/sample/400000000000000000000003',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, measurements: [{_id: '800000000000000000000003', sample_id: '400000000000000000000003', values: {val1: 1}, measurement_template: '300000000000000000000003'}], user: 'admin'}
+ });
+ });
+ it('works with an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/sample/400000000000000000000003',
+ auth: {key: 'janedoe'},
+ httpStatus: 200,
+ res: {_id: '400000000000000000000003', number: '33', type: 'part', color: 'black', batch: '1704-005', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {comment: '', sample_references: [{sample_id: '400000000000000000000004', relation: 'granulate to sample'}], custom_fields: {'not allowed for new applications': true}}, measurements: [{_id: '800000000000000000000003', sample_id: '400000000000000000000003', values: {val1: 1}, measurement_template: '300000000000000000000003'}], user: 'admin'}
+ });
+ });
+ it('returns a deleted sample for a maintain/admin user', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/sample/400000000000000000000005',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ res: {_id: '400000000000000000000005', number: 'Rng33', type: 'granulate', color: 'black', batch: '1653000308', condition: {condition_template: '200000000000000000000003'}, material: {_id: '100000000000000000000005', name: 'Amodel A 1133 HS', supplier: 'Solvay', group: 'PPA', mineral: 0, glass_fiber: 33, carbon_fiber: 0, numbers: [{color: 'black', number: '5514262406'}]}, notes: {}, measurements: [], user: 'admin'}
+ });
+ });
+ it('returns 403 for a write user when requesting a deleted sample', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/sample/400000000000000000000005',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403
+ });
+ });
+ it('returns 404 for an unknown sample', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/sample/000000000000000000000005',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404
+ });
+ });
+ it('rejects an invalid id', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/sample/400000000h00000000000005',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/sample/400000000000000000000005',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('PUT /sample/{id}', () => { // TODO: fix tests, work on /samples
+ it('returns the right sample', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {},
+ res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'}
+ });
+ });
+ it('keeps unchanged properties', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', notes: {}}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'});
+ SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => {
+ if (err) return done (err);
+ should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v');
+ should(data).have.property('_id');
+ should(data).have.property('number', '1');
+ should(data).have.property('color', 'black');
+ should(data).have.property('type', 'granulate');
+ should(data).have.property('batch', '');
+ should(data).have.property('condition', {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'});
+ should(data.material_id.toString()).be.eql('100000000000000000000004');
+ should(data.user_id.toString()).be.eql('000000000000000000000002');
+ should(data).have.property('status',globals.status.validated);
+ should(data).have.property('note_id', null);
+ done();
+ });
+ });
+ });
+ it('keeps only one unchanged parameter', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {type: 'granulate'}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'});
+ SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => {
+ if (err) return done (err);
+ should(data).have.property('status',globals.status.validated);
+ done();
+ });
+ });
+ });
+ it('keeps an unchanged condition', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'});
+ SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => {
+ if (err) return done (err);
+ should(data).have.property('status',globals.status.validated);
+ done();
+ });
+ });
+ });
+ it('keeps unchanged notes', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {notes: {comment: 'Stoff gesperrt', sample_references: []}}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({_id: '400000000000000000000002', number: '21', type: 'granulate', color: 'natural', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', note_id: '500000000000000000000001', user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'});
+ SampleModel.findById('400000000000000000000002').lean().exec((err, data: any) => {
+ if (err) return done (err);
+ should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v');
+ should(data).have.property('_id');
+ should(data).have.property('number', '21');
+ should(data).have.property('color', 'natural');
+ should(data).have.property('type', 'granulate');
+ should(data).have.property('batch', '1560237365');
+ should(data.condition).have.property('material', 'copper');
+ should(data.condition).have.property('weeks', 3);
+ should(data.condition.condition_template.toString()).be.eql('200000000000000000000001');
+ should(data.material_id.toString()).be.eql('100000000000000000000001');
+ should(data.user_id.toString()).be.eql('000000000000000000000002');
+ should(data).have.property('status',globals.status.validated);
+ should(data.note_id.toString()).be.eql('500000000000000000000001');
+ done();
+ });
+ });
+ });
+ it('changes the given properties', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {type: 'part', color: 'signalviolet', batch: '114531', condition: {condition_template: '200000000000000000000003'}, material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}
+ }).end(err => {
+ if (err) return done (err);
+ SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => {
+ if (err) return done (err);
+ should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v');
+ should(data).have.property('_id');
+ should(data).have.property('number', '1');
+ should(data).have.property('color', 'signalviolet');
+ should(data).have.property('type', 'part');
+ should(data).have.property('batch', '114531');
+ should(data).have.property('condition', {condition_template: '200000000000000000000003'});
+ should(data.material_id.toString()).be.eql('100000000000000000000002');
+ should(data.user_id.toString()).be.eql('000000000000000000000002');
+ should(data).have.property('status',globals.status.new);
+ should(data).have.property('note_id');
+ NoteModel.findById(data.note_id).lean().exec((err, data: any) => {
+ if (err) return done (err);
+ should(data).have.property('_id');
+ should(data).have.property('comment', 'Testcomment');
+ should(data).have.property('sample_references');
+ should(data.sample_references).have.lengthOf(1);
+ should(data.sample_references[0].sample_id.toString()).be.eql('400000000000000000000003');
+ should(data.sample_references[0]).have.property('relation', 'part to this sample');
+ done();
+ });
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {type: 'part', color: 'signalviolet', batch: '114531', condition: {condition_template: '200000000000000000000003'}, material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ log: {
+ collection: 'samples',
+ dataAdd: {
+ status: 0
+ },
+ dataIgn: ['notes', 'note_id']
+ }
+ });
+ });
+ it('adjusts the note_fields correctly', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000003',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {notes: {comment: 'Testcomment', sample_references: [], custom_fields: {field1: 'value 1'}}}
+ }).end(err => {
+ if (err) return done(err);
+ NoteFieldModel.findOne({name: 'not allowed for new applications'}).lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.property('qty', 1);
+ NoteFieldModel.findOne({name: 'field1'}).lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.property('qty', 1);
+ done();
+ });
+ });
+ });
+ });
+ it('deletes old note_fields', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000004',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {notes: {comment: 'Testcomment', sample_references: []}}
+ }).end(err => {
+ if (err) return done (err);
+ NoteFieldModel.findOne({name: 'another_field'}).lean().exec((err, data) => {
+ if (err) return done (err);
+ should(data).be.null();
+ done();
+ });
+ });
+ });
+ it('keeps untouched notes', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {type: 'part'}
+ }).end((err, res) => {
+ if (err) return done (err);
+ NoteModel.findById(res.body.note_id).lean().exec((err, data) => {
+ if (err) return done (err);
+ should(data).not.be.null();
+ should(data).have.property('comment', 'Stoff gesperrt');
+ should(data).have.property('sample_references').have.lengthOf(0);
+ done();
+ });
+ });
+ });
+ it('deletes old notes', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000004',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {notes: {comment: 'Testcomment', sample_references: []}}
+ }).end(err => {
+ if (err) return done (err);
+ NoteModel.findById('500000000000000000000003').lean().exec((err, data) => {
+ if (err) return done (err);
+ should(data).be.null();
+ done();
+ });
+ });
+ });
+ it('rejects a color not defined for the material', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Color not available for material'}
+ });
+ });
+ it('rejects an undefined color for the same material', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {type: 'part', color: 'signalviolet', batch: '114531', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Color not available for material'}
+ });
+ });
+ it('rejects an unknown material id', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '000000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Material not available'}
+ });
+ });
+ it('rejects a sample number', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {number: 25, type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Invalid body format', details: '"number" is not allowed'}
+ });
+ });
+ it('rejects an invalid sample reference', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '000000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Sample reference not available'}
+ });
+ });
+ it('rejects an invalid material id', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'}
+ });
+ });
+ it('rejects an invalid id', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/10000000000h000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404,
+ req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ });
+ });
+ it('rejects not specified condition parameters', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {condition: {material: 'copper', weeks: 3, xxx: 44, condition_template: '200000000000000000000001'}},
+ res: {status: 'Invalid body format', details: '"xxx" is not allowed'}
+ });
+ });
+ it('rejects a condition parameter not in the value range', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {condition: {material: 'xx', weeks: 3, condition_template: '200000000000000000000001'}},
+ res: {status: 'Invalid body format', details: '"material" must be one of [copper, hot air]'}
+ });
+ });
+ it('rejects a condition parameter below minimum range', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {condition: {material: 'copper', weeks: 0, condition_template: '200000000000000000000001'}},
+ res: {status: 'Invalid body format', details: '"weeks" must be larger than or equal to 1'}
+ });
+ });
+ it('rejects a condition parameter above maximum range', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {condition: {material: 'copper', weeks: 10.5, condition_template: '200000000000000000000001'}},
+ res: {status: 'Invalid body format', details: '"weeks" must be less than or equal to 10'}
+ });
+ });
+ it('rejects an invalid condition template', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {condition: {material: 'copper', weeks: 3, condition_template: '200000000000h00000000001'}},
+ res: {status: 'Condition template not available'}
+ });
+ });
+ it('rejects an unknown condition template', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {condition: {material: 'copper', weeks: 3, condition_template: '000000000000000000000001'}},
+ res: {status: 'Condition template not available'}
+ });
+ });
+ it('allows keeping an empty condition empty', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000006',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {condition: {}},
+ res: {_id: '400000000000000000000006', number: 'Rng36', type: 'granulate', color: 'black', batch: '', condition: {}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'}
+ });
+ });
+ it('rejects an old version of a condition template', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {condition: {p1: 36, condition_template: '200000000000000000000004'}},
+ res: {status: 'Old template version not allowed'}
+ });
+ });
+ it('allows keeping an old version of a condition template', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000004',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {condition: {p1: 36, condition_template: '200000000000000000000004'}},
+ res: {_id: '400000000000000000000004', number: '32', type: 'granulate', color: 'black', batch: '1653000308', condition: {p1: 36, condition_template: '200000000000000000000004'}, material_id: '100000000000000000000005', note_id: '500000000000000000000003', user_id: '000000000000000000000003', added: '2004-01-10T13:37:04.000Z'}
+ });
+ });
+ it('rejects an changing back to an empty condition', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {condition: {}},
+ res: {status: 'Condition template not available'}
+ });
+ });
+ it('rejects editing a deleted sample', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000005',
+ auth: {basic: 'admin'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {key: 'janedoe'},
+ httpStatus: 401,
+ req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ });
+ });
+ it('rejects changes for samples from another user for a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000003',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('accepts changes for samples from another user for a maintain/admin user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {},
+ res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', condition: {condition_template: '200000000000000000000001', material: 'copper', weeks: 3}, material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002', added: '2004-01-10T13:37:04.000Z'}
+ });
+ });
+ it('rejects requests from a read user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'user'},
+ httpStatus: 403,
+ req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ });
+ });
+ it('returns 404 for an unknown sample', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/000000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404,
+ req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}
+ });
+ })
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/400000000000000000000001',
+ httpStatus: 401,
+ req: {type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ });
+ });
+ });
+
+ describe('DELETE /sample/{id}', () => {
+ it('sets the status to deleted', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({status: 'OK'});
+ SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => {
+ if (err) return done(err);
+ should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v');
+ should(data).have.property('_id');
+ should(data).have.property('number', '1');
+ should(data).have.property('color', 'black');
+ should(data).have.property('type', 'granulate');
+ should(data).have.property('batch', '');
+ should(data.condition).have.property('material', 'copper');
+ should(data.condition).have.property('weeks', 3);
+ should(data.condition.condition_template.toString()).be.eql('200000000000000000000001');
+ should(data.material_id.toString()).be.eql('100000000000000000000004');
+ should(data.user_id.toString()).be.eql('000000000000000000000002');
+ should(data).have.property('status',globals.status.deleted);
+ should(data).have.property('note_id', null);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ log: {
+ collection: 'samples',
+ skip: 1,
+ dataAdd: {status: -1}
+ }
+ });
+ });
+ it('keeps the notes of the sample', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/sample/400000000000000000000002',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({status: 'OK'});
+ NoteModel.findById('500000000000000000000001').lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.only.keys('_id', 'comment', 'sample_references', '__v');
+ should(data).have.property('comment', 'Stoff gesperrt');
+ should(data).have.property('sample_references').with.lengthOf(0);
+ done();
+ });
+ });
+ });
+ it('adjusts the note_fields correctly', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/sample/400000000000000000000004',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({status: 'OK'});
+ NoteFieldModel.findOne({name: 'not allowed for new applications'}).lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.property('qty', 1);
+ NoteFieldModel.findOne({name: 'another_field'}).lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).be.null();
+ done();
+ });
+ });
+ });
+ });
+ it('keeps references to this sample', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/sample/400000000000000000000003',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({status: 'OK'});
+ setTimeout(() => { // background action takes some time before we can check
+ NoteModel.findById('500000000000000000000003').lean().exec((err, data: any) => {
+ if (err) return done(err);
+ should(data).have.property('sample_references').with.lengthOf(1);
+ should(data.sample_references[0].sample_id.toString()).be.eql('400000000000000000000003');
+ should(data.sample_references[0]).have.property('relation', 'part to sample');
+ done();
+ });
+ }, 100);
+
+ });
+ });
+ it('lets admin/maintain users delete samples of other users', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({status: 'OK'});
+ SampleModel.findById('400000000000000000000001').lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.property('status',globals.status.deleted);
+ done();
+ });
+ });
+ });
+ it('deletes associated measurements', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/sample/400000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({status: 'OK'});
+ MeasurementModel.find({sample_id: mongoose.Types.ObjectId('400000000000000000000001')}).lean().exec((err, data: any) => {
+ if (err) return done(err);
+ should(data).matchEach(sample => {
+ should(sample).have.property('status', -1);
+ });
+ done();
+ });
+ });
+ });
+ it('rejects deleting samples of other users for write users', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/sample/400000000000000000000004',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403
+ });
+ });
+ it('rejects an invalid id', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/sample/400000000000h00000000004',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404
+ });
+ });
+ it('rejects requests from a read user', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/sample/400000000000000000000004',
+ auth: {basic: 'user'},
+ httpStatus: 403
+ });
+ });
+ it('returns 404 for an unknown id', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/sample/000000000000000000000004',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/sample/400000000000000000000001',
+ auth: {key: 'janedoe'},
+ httpStatus: 401
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/sample/400000000000000000000001',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('PUT /sample/restore/{id}', () => {
+ it('sets the status', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/restore/400000000000000000000005',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).be.eql({status: 'OK'});
+ SampleModel.findById('400000000000000000000005').lean().exec((err, data: any) => {
+ if (err) return done(err);
+ should(data).have.property('status',globals.status.new);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/restore/400000000000000000000005',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {},
+ log: {
+ collection: 'samples',
+ dataAdd: {
+ group_id: '900000000000000000000002',
+ supplier_id: '110000000000000000000002',
+ status: 0
+ },
+ dataIgn: ['group_id', 'supplier_id']
+ }
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/restore/400000000000000000000005',
+ auth: {key: 'admin'},
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ it('rejects a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/restore/400000000000000000000005',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('returns 404 for an unknown sample', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/restore/000000000000000000000005',
+ auth: {basic: 'admin'},
+ httpStatus: 404,
+ req: {}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/restore/400000000000000000000005',
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ });
+
+ describe('PUT /sample/validate/{id}', () => {
+ it('sets the status', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/validate/400000000000000000000003',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).be.eql({status: 'OK'});
+ SampleModel.findById('400000000000000000000003').lean().exec((err, data: any) => {
+ if (err) return done(err);
+ should(data).have.property('status',globals.status.validated);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/validate/400000000000000000000003',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {},
+ log: {
+ collection: 'samples',
+ dataAdd: {
+ group_id: '900000000000000000000002',
+ supplier_id: '110000000000000000000002',
+ status: 10
+ },
+ dataIgn: ['group_id', 'supplier_id']
+ }
+ });
+ });
+ it('rejects validating a sample without condition', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/validate/400000000000000000000006',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {},
+ res: {status: 'Sample without condition cannot be valid'}
+ });
+ });
+ it('rejects validating a sample without measurements', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/validate/400000000000000000000004',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {},
+ res: {status: 'Sample without measurements cannot be valid'}
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/validate/400000000000000000000003',
+ auth: {key: 'admin'},
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ it('rejects a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/validate/400000000000000000000003',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('returns 404 for an unknown sample', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/validate/000000000000000000000003',
+ auth: {basic: 'admin'},
+ httpStatus: 404,
+ req: {}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/sample/validate/400000000000000000000003',
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ });
+
+ describe('POST /sample/new', () => {
+ it('returns the right sample', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('number', 'Rng37');
+ should(res.body).have.property('color', 'black');
+ should(res.body).have.property('type', 'granulate');
+ should(res.body).have.property('batch', '1560237365');
+ should(res.body).have.property('condition', {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'});
+ should(res.body).have.property('material_id', '100000000000000000000001');
+ should(res.body).have.property('note_id').be.type('string');
+ should(res.body).have.property('user_id', '000000000000000000000002');
+ should(res.body).have.property('added').be.type('string');
+ should(new Date().getTime() - new Date(res.body.added).getTime()).be.below(1000);
+ done();
+ });
+ });
+ it('stores the sample', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}
+ }).end(err => {
+ if (err) return done (err);
+ SampleModel.find({number: 'Rng37'}).lean().exec((err, data: any) => {
+ if (err) return done (err);
+ should(data).have.lengthOf(1);
+ should(data[0]).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'status', '__v');
+ should(data[0]).have.property('_id');
+ should(data[0]).have.property('number', 'Rng37');
+ should(data[0]).have.property('color', 'black');
+ should(data[0]).have.property('type', 'granulate');
+ should(data[0]).have.property('batch', '1560237365');
+ should(data[0]).have.property('condition', {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'});
+ should(data[0].material_id.toString()).be.eql('100000000000000000000001');
+ should(data[0].user_id.toString()).be.eql('000000000000000000000002');
+ should(data[0]).have.property('status',globals.status.new);
+ should(data[0]).have.property('note_id');
+ NoteModel.findById(data[0].note_id).lean().exec((err, data: any) => {
+ if (err) return done (err);
+ should(data).have.property('_id');
+ should(data).have.property('comment', 'Testcomment');
+ should(data).have.property('sample_references');
+ should(data.sample_references).have.lengthOf(1);
+ should(data.sample_references[0].sample_id.toString()).be.eql('400000000000000000000003');
+ should(data.sample_references[0]).have.property('relation', 'part to this sample');
+ done();
+ });
+ })
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '200000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ log: {
+ collection: 'samples',
+ dataAdd: {
+ number: 'Rng37',
+ user_id: '000000000000000000000002',
+ status: 0
+ },
+ dataIgn: ['notes', 'note_id']
+ }
+ });
+ });
+ it('stores the custom fields', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [], custom_fields: {field1: 'a', field2: 'b', 'not allowed for new applications': true}}}
+ }).end((err, res) => {
+ if (err) return done (err);
+ NoteModel.findById(res.body.note_id).lean().exec((err, data: any) => {
+ if (err) return done(err);
+ should(data).have.property('_id');
+ should(data).have.property('comment', 'Testcomment');
+ should(data).have.property('sample_references').have.lengthOf(0);
+ should(data).have.property('custom_fields');
+ should(data.custom_fields).have.property('field1', 'a');
+ should(data.custom_fields).have.property('field2', 'b');
+ should(data.custom_fields).have.property('not allowed for new applications', true);
+ NoteFieldModel.find({name: 'field1'}).lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ should(data[0]).have.property('qty', 1);
+ NoteFieldModel.find({name: 'field2'}).lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ should(data[0]).have.property('qty', 1);
+ NoteFieldModel.find({name: 'not allowed for new applications'}).lean().exec((err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ should(data[0]).have.property('qty', 3);
+ done();
+ });
+ });
+ });
+ });
+ });
+ });
+ it('stores a new sample location as 1', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'johnnydoe'},
+ httpStatus: 200,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('number', 'Fe1');
+ should(res.body).have.property('color', 'black');
+ should(res.body).have.property('type', 'granulate');
+ should(res.body).have.property('batch', '1560237365');
+ should(res.body).have.property('material_id', '100000000000000000000001');
+ should(res.body).have.property('note_id').be.type('string');
+ should(res.body).have.property('user_id', '000000000000000000000004');
+ should(res.body).have.property('added').be.type('string');
+ should(new Date().getTime() - new Date(res.body.added).getTime()).be.below(1500);
+ done();
+ });
+ });
+ it('accepts a sample without condition', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('number', 'Rng37');
+ should(res.body).have.property('color', 'black');
+ should(res.body).have.property('type', 'granulate');
+ should(res.body).have.property('batch', '1560237365');
+ should(res.body).have.property('condition', {});
+ should(res.body).have.property('material_id', '100000000000000000000001');
+ should(res.body).have.property('note_id').be.type('string');
+ should(res.body).have.property('user_id', '000000000000000000000002');
+ should(res.body).have.property('added').be.type('string');
+ should(new Date().getTime() - new Date(res.body.added).getTime()).be.below(1000);
+ done();
+ });
+ });
+ it('rejects a color not defined for the material', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'green', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Color not available for material'}
+ });
+ });
+ it('rejects an unknown material id', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '000000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Material not available'}
+ });
+ });
+ it('rejects a sample number for a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {number: 'Rng34', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Invalid body format', details: '"number" is not allowed'}
+ });
+ });
+ it('allows a sample number for an admin user', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {number: 'Rng34', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'condition', 'material_id', 'note_id', 'user_id', 'added');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('number', 'Rng34');
+ should(res.body).have.property('color', 'black');
+ should(res.body).have.property('type', 'granulate');
+ should(res.body).have.property('batch', '1560237365');
+ should(res.body).have.property('condition', {});
+ should(res.body).have.property('material_id', '100000000000000000000001');
+ should(res.body).have.property('note_id').be.type('string');
+ should(res.body).have.property('user_id', '000000000000000000000003');
+ should(res.body).have.property('added').be.type('string');
+ should(new Date().getTime() - new Date(res.body.added).getTime()).be.below(1000);
+ done();
+ });
+ });
+ it('rejects an existing sample number for an admin user', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {number: 'Rng33', color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Sample number already taken'}
+ });
+ });
+ it('rejects an invalid sample reference', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '000000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Sample reference not available'}
+ });
+ });
+ it('rejects an invalid condition_template id', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '20000h000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Condition template not available'}
+ });
+ });
+ it('rejects a not existing condition_template id', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, condition_template: '000000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Condition template not available'}
+ });
+ });
+ it('rejects not specified condition parameters', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3, xxx: 23, condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Condition template not available'}
+ });
+ });
+ it('rejects missing condition parameters', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Condition template not available'}
+ });
+ });
+ it('rejects condition parameters not in the value range', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'xxx', weeks: 3, condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Condition template not available'}
+ });
+ });
+ it('rejects a condition parameter below minimum range', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 0, condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Condition template not available'}
+ });
+ });
+ it('rejects a condition parameter above maximum range', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 11, condition_template: '20000000000000000000001'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Condition template not available'}
+ });
+ });
+ it('rejects a condition without condition template', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {material: 'copper', weeks: 3}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Condition template not available'}
+ });
+ });
+ it('rejects an old version of a condition template', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', condition: {p1: 36, condition_template: '200000000000000000000004'}, material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Old template version not allowed'}
+ });
+ });
+ it('rejects a missing color', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Invalid body format', details: '"color" is required'}
+ });
+ });
+ it('rejects a missing type', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Invalid body format', details: '"type" is required'}
+ });
+ });
+ it('rejects a missing batch', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Invalid body format', details: '"batch" is required'}
+ });
+ });
+ it('rejects a missing material id', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Invalid body format', details: '"material_id" is required'}
+ });
+ });
+ it('rejects an invalid material id', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}},
+ res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'}
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {key: 'janedoe'},
+ httpStatus: 401,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}
+ });
+ });
+ it('rejects requests from a read user', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ auth: {basic: 'user'},
+ httpStatus: 403,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/sample/new',
+ httpStatus: 401,
+ req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{sample_id: '400000000000000000000003', relation: 'part to this sample'}]}}
+ });
+ });
+ });
+
+ describe('GET /sample/notes/fields', () => {
+ it('returns all fields', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/sample/notes/fields',
+ auth: {basic: 'user'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.note_fields.length);
+ should(res.body).matchEach(material => {
+ should(material).have.only.keys('name', 'qty');
+ should(material).have.property('qty').be.type('number');
+ });
+ done();
+ });
+ });
+ it('works with an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/sample/notes/fields',
+ auth: {key: 'user'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.note_fields.length);
+ should(res.body).matchEach(material => {
+ should(material).have.only.keys('name', 'qty');
+ should(material).have.property('qty').be.type('number');
+ });
+ done();
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/sample/notes/fields',
+ httpStatus: 401
+ });
+ });
+ });
+});
diff --git a/src/routes/sample.ts b/src/routes/sample.ts
new file mode 100644
index 0000000..91ada86
--- /dev/null
+++ b/src/routes/sample.ts
@@ -0,0 +1,780 @@
+import express from 'express';
+import _ from 'lodash';
+
+import SampleValidate from './validate/sample';
+import NoteFieldValidate from './validate/note_field';
+import res400 from './validate/res400';
+import SampleModel from '../models/sample'
+import MeasurementModel from '../models/measurement';
+import MeasurementTemplateModel from '../models/measurement_template';
+import MaterialModel from '../models/material';
+import NoteModel from '../models/note';
+import NoteFieldModel from '../models/note_field';
+import IdValidate from './validate/id';
+import mongoose from 'mongoose';
+import ConditionTemplateModel from '../models/condition_template';
+import ParametersValidate from './validate/parameters';
+import globals from '../globals';
+import db from '../db';
+import csv from '../helpers/csv';
+
+
+const router = express.Router();
+
+// TODO: check added filter
+// TODO: return total number of pages -> use facet
+// TODO: use query pointer
+// TODO: convert filter value to number according to table model
+// TODO: validation for filter parameters
+// TODO: location/device sort/filter
+router.get('/samples', async (req, res, next) => {
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return;
+
+ const {error, value: filters} = SampleValidate.query(req.query);
+ if (error) return res400(error, res);
+
+ // TODO: find a better place for these
+ const sampleKeys = ['_id', 'color', 'number', 'type', 'batch', 'added', 'condition', 'material_id', 'note_id', 'user_id'];
+
+ // evaluate sort parameter from 'color-asc' to ['color', 1]
+ filters.sort = filters.sort.split('-');
+ filters.sort[0] = filters.sort[0] === 'added' ? '_id' : filters.sort[0]; // route added sorting criteria to _id
+ filters.sort[1] = filters.sort[1] === 'desc' ? -1 : 1;
+ if (!filters['to-page']) { // set to-page default
+ filters['to-page'] = 0;
+ }
+ const addedFilter = filters.filters.find(e => e.field === 'added');
+ if (addedFilter) { // convert added filter to object id
+ filters.filters.splice(filters.filters.findIndex(e => e.field === 'added'), 1);
+ if (addedFilter.mode === 'in') {
+ const v = []; // query value
+ addedFilter.values.forEach(value => {
+ const date = [new Date(value).setHours(0,0,0,0), new Date(value).setHours(23,59,59,999)];
+ v.push({$and: [{ _id: { '$gte': dateToOId(date[0])}}, { _id: { '$lte': dateToOId(date[1])}}]});
+ });
+ filters.filters.push({mode: 'or', field: '_id', values: v});
+ }
+ else if (addedFilter.mode === 'nin') {
+ addedFilter.values = addedFilter.values.sort();
+ const v = []; // query value
+
+ for (let i = 0; i <= addedFilter.values.length; i ++) {
+ v[i] = {$and: []};
+ if (i > 0) {
+ const date = new Date(addedFilter.values[i - 1]).setHours(23,59,59,999);
+ v[i].$and.push({ _id: { '$gt': dateToOId(date)}}) ;
+ }
+ if (i < addedFilter.values.length) {
+ const date = new Date(addedFilter.values[i]).setHours(0,0,0,0);
+ v[i].$and.push({ _id: { '$lt': dateToOId(date)}}) ;
+ }
+ }
+ filters.filters.push({mode: 'or', field: '_id', values: v});
+ }
+ else {
+ // start and end of day
+ const date = [new Date(addedFilter.values[0]).setHours(0,0,0,0), new Date(addedFilter.values[0]).setHours(23,59,59,999)];
+ if (addedFilter.mode === 'lt') { // lt start
+ filters.filters.push({mode: 'lt', field: '_id', values: [dateToOId(date[0])]});
+ }
+ if (addedFilter.mode === 'eq' || addedFilter.mode === 'lte') { // lte end
+ filters.filters.push({mode: 'lte', field: '_id', values: [dateToOId(date[1])]});
+ }
+ if (addedFilter.mode === 'gt') { // gt end
+ filters.filters.push({mode: 'gt', field: '_id', values: [dateToOId(date[1])]});
+ }
+ if (addedFilter.mode === 'eq' || addedFilter.mode === 'gte') { // gte start
+ filters.filters.push({mode: 'gte', field: '_id', values: [dateToOId(date[0])]});
+ }
+ if (addedFilter.mode === 'ne') {
+ filters.filters.push({mode: 'or', field: '_id', values: [{ _id: { '$lt': dateToOId(date[0])}}, { _id: { '$gt': dateToOId(date[1])}}]});
+ }
+ }
+ }
+
+ const sortFilterKeys = filters.filters.map(e => e.field);
+
+ let collection;
+ const query = [];
+ let queryPtr = query;
+ queryPtr.push({$match: {$and: []}});
+
+ if (filters.sort[0].indexOf('measurements.') >= 0) { // sorting with measurements as starting collection
+ collection = MeasurementModel;
+ const [,measurementName, measurementParam] = filters.sort[0].split('.');
+ const measurementTemplate = await MeasurementTemplateModel.findOne({name: measurementName}).lean().exec().catch(err => {next(err);});
+ if (measurementTemplate instanceof Error) return;
+ if (!measurementTemplate) {
+ return res.status(400).json({status: 'Invalid body format', details: filters.sort[0] + ' not found'});
+ }
+ let sortStartValue = null;
+ if (filters['from-id']) { // from-id specified, fetch values for sorting
+ const fromSample = await MeasurementModel.findOne({sample_id: mongoose.Types.ObjectId(filters['from-id'])}).lean().exec().catch(err => {next(err);}); // TODO: what if more than one measurement for sample?
+ if (fromSample instanceof Error) return;
+ if (!fromSample) {
+ return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'});
+ }
+ sortStartValue = fromSample.values[measurementParam];
+ }
+ queryPtr[0].$match.$and.push({measurement_template: mongoose.Types.ObjectId(measurementTemplate._id)}); // find measurements to sort
+ if (filters.filters.find(e => e.field === filters.sort[0])) { // sorted measurement should also be filtered
+ queryPtr[0].$match.$and.push(...filterQueries(filters.filters.filter(e => e.field === filters.sort[0]).map(e => {e.field = 'values.' + e.field.split('.')[2]; return e; })));
+ }
+ queryPtr.push(
+ ...sortQuery(filters, ['values.' + measurementParam, 'sample_id'], sortStartValue), // sort measurements
+ {$replaceRoot: {newRoot: {measurement: '$$ROOT'}}}, // fetch samples and restructure them to fit sample structure
+ {$lookup: {from: 'samples', localField: 'measurement.sample_id', foreignField: '_id', as: 'sample'}},
+ {$match: statusQuery(filters, 'sample.status')}, // filter out wrong status once samples were added
+ {$addFields: {['sample.' + measurementName]: '$measurement.values'}}, // more restructuring
+ {$replaceRoot: {newRoot: {$mergeObjects: [{$arrayElemAt: ['$sample', 0]}, {}]}}}
+ );
+ }
+ else { // sorting with samples as starting collection
+ collection = SampleModel;
+ queryPtr[0].$match.$and.push(statusQuery(filters, 'status'));
+
+ if (sampleKeys.indexOf(filters.sort[0]) >= 0) { // sorting for sample keys
+ let sortStartValue = null;
+ if (filters['from-id']) { // from-id specified
+ const fromSample = await SampleModel.findById(filters['from-id']).lean().exec().catch(err => {
+ next(err);
+ });
+ if (fromSample instanceof Error) return;
+ if (!fromSample) {
+ return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'});
+ }
+ sortStartValue = fromSample[filters.sort[0]];
+ }
+ queryPtr.push(...sortQuery(filters, [filters.sort[0], '_id'], sortStartValue));
+ }
+ else { // add sort key to list to add field later
+ sortFilterKeys.push(filters.sort[0]);
+ }
+ }
+
+ addFilterQueries(queryPtr, filters.filters.filter(e => sampleKeys.indexOf(e.field) >= 0)); // sample filters
+
+ let materialQuery = []; // put material query together separate first to reuse for first-id
+ let materialAdded = false;
+ if (sortFilterKeys.find(e => /material\./.test(e))) { // add material fields
+ materialAdded = true;
+ materialQuery.push( // add material properties
+ {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}}, // TODO: project out unnecessary fields
+ {$addFields: {material: {$arrayElemAt: ['$material', 0]}}}
+ );
+ const baseMFilters = sortFilterKeys.filter(e => /material\./.test(e)).filter(e => ['material.supplier', 'material.group', 'material.number'].indexOf(e) < 0);
+ addFilterQueries(materialQuery, filters.filters.filter(e => baseMFilters.indexOf(e.field) >= 0)); // base material filters
+ if (sortFilterKeys.find(e => e === 'material.supplier')) { // add supplier if needed
+ materialQuery.push(
+ {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}},
+ {$addFields: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}}
+ );
+ }
+ if (sortFilterKeys.find(e => e === 'material.group')) { // add group if needed
+ materialQuery.push(
+ {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }},
+ {$addFields: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}}
+ );
+ }
+ if (sortFilterKeys.find(e => e === 'material.number')) { // add material number if needed
+ materialQuery.push(
+ {$addFields: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}}
+ );
+ }
+ const specialMFilters = sortFilterKeys.filter(e => /material\./.test(e)).filter(e => ['material.supplier', 'material.group', 'material.number'].indexOf(e) >= 0);
+ addFilterQueries(materialQuery, filters.filters.filter(e => specialMFilters.indexOf(e.field) >= 0)); // base material filters
+ queryPtr.push(...materialQuery);
+ if (/material\./.test(filters.sort[0])) { // sort by material key
+ let sortStartValue = null;
+ if (filters['from-id']) { // from-id specified
+ const fromSample = await SampleModel.aggregate([{$match: {_id: mongoose.Types.ObjectId(filters['from-id'])}}, ...materialQuery]).exec().catch(err => {next(err);});
+ if (fromSample instanceof Error) return;
+ if (!fromSample) {
+ return res.status(400).json({status: 'Invalid body format', details: 'from-id not found'});
+ }
+ sortStartValue = fromSample[filters.sort[0]];
+ }
+ queryPtr.push(...sortQuery(filters, [filters.sort[0], '_id'], sortStartValue));
+ }
+ }
+
+ const measurementFilterFields = _.uniq(sortFilterKeys.filter(e => /measurements\./.test(e)).map(e => e.split('.')[1])); // filter measurement names and remove duplicates from parameters
+ if (sortFilterKeys.find(e => /measurements\./.test(e))) { // add measurement fields
+ const measurementTemplates = await MeasurementTemplateModel.find({name: {$in: measurementFilterFields}}).lean().exec().catch(err => {next(err);});
+ if (measurementTemplates instanceof Error) return;
+ if (measurementTemplates.length < measurementFilterFields.length) {
+ return res.status(400).json({status: 'Invalid body format', details: 'Measurement key not found'});
+ }
+ queryPtr.push({$lookup: {
+ from: 'measurements', let: {sId: '$_id'},
+ pipeline: [{$match: {$expr: {$and: [{$eq: ['$sample_id', '$$sId']}, {$in: ['$measurement_template', measurementTemplates.map(e => mongoose.Types.ObjectId(e._id))]}]}}}],
+ as: 'measurements'
+ }});
+ measurementTemplates.forEach(template => {
+ queryPtr.push({$addFields: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values
+ vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}},
+ in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']}
+ }}}}, {$addFields: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', template.parameters.reduce((s, e) => {s[e.name] = null; return s;}, {})]}}});
+ });
+ addFilterQueries(queryPtr, filters.filters
+ .filter(e => sortFilterKeys.filter(e => /measurements\./.test(e)).indexOf(e.field) >= 0)
+ .map(e => {e.field = e.field.replace('measurements.', ''); return e; })
+ ); // measurement filters
+ }
+
+ if (!filters.fields.find(e => /spectrum\./.test(e)) && !filters['from-id']) { // count total number of items before $skip and $limit, only works when from-id is not specified and spectra are not included
+ queryPtr.push({$facet: {count: [{$count: 'count'}], samples: []}});
+ queryPtr = queryPtr[queryPtr.length - 1].$facet.samples; // add rest of aggregation pipeline into $facet
+ }
+
+ // paging
+ if (filters['to-page']) {
+ queryPtr.push({$skip: Math.abs(filters['to-page'] + Number(filters['to-page'] < 0)) * filters['page-size'] + Number(filters['to-page'] < 0)}) // number to skip, if going back pages, one page has to be skipped less but on sample more
+ }
+ if (filters['page-size']) {
+ queryPtr.push({$limit: filters['page-size']});
+ }
+
+ const fieldsToAdd = filters.fields.filter(e => // fields to add
+ sortFilterKeys.indexOf(e) < 0 // field was not in filter
+ && e !== filters.sort[0] // field was not in sort
+ );
+
+ if (fieldsToAdd.find(e => /material\./.test(e)) && !materialAdded) { // add material, was not added already
+ queryPtr.push(
+ {$lookup: {from: 'materials', localField: 'material_id', foreignField: '_id', as: 'material'}},
+ {$addFields: {material: { $arrayElemAt: ['$material', 0]}}}
+ );
+ }
+ if (fieldsToAdd.indexOf('material.supplier') >= 0) { // add supplier if needed
+ queryPtr.push(
+ {$lookup: { from: 'material_suppliers', localField: 'material.supplier_id', foreignField: '_id', as: 'material.supplier'}},
+ {$addFields: {'material.supplier': {$arrayElemAt: ['$material.supplier.name', 0]}}}
+ );
+ }
+ if (fieldsToAdd.indexOf('material.group') >= 0) { // add group if needed
+ queryPtr.push(
+ {$lookup: { from: 'material_groups', localField: 'material.group_id', foreignField: '_id', as: 'material.group' }},
+ {$addFields: {'material.group': { $arrayElemAt: ['$material.group.name', 0]}}}
+ );
+ }
+ if (fieldsToAdd.indexOf('material.number') >= 0) { // add material number if needed
+ queryPtr.push(
+ {$addFields: {'material.number': { $arrayElemAt: ['$material.numbers.number', {$indexOfArray: ['$material.numbers.color', '$color']}]}}}
+ );
+ }
+
+ let measurementFieldsFields: string[] = _.uniq(fieldsToAdd.filter(e => /measurements\./.test(e)).map(e => e.split('.')[1])); // filter measurement names and remove duplicates from parameters
+ if (fieldsToAdd.find(e => /measurements\./.test(e))) { // add measurement fields
+ const measurementTemplates = await MeasurementTemplateModel.find({name: {$in: measurementFieldsFields}}).lean().exec().catch(err => {next(err);});
+ if (measurementTemplates instanceof Error) return;
+ if (measurementTemplates.length < measurementFieldsFields.length) {
+ return res.status(400).json({status: 'Invalid body format', details: 'Measurement key not found'});
+ }
+ if (fieldsToAdd.find(e => /spectrum\./.test(e))) { // use different lookup methods with and without spectrum for the best performance
+ queryPtr.push({$lookup: {from: 'measurements', localField: '_id', foreignField: 'sample_id', as: 'measurements'}});
+ }
+ else {
+ queryPtr.push({$lookup: {
+ from: 'measurements', let: {sId: '$_id'},
+ pipeline: [{$match: {$expr: {$and: [{$eq: ['$sample_id', '$$sId']}, {$in: ['$measurement_template', measurementTemplates.map(e => mongoose.Types.ObjectId(e._id))]}]}}}],
+ as: 'measurements'
+ }});
+ }
+ measurementTemplates.filter(e => e.name !== 'spectrum').forEach(template => { // TODO: hard coded dpt for special treatment, change later
+ queryPtr.push({$addFields: {[template.name]: {$let: { // add measurements as property [template.name], if one result, array is reduced to direct values
+ vars: {arr: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', mongoose.Types.ObjectId(template._id)]}}}},
+ in:{$cond: [{$lte: [{$size: '$$arr'}, 1]}, {$arrayElemAt: ['$$arr', 0]}, '$$arr']}
+ }}}}, {$addFields: {[template.name]: {$cond: ['$' + template.name + '.values', '$' + template.name + '.values', template.parameters.reduce((s, e) => {s[e.name] = null; return s;}, {})]}}});
+ });
+ if (measurementFieldsFields.find(e => e === 'spectrum')) { // TODO: remove hardcoded as well
+ queryPtr.push(
+ {$addFields: {spectrum: {$filter: {input: '$measurements', cond: {$eq: ['$$this.measurement_template', measurementTemplates.filter(e => e.name === 'spectrum')[0]._id]}}}}},
+ {$addFields: {spectrum: '$spectrum.values'}},
+ {$unwind: '$spectrum'}
+ );
+ }
+ // queryPtr.push({$unset: 'measurements'});
+ queryPtr.push({$project: {measurements: 0}});
+ }
+
+ const projection = filters.fields.map(e => e.replace('measurements.', '')).reduce((s, e) => {s[e] = true; return s; }, {});
+ if (filters.fields.indexOf('added') >= 0) { // add added date
+ // projection.added = {$toDate: '$_id'};
+ // projection.added = { $convert: { input: '$_id', to: "date" } } // TODO: upgrade MongoDB version or find alternative
+ }
+ if (filters.fields.indexOf('_id') < 0 && filters.fields.indexOf('added') < 0) { // disable _id explicitly
+ projection._id = false;
+ }
+ queryPtr.push({$project: projection});
+
+ if (!fieldsToAdd.find(e => /spectrum\./.test(e))) { // use streaming when including spectrum files
+ collection.aggregate(query).exec((err, data) => {
+ if (err) return next(err);
+ if (data[0].count) {
+ res.header('x-total-items', data[0].count.length > 0 ? data[0].count[0].count : 0);
+ res.header('Access-Control-Expose-Headers', 'x-total-items');
+ data = data[0].samples;
+ }
+ if (filters.fields.indexOf('added') >= 0) { // add added date
+ data.map(e => {
+ e.added = e._id.getTimestamp();
+ if (filters.fields.indexOf('_id') < 0) {
+ delete e._id;
+ }
+ return e
+ });
+ }
+ if (filters['to-page'] < 0) {
+ data.reverse();
+ }
+ const measurementFields = _.uniq([filters.sort[0].split('.')[1], ...measurementFilterFields, ...measurementFieldsFields]);
+ if (filters.csv) { // output as csv
+ csv(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields))), (err, data) => {
+ if (err) return next(err);
+ res.set('Content-Type', 'text/csv');
+ res.send(data);
+ });
+ }
+ else {
+ res.json(_.compact(data.map(e => SampleValidate.output(e, 'refs', measurementFields)))); // validate all and filter null values from validation errors
+ }
+ });
+ }
+ else {
+ res.writeHead(200, {'Content-Type': 'application/json; charset=utf-8'});
+ res.write('[');
+ let count = 0;
+ const stream = collection.aggregate(query).cursor().exec();
+ stream.on('data', data => {
+ if (filters.fields.indexOf('added') >= 0) { // add added date
+ data.added = data._id.getTimestamp();
+ if (filters.fields.indexOf('_id') < 0) {
+ delete data._id;
+ }
+ }
+ res.write((count === 0 ? '' : ',\n') + JSON.stringify(data)); count ++;
+ });
+ stream.on('close', () => {
+ res.write(']');
+ res.end();
+ });
+ }
+});
+
+router.get('/samples/:state(new|deleted)', (req, res, next) => {
+ if (!req.auth(res, ['maintain', 'admin'], 'basic')) return;
+
+ SampleModel.find({status: globals.status[req.params.state]}).lean().exec((err, data) => {
+ if (err) return next(err);
+ res.json(_.compact(data.map(e => SampleValidate.output(e)))); // validate all and filter null values from validation errors
+ });
+});
+
+router.get('/samples/count', (req, res, next) => {
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return;
+
+ SampleModel.estimatedDocumentCount((err, data) => {
+ if (err) return next(err);
+ res.json({count: data});
+ });
+});
+
+router.get('/sample/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return;
+
+ SampleModel.findById(req.params.id).populate('material_id').populate('user_id', 'name').populate('note_id').exec(async (err, sampleData: any) => {
+ if (err) return next(err);
+
+ if (sampleData) {
+ await sampleData.populate('material_id.group_id').populate('material_id.supplier_id').execPopulate().catch(err => next(err));
+ if (sampleData instanceof Error) return;
+ sampleData = sampleData.toObject();
+
+ if (sampleData.status === globals.status.deleted && !req.auth(res, ['maintain', 'admin'], 'all')) return; // deleted samples only available for maintain/admin
+ sampleData.material = sampleData.material_id; // map data to right keys
+ sampleData.material.group = sampleData.material.group_id.name;
+ sampleData.material.supplier = sampleData.material.supplier_id.name;
+ sampleData.user = sampleData.user_id.name;
+ sampleData.notes = sampleData.note_id ? sampleData.note_id : {};
+ MeasurementModel.find({sample_id: mongoose.Types.ObjectId(req.params.id), status: {$ne: globals.status.deleted}}).lean().exec((err, data) => {
+ sampleData.measurements = data;
+ res.json(SampleValidate.output(sampleData, 'details'));
+ });
+ }
+ else {
+ res.status(404).json({status: 'Not found'});
+ }
+ });
+});
+
+router.put('/sample/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ const {error, value: sample} = SampleValidate.input(req.body, 'change');
+ if (error) return res400(error, res);
+
+ SampleModel.findById(req.params.id).lean().exec(async (err, sampleData: any) => { // check if id exists
+ if (err) return next(err);
+ if (!sampleData) {
+ return res.status(404).json({status: 'Not found'});
+ }
+ if (sampleData.status === globals.status.deleted) {
+ return res.status(403).json({status: 'Forbidden'});
+ }
+
+ // only maintain and admin are allowed to edit other user's data
+ if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return;
+
+ if (sample.hasOwnProperty('material_id')) {
+ if (!await materialCheck(sample, res, next)) return;
+ }
+ else if (sample.hasOwnProperty('color')) {
+ if (!await materialCheck(sample, res, next, sampleData.material_id)) return;
+ }
+
+ if (sample.hasOwnProperty('condition') && !(_.isEmpty(sample.condition) && _.isEmpty(sampleData.condition))) { // do not execute check if condition is and was empty
+ if (!await conditionCheck(sample.condition, 'change', res, next, sampleData.condition.condition_template.toString() !== sample.condition.condition_template)) return;
+ }
+
+ if (sample.hasOwnProperty('notes')) {
+ let newNotes = true;
+ if (sampleData.note_id !== null) { // old notes data exists
+ const data = await NoteModel.findById(sampleData.note_id).lean().exec().catch(err => {next(err);}) as any;
+ if (data instanceof Error) return;
+ newNotes = !_.isEqual(_.pick(IdValidate.stringify(data), _.keys(sample.notes)), sample.notes); // check if notes were changed
+ if (newNotes) {
+ if (data.hasOwnProperty('custom_fields')) { // update note_fields
+ customFieldsChange(Object.keys(data.custom_fields), -1, req);
+ }
+ await NoteModel.findByIdAndDelete(sampleData.note_id).log(req).lean().exec(err => { // delete old notes
+ if (err) return console.error(err);
+ });
+ }
+ }
+
+ if (_.keys(sample.notes).length > 0 && newNotes) { // save new notes
+ if (!await sampleRefCheck(sample, res, next)) return;
+ if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields
+ customFieldsChange(Object.keys(sample.notes.custom_fields), 1, req);
+ }
+ let data = await new NoteModel(sample.notes).save().catch(err => { return next(err)}); // save new notes
+ db.log(req, 'notes', {_id: data._id}, data.toObject());
+ delete sample.notes;
+ sample.note_id = data._id;
+ }
+ }
+
+ // check for changes
+ if (!_.isEqual(_.pick(IdValidate.stringify(sampleData), _.keys(sample)), _.omit(sample, ['notes']))) {
+ sample.status = globals.status.new;
+ }
+
+ await SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).log(req).lean().exec((err, data: any) => {
+ if (err) return next(err);
+ res.json(SampleValidate.output(data));
+ });
+
+ });
+});
+
+router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ SampleModel.findById(req.params.id).lean().exec(async (err, sampleData: any) => { // check if id exists
+ if (err) return next(err);
+ if (!sampleData) {
+ return res.status(404).json({status: 'Not found'});
+ }
+
+ // only maintain and admin are allowed to edit other user's data
+ if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return;
+
+ await SampleModel.findByIdAndUpdate(req.params.id, {status:globals.status.deleted}).log(req).lean().exec(err => { // set sample status
+ if (err) return next(err);
+
+ // set status of associated measurements also to deleted
+ MeasurementModel.updateMany({sample_id: mongoose.Types.ObjectId(req.params.id)}, {status: -1}).log(req).lean().exec(err => {
+ if (err) return next(err);
+
+ if (sampleData.note_id !== null) { // handle notes
+ NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => { // find notes to update note_fields
+ if (err) return next(err);
+ if (data.hasOwnProperty('custom_fields')) { // update note_fields
+ customFieldsChange(Object.keys(data.custom_fields), -1, req);
+ }
+ res.json({status: 'OK'});
+ });
+ }
+ else {
+ res.json({status: 'OK'});
+ }
+ });
+ });
+ });
+});
+
+router.put('/sample/restore/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['maintain', 'admin'], 'basic')) return;
+
+ SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.new}).log(req).lean().exec((err, data) => {
+ if (err) return next(err);
+
+ if (!data) {
+ return res.status(404).json({status: 'Not found'});
+ }
+ res.json({status: 'OK'});
+ });
+});
+
+router.put('/sample/validate/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['maintain', 'admin'], 'basic')) return;
+
+ SampleModel.findById(req.params.id).lean().exec((err, data: any) => {
+ if (err) return next(err);
+
+ if (!data) {
+ return res.status(404).json({status: 'Not found'});
+ }
+ if (Object.keys(data.condition).length === 0) {
+ return res.status(400).json({status: 'Sample without condition cannot be valid'});
+ }
+
+ MeasurementModel.find({sample_id: mongoose.Types.ObjectId(req.params.id)}).lean().exec((err, data) => {
+ if (err) return next(err);
+
+ if (data.length === 0) {
+ return res.status(400).json({status: 'Sample without measurements cannot be valid'});
+ }
+
+ SampleModel.findByIdAndUpdate(req.params.id, {status: globals.status.validated}).log(req).lean().exec(err => {
+ if (err) return next(err);
+ res.json({status: 'OK'});
+ });
+ });
+ });
+});
+
+router.post('/sample/new', async (req, res, next) => {
+ if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ if (!req.body.hasOwnProperty('condition')) { // add empty condition if not specified
+ req.body.condition = {};
+ }
+
+ const {error, value: sample} = SampleValidate.input(req.body, 'new' + (req.authDetails.level === 'admin' ? '-admin' : ''));
+ if (error) return res400(error, res);
+
+ if (!await materialCheck(sample, res, next)) return;
+ if (!await sampleRefCheck(sample, res, next)) return;
+
+ if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields
+ customFieldsChange(Object.keys(sample.notes.custom_fields), 1, req);
+ }
+
+ if (!_.isEmpty(sample.condition)) { // do not execute check if condition is empty
+ if (!await conditionCheck(sample.condition, 'change', res, next)) return;
+ }
+
+ sample.status = globals.status.new; // set status to new
+ if (sample.hasOwnProperty('number')) {
+ if (!await numberCheck(sample, res, next)) return;
+ }
+ else {
+ sample.number = await numberGenerate(sample, req, res, next);
+ }
+ if (!sample.number) return;
+
+ await new NoteModel(sample.notes).save((err, data) => { // save notes
+ if (err) return next(err);
+ db.log(req, 'notes', {_id: data._id}, data.toObject());
+ delete sample.notes;
+ sample.note_id = data._id;
+ sample.user_id = req.authDetails.id;
+
+ new SampleModel(sample).save((err, data) => {
+ if (err) return next(err);
+ db.log(req, 'samples', {_id: data._id}, data.toObject());
+ res.json(SampleValidate.output(data.toObject()));
+ });
+ });
+});
+
+router.get('/sample/notes/fields', (req, res, next) => {
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return;
+
+ NoteFieldModel.find({}).lean().exec((err, data) => {
+ if (err) return next(err);
+ res.json(_.compact(data.map(e => NoteFieldValidate.output(e)))); // validate all and filter null values from validation errors
+ })
+});
+
+
+module.exports = router;
+
+
+async function numberGenerate (sample, req, res, next) { // generate number in format Location32, returns false on error
+ const sampleData = await SampleModel
+ // .findOne({number: new RegExp('^' + req.authDetails.location + '[0-9]+$', 'm')})
+ // .sort({number: -1})
+ // .lean()
+ .aggregate([
+ {$match: {number: new RegExp('^' + 'Rng' + '[0-9]+$', 'm')}},
+ // {$addFields: {number2: {$toDecimal: {$arrayElemAt: [{$split: [{$arrayElemAt: [{$split: ['$number', 'Rng']}, 1]}, '_']}, 0]}}}}, // not working with MongoDb 3.6
+ {$addFields: {sortNumber: {$let: {
+ vars: {tmp: {$concat: ['000000000000000000000000000000', {$arrayElemAt: [{$split: [{$arrayElemAt: [{$split: ['$number', 'Rng']}, 1]}, '_']}, 0]}]}},
+ in: {$substrCP: ['$$tmp', {$subtract: [{$strLenCP: '$$tmp'}, 30]}, {$strLenCP: '$$tmp'}]}
+ }}}},
+ {$sort: {sortNumber: -1}},
+ {$limit: 1}
+ ])
+ .exec()
+ .catch(err => next(err));
+ if (sampleData instanceof Error) return false;
+ return req.authDetails.location + (sampleData[0] ? Number(sampleData[0].number.replace(/[^0-9]+/g, '')) + 1 : 1);
+}
+
+async function numberCheck(sample, res, next) {
+ const sampleData = await SampleModel.findOne({number: sample.number}).lean().exec().catch(err => {next(err); return false;});
+ if (sampleData) { // found entry with sample number
+ res.status(400).json({status: 'Sample number already taken'});
+ return false
+ }
+ return true;
+}
+
+async function materialCheck (sample, res, next, id = sample.material_id) { // validate material_id and color, returns false if invalid
+ const materialData = await MaterialModel.findById(id).lean().exec().catch(err => next(err)) as any;
+ if (materialData instanceof Error) return false;
+ if (!materialData) { // could not find material_id
+ res.status(400).json({status: 'Material not available'});
+ return false;
+ }
+ if (sample.hasOwnProperty('color') && sample.color !== '' && !materialData.numbers.find(e => e.color === sample.color)) { // color for material not specified
+ res.status(400).json({status: 'Color not available for material'});
+ return false;
+ }
+ return true;
+}
+
+async function conditionCheck (condition, param, res, next, checkVersion = true) { // validate treatment template, returns false if invalid, otherwise template data
+ if (!condition.condition_template || !IdValidate.valid(condition.condition_template)) { // template id not found
+ res.status(400).json({status: 'Condition template not available'});
+ return false;
+ }
+ const conditionData = await ConditionTemplateModel.findById(condition.condition_template).lean().exec().catch(err => next(err)) as any;
+ if (conditionData instanceof Error) return false;
+ if (!conditionData) { // template not found
+ res.status(400).json({status: 'Condition template not available'});
+ return false;
+ }
+
+ if (checkVersion) {
+ // get all template versions and check if given is latest
+ const conditionVersions = await ConditionTemplateModel.find({first_id: conditionData.first_id}).sort({version: -1}).lean().exec().catch(err => next(err)) as any;
+ if (conditionVersions instanceof Error) return false;
+ if (condition.condition_template !== conditionVersions[0]._id.toString()) { // template not latest
+ res.status(400).json({status: 'Old template version not allowed'});
+ return false;
+ }
+ }
+
+ // validate parameters
+ const {error, value: ignore} = ParametersValidate.input(_.omit(condition, 'condition_template'), conditionData.parameters, param);
+ if (error) {res400(error, res); return false;}
+ return conditionData;
+}
+
+function sampleRefCheck (sample, res, next) { // validate sample_references, resolves false for invalid reference
+ return new Promise(resolve => {
+ if (sample.notes.hasOwnProperty('sample_references') && sample.notes.sample_references.length > 0) { // there are sample_references
+ let referencesCount = sample.notes.sample_references.length; // count to keep track of running async operations
+
+ sample.notes.sample_references.forEach(reference => {
+ SampleModel.findById(reference.sample_id).lean().exec((err, data) => {
+ if (err) {next(err); resolve(false)}
+ if (!data) {
+ res.status(400).json({status: 'Sample reference not available'});
+ return resolve(false);
+ }
+ referencesCount --;
+ if (referencesCount <= 0) { // all async requests done
+ resolve(true);
+ }
+ });
+ });
+ }
+ else {
+ resolve(true);
+ }
+ });
+}
+
+function customFieldsChange (fields, amount, req) { // update custom_fields and respective quantities
+ fields.forEach(field => {
+ NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: amount}} as any, {new: true}).log(req).lean().exec((err, data: any) => { // check if field exists
+ if (err) return console.error(err);
+ if (!data) { // new field
+ new NoteFieldModel({name: field, qty: 1}).save((err, data) => {
+ if (err) return console.error(err);
+ db.log(req, 'note_fields', {_id: data._id}, data.toObject());
+ })
+ }
+ else if (data.qty <= 0) { // delete document if field is not used anymore
+ NoteFieldModel.findOneAndDelete({name: field}).log(req).lean().exec(err => {
+ if (err) return console.error(err);
+ });
+ }
+ });
+ });
+}
+
+function sortQuery(filters, sortKeys, sortStartValue) { // sortKeys = ['primary key', 'secondary key']
+ if (filters['from-id']) { // from-id specified
+ if ((filters['to-page'] === 0 && filters.sort[1] === 1) || (filters.sort[1] * filters['to-page'] > 0)) { // asc
+ return [{$match: {$or: [{[sortKeys[0]]: {$gt: sortStartValue}}, {$and: [{[sortKeys[0]]: sortStartValue}, {[sortKeys[1]]: {$gte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}},
+ {$sort: {[sortKeys[0]]: 1, _id: 1}}];
+ } else {
+ return [{$match: {$or: [{[sortKeys[0]]: {$lt: sortStartValue}}, {$and: [{[sortKeys[0]]: sortStartValue}, {[sortKeys[1]]: {$lte: new mongoose.Types.ObjectId(filters['from-id'])}}]}]}},
+ {$sort: {[sortKeys[0]]: -1, _id: -1}}];
+ }
+ } else { // sort from beginning
+ return [{$sort: {[sortKeys[0]]: filters.sort[1], [sortKeys[1]]: filters.sort[1]}}]; // set _id as secondary sort
+ }
+}
+
+function statusQuery(filters, field) {
+ if (filters.hasOwnProperty('status')) {
+ if(filters.status === 'all') {
+ return {$or: [{[field]: globals.status.validated}, {[field]: globals.status.new}]};
+ }
+ else {
+ return {[field]: globals.status[filters.status]};
+ }
+ }
+ else { // default
+ return {[field]: globals.status.validated};
+ }
+}
+
+function addFilterQueries (queryPtr, filters) { // returns array of match queries from given filters
+ if (filters.length) {
+ queryPtr.push({$match: {$and: filterQueries(filters)}});
+ }
+}
+
+function filterQueries (filters) {
+ console.log(filters);
+ return filters.map(e => {
+ if (e.mode === 'or') { // allow or queries (needed for $ne added)
+ return {['$' + e.mode]: e.values};
+ }
+ else {
+ return {[e.field]: {['$' + e.mode]: (e.mode.indexOf('in') >= 0 ? e.values : e.values[0])}}; // add filter criteria as {field: {$mode: value}}, only use first value when mode is not in/nin
+ }
+ });
+}
+
+function dateToOId (date) { // convert date to ObjectId
+ return mongoose.Types.ObjectId(Math.floor(date / 1000).toString(16) + '0000000000000000');
+}
\ No newline at end of file
diff --git a/src/routes/template.spec.ts b/src/routes/template.spec.ts
new file mode 100644
index 0000000..cd90108
--- /dev/null
+++ b/src/routes/template.spec.ts
@@ -0,0 +1,898 @@
+import should from 'should/as-function';
+import _ from 'lodash';
+import TemplateConditionModel from '../models/condition_template';
+import TemplateMeasurementModel from '../models/measurement_template';
+import TestHelper from "../test/helper";
+
+
+describe('/template', () => {
+ let server;
+ before(done => TestHelper.before(done));
+ beforeEach(done => server = TestHelper.beforeEach(server, done));
+ afterEach(done => TestHelper.afterEach(server, done));
+ after(done => TestHelper.after(done));
+
+ describe('/template/condition', () => {
+ describe('GET /template/conditions', () => {
+ it('returns all condition templates', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/conditions',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.condition_templates.length);
+ should(res.body).matchEach(condition => {
+ should(condition).have.only.keys('_id', 'name', 'version', 'parameters');
+ should(condition).have.property('_id').be.type('string');
+ should(condition).have.property('name').be.type('string');
+ should(condition).have.property('version').be.type('number');
+ should(condition.parameters).matchEach(number => {
+ should(number).have.only.keys('name', 'range');
+ should(number).have.property('name').be.type('string');
+ should(number).have.property('range').be.type('object');
+ });
+ });
+ done();
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/conditions',
+ auth: {key: 'janedoe'},
+ httpStatus: 401
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/conditions',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('GET /template/condition/{id}', () => {
+ it('returns the right condition template', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]}
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/condition/200000000000000000000001',
+ auth: {key: 'janedoe'},
+ httpStatus: 401
+ });
+ });
+ it('rejects an unknown id', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/condition/000000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/condition/200000000000000000000001',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('PUT /template/condition/{name}', () => {
+ it('returns the right condition template', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {},
+ res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]}
+ });
+ });
+ it('keeps unchanged properties', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'heat treatment', parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]},
+ res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]}
+ });
+ });
+ it('keeps only one unchanged property', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'heat treatment'},
+ res: {_id: '200000000000000000000001', name: 'heat treatment', version: 1, parameters: [{name: 'material', range: {values: ['copper', 'hot air']}}, {name: 'weeks', range: {min: 1, max: 10}}]}
+ });
+ });
+ it('changes the given properties', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ TemplateConditionModel.findById(res.body._id).lean().exec((err, data:any) => {
+ if (err) return done(err);
+ should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v');
+ should(data.first_id.toString()).be.eql('200000000000000000000001');
+ should(data).have.property('name', 'heat aging');
+ should(data).have.property('version', 2);
+ should(data).have.property('parameters').have.lengthOf(1);
+ should(data.parameters[0]).have.property('name', 'time');
+ should(data.parameters[0]).have.property('range');
+ should(data.parameters[0].range).have.property('min', 1);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]},
+ log: {
+ collection: 'condition_templates',
+ dataAdd: {
+ first_id: '200000000000000000000001',
+ version: 2
+ }
+ }
+ });
+ });
+ it('allows changing only one property', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'heat aging'}
+ }).end((err, res) => {
+ if (err) return done(err);
+ TemplateConditionModel.findById(res.body._id).lean().exec((err, data:any) => {
+ if (err) return done(err);
+ should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v');
+ should(data.first_id.toString()).be.eql('200000000000000000000001');
+ should(data).have.property('name', 'heat aging');
+ should(data).have.property('version', 2);
+ should(data).have.property('parameters').have.lengthOf(2);
+ should(data.parameters[0]).have.property('name', 'material');
+ should(data.parameters[1]).have.property('name', 'weeks');
+ done();
+ });
+ });
+ });
+ it('supports values ranges', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {parameters: [{name: 'time', range: {values: [1, 2, 5]}}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, parameters: [{name: 'time', range: {values: [1, 2, 5]}}]});
+ done();
+ });
+ });
+ it('supports min max ranges', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {parameters: [{name: 'time', range: {min: 1, max: 11}}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, parameters: [{name: 'time', range: {min: 1, max: 11}}]});
+ done();
+ });
+ });
+ it('supports array type ranges', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {parameters: [{name: 'time', range: {type: 'array'}}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, parameters: [{name: 'time', range: {type: 'array'}}]});
+ done();
+ });
+ });
+ it('supports empty ranges', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {parameters: [{name: 'time', range: {}}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(_.omit(res.body, '_id')).be.eql({name: 'heat treatment', version: 2, parameters: [{name: 'time', range: {}}]});
+ done();
+ });
+ });
+ it('rejects `condition_template` as parameter name', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {parameters: [{name: 'condition_template', range: {}}]},
+ res: {status: 'Invalid body format', details: '"parameters[0].name" contains an invalid value'}
+ });
+ });
+ it('rejects not specified parameters', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {name: 'heat treatment', parameters: [{name: 'material', range: {xx: 5}}]},
+ res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'}
+ });
+ });
+ it('rejects an invalid id', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/2000000000h0000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 404,
+ req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}
+ });
+ });
+ it('rejects an unknown id', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/000000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 404,
+ req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {key: 'admin'},
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ it('rejects requests from a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/condition/200000000000000000000001',
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ });
+
+ describe('POST /template/condition/new', () => {
+ it('returns the right condition template', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'heat treatment3', parameters: [{name: 'material', range: {values: ['copper']}}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).have.only.keys('_id', 'name', 'version', 'parameters');
+ should(res.body).have.property('name', 'heat treatment3');
+ should(res.body).have.property('version', 1);
+ should(res.body).have.property('parameters').have.lengthOf(1);
+ should(res.body.parameters[0]).have.property('name', 'material');
+ should(res.body.parameters[0]).have.property('range');
+ should(res.body.parameters[0].range).have.property('values');
+ should(res.body.parameters[0].range.values[0]).be.eql('copper');
+ done();
+ });
+ });
+ it('stores the template', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ TemplateConditionModel.findById(res.body._id).lean().exec((err, data:any) => {
+ if (err) return done(err);
+ should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v');
+ should(data.first_id.toString()).be.eql(data._id.toString());
+ should(data).have.property('name', 'heat aging');
+ should(data).have.property('version', 1);
+ should(data).have.property('parameters').have.lengthOf(1);
+ should(data.parameters[0]).have.property('name', 'time');
+ should(data.parameters[0]).have.property('range');
+ should(data.parameters[0].range).have.property('min', 1);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]},
+ log: {
+ collection: 'condition_templates',
+ dataAdd: {version: 1},
+ dataIgn: ['first_id']
+ }
+ });
+ });
+ it('rejects a missing name', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {parameters: [{name: 'time', range: {min: 1}}]},
+ res: {status: 'Invalid body format', details: '"name" is required'}
+ });
+ });
+ it('rejects `condition_template` as parameter name', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {name: 'heat aging', parameters: [{name: 'condition_template', range: {min: 1}}]},
+ res: {status: 'Invalid body format', details: '"parameters[0].name" contains an invalid value'}
+ });
+ });
+ it('rejects a number prefix', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {name: 'heat aging', number_prefix: 'C', parameters: [{name: 'time', range: {min: 1}}]},
+ res: {status: 'Invalid body format', details: '"number_prefix" is not allowed'}
+ });
+ });
+ it('rejects missing parameters', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {name: 'heat aging'},
+ res: {status: 'Invalid body format', details: '"parameters" is required'}
+ });
+ });
+ it('rejects a missing parameter name', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {name: 'heat aging', parameters: [{range: {min: 1}}]},
+ res: {status: 'Invalid body format', details: '"parameters[0].name" is required'}
+ });
+ });
+ it('rejects a missing parameter range', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {name: 'heat aging', parameters: [{name: 'time'}]},
+ res: {status: 'Invalid body format', details: '"parameters[0].range" is required'}
+ });
+ });
+ it('rejects an invalid parameter range property', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {name: 'heat aging', parameters: [{name: 'time', range: {xx: 1}}]},
+ res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'}
+ });
+ });
+ it('rejects wrong properties', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {name: 'heat aging', parameters: [{name: 'time', range: {}}], xx: 33},
+ res: {status: 'Invalid body format', details: '"xx" is not allowed'}
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ auth: {key: 'admin'},
+ httpStatus: 401,
+ req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}
+ });
+ });
+ it('rejects requests from a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/condition/new',
+ httpStatus: 401,
+ req: {name: 'heat aging', parameters: [{name: 'time', range: {min: 1}}]}
+ });
+ });
+ });
+ });
+
+ describe('/template/measurement', () => {
+ describe('GET /template/measurements', () => {
+ it('returns all measurement templates', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/measurements',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.measurement_templates.length);
+ should(res.body).matchEach(measurement => {
+ should(measurement).have.only.keys('_id', 'name', 'version', 'parameters');
+ should(measurement).have.property('_id').be.type('string');
+ should(measurement).have.property('name').be.type('string');
+ should(measurement).have.property('version').be.type('number');
+ should(measurement.parameters).matchEach(number => {
+ should(number).have.only.keys('name', 'range');
+ should(number).have.property('name').be.type('string');
+ should(number).have.property('range').be.type('object');
+ });
+ });
+ done();
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/measurements',
+ auth: {key: 'janedoe'},
+ httpStatus: 401
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/measurements',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('GET /template/measurement/id', () => {
+ it('returns the right measurement template', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: { type: 'array'}}]}
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {key: 'janedoe'},
+ httpStatus: 401
+ });
+ });
+ it('rejects an unknown id', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/measurement/000000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 404
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/template/measurement/300000000000000000000001',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('PUT /template/measurement/{name}', () => {
+ it('returns the right measurement template', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {},
+ res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: { type: 'array'}}]}
+ });
+ });
+ it('keeps unchanged properties', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'spectrum', parameters: [{name: 'dpt', range: { type: 'array'}}]},
+ res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: {type: 'array'}}]}
+ });
+ });
+ it('keeps only one unchanged property', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'spectrum'},
+ res: {_id: '300000000000000000000001', name: 'spectrum', version: 1, parameters: [{name: 'dpt', range: {type: 'array'}}]}
+ });
+ });
+ it('changes the given properties', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(_.omit(res.body, '_id')).be.eql({name: 'IR spectrum', version: 2, parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]});
+ TemplateMeasurementModel.findById(res.body._id).lean().exec((err, data:any) => {
+ if (err) return done(err);
+ should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v');
+ should(data.first_id.toString()).be.eql('300000000000000000000001');
+ should(data).have.property('name', 'IR spectrum');
+ should(data).have.property('version', 2);
+ should(data).have.property('parameters').have.lengthOf(1);
+ should(data.parameters[0]).have.property('name', 'data point table');
+ should(data.parameters[0]).have.property('range');
+ should(data.parameters[0].range).have.property('min', 0);
+ should(data.parameters[0].range).have.property('max', 1000);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]},
+ log: {
+ collection: 'measurement_templates',
+ dataAdd: {
+ first_id: '300000000000000000000001',
+ version: 2
+ }
+ }
+ });
+ });
+ it('allows changing only one property', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'IR spectrum'},
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(_.omit(res.body, '_id')).be.eql({name: 'IR spectrum', version: 2, parameters: [{name: 'dpt', range: {type: 'array'}}]});
+ TemplateMeasurementModel.findById(res.body._id).lean().exec((err, data:any) => {
+ if (err) return done(err);
+ should(data).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v');
+ should(data.first_id.toString()).be.eql('300000000000000000000001');
+ should(data).have.property('name', 'IR spectrum');
+ should(data).have.property('version', 2);
+ should(data).have.property('parameters').have.lengthOf(1);
+ should(data.parameters[0]).have.property('name', 'dpt');
+ should(data.parameters[0]).have.property('range');
+ should(data.parameters[0].range).have.property('type', 'array');
+ done();
+ });
+ });
+ });
+ it('supports values ranges', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(_.omit(res.body, '_id')).be.eql({name: 'spectrum', version: 2, parameters: [{name: 'dpt', range: {values: [1, 2, 5]}}]});
+ done();
+ });
+ });
+ it('supports min max ranges', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(_.omit(res.body, '_id')).be.eql({name: 'spectrum', version: 2, parameters: [{name: 'dpt', range: {min: 0, max: 1000}}]});
+ done();
+ });
+ });
+ it('supports array type ranges', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {parameters: [{name: 'dpt2', range: {type: 'array'}}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(_.omit(res.body, '_id')).be.eql({name: 'spectrum', version: 2, parameters: [{name: 'dpt2', range: {type: 'array'}}]});
+ done();
+ });
+ });
+ it('supports empty ranges', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000002',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {parameters: [{name: 'weight %', range: {}}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(_.omit(res.body, '_id')).be.eql({name: 'kf', version: 2, parameters: [{name: 'weight %', range: {}}]});
+ done();
+ });
+ });
+ it('rejects not specified parameters', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {parameters: [{name: 'dpt'}], range: {xx: 33}},
+ res: {status: 'Invalid body format', details: '"parameters[0].range" is required'}
+ });
+ });
+ it('rejects an invalid id', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/3000000000h0000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 404,
+ req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]},
+ });
+ });
+ it('rejects an unknown id', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/000000000000000000000001',
+ auth: {basic: 'admin'},
+ httpStatus: 404,
+ req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]},
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {key: 'admin'},
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ it('rejects requests from a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000001',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/template/measurement/300000000000000000000001',
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ });
+
+ describe('POST /template/measurement/new', () => {
+ it('returns the right measurement template', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/measurement/new',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).have.only.keys('_id', 'name', 'version', 'parameters');
+ should(res.body).have.property('name', 'vz');
+ should(res.body).have.property('version', 1);
+ should(res.body).have.property('parameters').have.lengthOf(1);
+ should(res.body.parameters[0]).have.property('name', 'vz');
+ should(res.body.parameters[0]).have.property('range');
+ should(res.body.parameters[0].range).have.property('min', 1);
+ done();
+ });
+ });
+ it('stores the template', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/measurement/new',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]}
+ }).end(err => {
+ if (err) return done(err);
+ TemplateMeasurementModel.find({name: 'vz'}).lean().exec((err, data:any) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ should(data[0]).have.only.keys('_id', 'first_id', 'name', 'version', 'parameters', '__v');
+ should(data[0].first_id.toString()).be.eql(data[0]._id.toString());
+ should(data[0]).have.property('name', 'vz');
+ should(data[0]).have.property('version', 1);
+ should(data[0]).have.property('parameters').have.lengthOf(1);
+ should(data[0].parameters[0]).have.property('name', 'vz');
+ should(data[0].parameters[0]).have.property('range');
+ should(data[0].parameters[0].range).have.property('min', 1);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/measurement/new',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]},
+ log: {
+ collection: 'measurement_templates',
+ dataAdd: {version: 1},
+ dataIgn: ['first_id']
+ }
+ });
+ });
+ it('rejects a missing name', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/measurement/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]},
+ res: {status: 'Invalid body format', details: '"name" is required'}
+ });
+ });
+ it('rejects missing parameters', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/measurement/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {name: 'IR spectrum'},
+ res: {status: 'Invalid body format', details: '"parameters" is required'}
+ });
+ });
+ it('rejects a missing parameter name', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/measurement/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {name: 'IR spectrum', parameters: [{range: {min: 0, max: 1000}}]},
+ res: {status: 'Invalid body format', details: '"parameters[0].name" is required'}
+ });
+ });
+ it('rejects a missing parameter range', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/measurement/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {name: 'IR spectrum', parameters: [{name: 'data point table'}]},
+ res: {status: 'Invalid body format', details: '"parameters[0].range" is required'}
+ });
+ });
+ it('rejects a an invalid parameter range property', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/measurement/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {xx: 0}}]},
+ res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'}
+ });
+ });
+ it('rejects wrong properties', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/measurement/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {}}], xx: 35},
+ res: {status: 'Invalid body format', details: '"xx" is not allowed'}
+ });
+ });
+ it('rejects an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/measurement/new',
+ auth: {key: 'admin'},
+ httpStatus: 401,
+ req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]}
+ });
+ });
+ it('rejects requests from a write user', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/measurement/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/template/measurement/new',
+ httpStatus: 401,
+ req: {name: 'vz', parameters: [{name: 'vz', range: {min: 1}}]}
+ });
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/routes/template.ts b/src/routes/template.ts
new file mode 100644
index 0000000..c3bd14b
--- /dev/null
+++ b/src/routes/template.ts
@@ -0,0 +1,86 @@
+import express from 'express';
+import _ from 'lodash';
+
+import TemplateValidate from './validate/template';
+import ConditionTemplateModel from '../models/condition_template';
+import MeasurementTemplateModel from '../models/measurement_template';
+import res400 from './validate/res400';
+import IdValidate from './validate/id';
+import mongoose from "mongoose";
+import db from '../db';
+
+
+
+const router = express.Router();
+
+router.get('/template/:collection(measurements|conditions)', (req, res, next) => {
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ req.params.collection = req.params.collection.replace(/s$/g, ''); // remove trailing s
+ model(req).find({}).lean().exec((err, data) => {
+ if (err) next (err);
+ res.json(_.compact(data.map(e => TemplateValidate.output(e)))); // validate all and filter null values from validation errors
+ });
+});
+
+router.get('/template/:collection(measurement|condition)/' + IdValidate.parameter(), (req, res, next) => {
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ model(req).findById(req.params.id).lean().exec((err, data) => {
+ if (err) next (err);
+ if (data) {
+ res.json(TemplateValidate.output(data));
+ }
+ else {
+ res.status(404).json({status: 'Not found'});
+ }
+ });
+});
+
+router.put('/template/:collection(measurement|condition)/' + IdValidate.parameter(), async (req, res, next) => {
+ if (!req.auth(res, ['maintain', 'admin'], 'basic')) return;
+
+ const {error, value: template} = TemplateValidate.input(req.body, 'change');
+ if (error) return res400(error, res);
+
+ const templateData = await model(req).findById(req.params.id).lean().exec().catch(err => {next(err);}) as any;
+ if (templateData instanceof Error) return;
+ if (!templateData) {
+ return res.status(404).json({status: 'Not found'});
+ }
+
+ if (!_.isEqual(_.pick(templateData, _.keys(template)), template)) { // data was changed
+ template.version = templateData.version + 1; // increase version
+ await new (model(req))(_.assign({}, _.omit(templateData, ['_id', '__v']), template)).save((err, data) => { // save new template, fill with old properties
+ if (err) next (err);
+ db.log(req, req.params.collection + '_templates', {_id: data._id}, data.toObject());
+ res.json(TemplateValidate.output(data.toObject()));
+ });
+ }
+ else {
+ res.json(TemplateValidate.output(templateData));
+ }
+});
+
+router.post('/template/:collection(measurement|condition)/new', async (req, res, next) => {
+ if (!req.auth(res, ['maintain', 'admin'], 'basic')) return;
+
+ const {error, value: template} = TemplateValidate.input(req.body, 'new');
+ if (error) return res400(error, res);
+
+ template._id = mongoose.Types.ObjectId(); // set reference to itself for first version of template
+ template.first_id = template._id;
+ template.version = 1; // set template version
+ await new (model(req))(template).save((err, data) => {
+ if (err) next (err);
+ db.log(req, req.params.collection + '_templates', {_id: data._id}, data.toObject());
+ res.json(TemplateValidate.output(data.toObject()));
+ });
+});
+
+
+module.exports = router;
+
+function model (req) { // return right template model
+ return req.params.collection === 'condition' ? ConditionTemplateModel : MeasurementTemplateModel;
+}
\ No newline at end of file
diff --git a/src/routes/user.spec.ts b/src/routes/user.spec.ts
new file mode 100644
index 0000000..79c0769
--- /dev/null
+++ b/src/routes/user.spec.ts
@@ -0,0 +1,677 @@
+import should from 'should/as-function';
+import UserModel from '../models/user';
+import TestHelper from "../test/helper";
+
+
+
+describe('/user', () => {
+ let server;
+ before(done => TestHelper.before(done));
+ beforeEach(done => server = TestHelper.beforeEach(server, done));
+ afterEach(done => TestHelper.afterEach(server, done));
+ after(done => TestHelper.after(done));
+
+ describe('GET /users', () => {
+ it('returns all users', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/users',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done(err);
+ const json = require('../test/db.json');
+ should(res.body).have.lengthOf(json.collections.users.length);
+ should(res.body).matchEach(user => {
+ should(user).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name');
+ should(user).have.property('_id').be.type('string');
+ should(user).have.property('email').be.type('string');
+ should(user).have.property('name').be.type('string');
+ should(user).have.property('level').be.type('string');
+ should(user).have.property('location').be.type('string');
+ should(user).have.property('device_name').be.type('string');
+ });
+ done();
+ });
+ });
+ it('rejects requests from non-admins', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/users',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403
+ });
+ });
+ it('rejects requests from an admin API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/users',
+ auth: {key: 'admin'},
+ httpStatus: 401
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/users',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('GET /user/{name}', () => {
+ it('returns own user details', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/user',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('email', 'jane.doe@bosch.com');
+ should(res.body).have.property('name', 'janedoe');
+ should(res.body).have.property('level', 'write');
+ should(res.body).have.property('location', 'Rng');
+ should(res.body).have.property('device_name', 'Alpha I');
+ done();
+ });
+ });
+ it('returns other user details for admin', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/user/janedoe',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('email', 'jane.doe@bosch.com');
+ should(res.body).have.property('name', 'janedoe');
+ should(res.body).have.property('level', 'write');
+ should(res.body).have.property('location', 'Rng');
+ should(res.body).have.property('device_name', 'Alpha I');
+ done();
+ });
+ });
+ it('rejects requests from non-admins for another user', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/user/admin',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403
+ });
+ });
+ it('rejects requests from a user API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/user',
+ auth: {key: 'janedoe'},
+ httpStatus: 401
+ });
+ });
+ it('rejects requests from an admin API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/user/janedoe',
+ auth: {key: 'janedoe'},
+ httpStatus: 401
+ });
+ });
+ it('returns 404 for an unknown user', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/user/unknown',
+ auth: {basic: 'admin'},
+ httpStatus: 404
+ });
+ });
+ it('rejects requests from an admin API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/user/janedoe',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('PUT /user/{name}', () => {
+ it('returns own user details', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ req: {}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('email', 'jane.doe@bosch.com');
+ should(res.body).have.property('name', 'janedoe');
+ should(res.body).have.property('level', 'write');
+ should(res.body).have.property('location', 'Rng');
+ should(res.body).have.property('device_name', 'Alpha I');
+ done();
+ });
+ });
+ it('returns other user details for admin', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user/janedoe',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('email', 'jane.doe@bosch.com');
+ should(res.body).have.property('name', 'janedoe');
+ should(res.body).have.property('level', 'write');
+ should(res.body).have.property('location', 'Rng');
+ should(res.body).have.property('device_name', 'Alpha I');
+ done();
+ });
+ });
+ it('changes user details as given', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'adminnew', email: 'adminnew@bosch.com', pass: 'Abc123##', location: 'Abt', device_name: 'test'}
+ }).end(err => {
+ if (err) return done (err);
+ UserModel.find({name: 'adminnew'}).lean().exec( (err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ should(data[0]).have.only.keys('_id', 'name', 'pass', 'email', 'level', 'location', 'device_name', 'key', '__v');
+ should(data[0]).have.property('_id');
+ should(data[0]).have.property('name', 'adminnew');
+ should(data[0]).have.property('email', 'adminnew@bosch.com');
+ should(data[0]).have.property('pass').not.eql('Abc123##');
+ should(data[0]).have.property('level', 'admin');
+ should(data[0]).have.property('location', 'Abt');
+ should(data[0]).have.property('device_name', 'test');
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {name: 'adminnew', email: 'adminnew@bosch.com', pass: 'Abc123##', location: 'Abt', device_name: 'test'},
+ log: {
+ collection: 'users',
+ dataIgn: ['pass']
+ }
+ });
+ });
+ it('lets the admin change a user level', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user/janedoe',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {level: 'read'}
+ }).end(err => {
+ if (err) return done (err);
+ UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ should(data[0]).have.property('level', 'read');
+ done();
+ });
+ });
+ });
+ it('does not change the level', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user',
+ auth: {basic: 'janedoe'},
+ httpStatus: 400, default: false,
+ req: {level: 'read'}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).be.eql({status: 'Invalid body format', details: '"level" is not allowed'});
+ UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ should(data[0]).have.property('level', 'write');
+ done();
+ });
+ });
+ });
+ it('rejects a username already in use', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user',
+ auth: {basic: 'admin'},
+ httpStatus: 400, default: false,
+ req: {name: 'janedoe'}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).be.eql({status: 'Username already taken'});
+ UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ done();
+ });
+ });
+ });
+ it('rejects a username which is in the special names', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400, default: false,
+ req: {email: 'j.doe@bosch.com', name: 'passreset', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'},
+ res: {status: 'Username already taken'}
+ });
+ });
+ it('rejects invalid user details', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', location: 44, device_name: 'Alpha II'},
+ res: {status: 'Invalid body format', details: '"location" must be a string'}
+ });
+ });
+ it('rejects an invalid email address', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {email: 'john.doe'},
+ res: {status: 'Invalid body format', details: '"email" must be a valid email'}
+ });
+ });
+ it('rejects an invalid password', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {pass: 'password'},
+ res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$)[a-zA-Z0-9!"#%&\'()*+,\\-.\\/:;<=>?@[\\]^_`{|}~]{8,}$/'}
+ });
+ });
+ it('rejects requests from non-admins for another user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user/admin',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {}
+ });
+ });
+ it('rejects requests from a user API key', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user',
+ auth: {key: 'janedoe'},
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ it('rejects requests from an admin API key', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user/janedoe',
+ auth: {key: 'admin'},
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ it('returns 404 for an unknown user', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user/unknown',
+ auth: {basic: 'admin'},
+ httpStatus: 404,
+ req: {}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'put',
+ url: '/user/janedoe',
+ httpStatus: 401,
+ req: {}
+ });
+ });
+ });
+
+ describe('DELETE /user/{name}', () => {
+ it('deletes own user details', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/user',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).be.eql({status: 'OK'});
+ UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(0);
+ done();
+ });
+ });
+ });
+ it('deletes other user details for admin', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/user/janedoe',
+ auth: {basic: 'admin'},
+ httpStatus: 200
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).be.eql({status: 'OK'});
+ UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(0);
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/user',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ log: {
+ collection: 'users'
+ }
+ });
+ });
+ it('rejects requests from non-admins for another user', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/user/admin',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403
+ });
+ });
+ it('rejects requests from a user API key', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/user',
+ auth: {key: 'janedoe'},
+ httpStatus: 401
+ });
+ });
+ it('rejects requests from an admin API key', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/user/janedoe',
+ auth: {key: 'admin'},
+ httpStatus: 401
+ });
+ });
+ it('returns 404 for an unknown user', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/user/unknown',
+ auth: {basic: 'admin'},
+ httpStatus: 404
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'delete',
+ url: '/user/janedoe',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('GET /user/key', () => {
+ it('returns the right API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/user/key',
+ auth: {basic: 'janedoe'},
+ httpStatus: 200,
+ res: {key: TestHelper.auth.janedoe.key}
+ });
+ });
+ it('rejects requests from an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/user/key',
+ auth: {key: 'janedoe'},
+ httpStatus: 401
+ });
+ });
+ it('rejects requests from an API key', done => {
+ TestHelper.request(server, done, {
+ method: 'get',
+ url: '/user/key',
+ httpStatus: 401
+ });
+ });
+ });
+
+ describe('POST /user/new', () => {
+ it('returns the added user data', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/new',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).have.only.keys('_id', 'email', 'name', 'level', 'location', 'device_name');
+ should(res.body).have.property('_id').be.type('string');
+ should(res.body).have.property('email', 'john.doe@bosch.com');
+ should(res.body).have.property('name', 'johndoe');
+ should(res.body).have.property('level', 'read');
+ should(res.body).have.property('location', 'Rng');
+ should(res.body).have.property('device_name', 'Alpha II');
+ done();
+ });
+ });
+ it('stores the data', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/new',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}
+ }).end(err => {
+ if (err) return done (err);
+ UserModel.find({name: 'johndoe'}).lean().exec( (err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ should(data[0]).have.only.keys('_id', 'name', 'pass', 'email', 'level', 'location', 'device_name', 'key', '__v');
+ should(data[0]).have.property('_id');
+ should(data[0]).have.property('name', 'johndoe');
+ should(data[0]).have.property('email', 'john.doe@bosch.com');
+ should(data[0]).have.property('pass').not.eql('Abc123!#');
+ should(data[0]).have.property('level', 'read');
+ should(data[0]).have.property('location', 'Rng');
+ should(data[0]).have.property('device_name', 'Alpha II');
+ done();
+ });
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/new',
+ auth: {basic: 'admin'},
+ httpStatus: 200,
+ req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'},
+ log: {
+ collection: 'users',
+ dataIgn: ['pass', 'key']
+ }
+ });
+ });
+ it('rejects a username already in use', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400, default: false,
+ req: {email: 'j.doe@bosch.com', name: 'janedoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}
+ }).end((err, res) => {
+ if (err) return done (err);
+ should(res.body).be.eql({status: 'Username already taken'});
+ UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => {
+ if (err) return done(err);
+ should(data).have.lengthOf(1);
+ done();
+ });
+ });
+ });
+ it('rejects a username which is in the special names', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400, default: false,
+ req: {email: 'j.doe@bosch.com', name: 'passreset', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'},
+ res: {status: 'Username already taken'}
+ });
+ });
+ it('rejects invalid user details', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 44, device_name: 'Alpha II'},
+ res: {status: 'Invalid body format', details: '"location" must be a string'}
+ });
+ });
+ it('rejects an invalid user level', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'xxx', location: 'Rng', device_name: 'Alpha II'},
+ res: {status: 'Invalid body format', details: '"level" must be one of [read, write, maintain, dev, admin]'}
+ });
+ });
+ it('rejects an invalid email address', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {email: 'john.doe', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'},
+ res: {status: 'Invalid body format', details: '"email" must be a valid email'}
+ });
+ });
+ it('rejects an invalid password', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/new',
+ auth: {basic: 'admin'},
+ httpStatus: 400,
+ req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'password', level: 'read', location: 'Rng', device_name: 'Alpha II'},
+ res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$)[a-zA-Z0-9!"#%&\'()*+,\\-.\\/:;<=>?@[\\]^_`{|}~]{8,}$/'}
+ });
+ });
+ it('rejects requests from non-admins', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/new',
+ auth: {basic: 'janedoe'},
+ httpStatus: 403,
+ req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}
+ });
+ });
+ it('rejects requests from an admin API key', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/new',
+ auth: {key: 'admin'},
+ httpStatus: 401,
+ req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}
+ });
+ });
+ it('rejects unauthorized requests', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/new',
+ httpStatus: 401,
+ req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'}
+ });
+ });
+ });
+
+ describe('POST /user/passreset', () => {
+ it('returns the ok response', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/passreset',
+ httpStatus: 200,
+ req: {email: 'jane.doe@bosch.com', name: 'janedoe'},
+ res: {status: 'OK'}
+ });
+ });
+ it('creates a changelog', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/passreset',
+ httpStatus: 200,
+ req: {email: 'jane.doe@bosch.com', name: 'janedoe'},
+ log: {
+ collection: 'users',
+ dataIgn: ['email', 'name', 'pass']
+ }
+ });
+ });
+ it('returns 404 for wrong username/email combo', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/passreset',
+ httpStatus: 404,
+ req: {email: 'jane.doe@bosch.com', name: 'admin'}
+ });
+ });
+ it('returns 404 for unknown username', done => {
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/passreset',
+ httpStatus: 404,
+ req: {email: 'jane.doe@bosch.com', name: 'username'}
+ });
+ });
+ it('changes the user password', done => {
+ UserModel.find({name: 'janedoe'}).lean().exec( (err, data: any) => {
+ if (err) return done(err);
+ const oldpass = data[0].pass;
+ TestHelper.request(server, done, {
+ method: 'post',
+ url: '/user/passreset',
+ httpStatus: 200,
+ req: {email: 'jane.doe@bosch.com', name: 'janedoe'}
+ }).end((err, res) => {
+ if (err) return done(err);
+ should(res.body).be.eql({status: 'OK'});
+ UserModel.find({name: 'janedoe'}).lean().exec( (err, data: any) => {
+ if (err) return done(err);
+ should(data[0].pass).not.eql(oldpass);
+ done();
+ });
+ });
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/routes/user.ts b/src/routes/user.ts
new file mode 100644
index 0000000..65c41d5
--- /dev/null
+++ b/src/routes/user.ts
@@ -0,0 +1,163 @@
+import express from 'express';
+import mongoose from 'mongoose';
+import bcrypt from 'bcryptjs';
+import _ from 'lodash';
+
+import UserValidate from './validate/user';
+import UserModel from '../models/user';
+import mail from '../helpers/mail';
+import res400 from './validate/res400';
+import db from '../db';
+
+const router = express.Router();
+
+
+router.get('/users', (req, res) => {
+ if (!req.auth(res, ['admin'], 'basic')) return;
+
+ UserModel.find({}).lean().exec( (err, data:any) => {
+ res.json(_.compact(data.map(e => UserValidate.output(e)))); // validate all and filter null values from validation errors
+ });
+});
+
+router.get('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new. See https://forbeslindesay.github.io/express-route-tester/ for the generated regex
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ const username = getUsername(req, res);
+ if (!username) return;
+ UserModel.findOne({name: username}).lean().exec( (err, data:any) => {
+ if (err) return next(err);
+ if (data) {
+ res.json(UserValidate.output(data)); // validate all and filter null values from validation errors
+ }
+ else {
+ res.status(404).json({status: 'Not found'});
+ }
+ });
+});
+
+router.put('/user:username([/](?!key|new).?*|/?)', async (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ const username = getUsername(req, res);
+ if (!username) return;
+
+ const {error, value: user} = UserValidate.input(req.body, 'change' + (req.authDetails.level === 'admin'? 'admin' : ''));
+ if (error) return res400(error, res);
+
+ if (user.hasOwnProperty('pass')) {
+ user.pass = bcrypt.hashSync(user.pass, 10);
+ }
+
+ // check that user does not already exist if new name was specified
+ if (user.hasOwnProperty('name') && user.name !== username) {
+ if (!await usernameCheck(user.name, res, next)) return;
+ }
+
+ await UserModel.findOneAndUpdate({name: username}, user, {new: true}).log(req).lean().exec( (err, data:any) => {
+ if (err) return next(err);
+ if (data) {
+ res.json(UserValidate.output(data));
+ }
+ else {
+ res.status(404).json({status: 'Not found'});
+ }
+ });
+});
+
+router.delete('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // this path matches /user, /user/ and /user/xxx, but not /user/key or user/new. See https://forbeslindesay.github.io/express-route-tester/ for the generated regex
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ const username = getUsername(req, res);
+ if (!username) return;
+
+ UserModel.findOneAndDelete({name: username}).log(req).lean().exec( (err, data:any) => {
+ if (err) return next(err);
+ if (data) {
+ res.json({status: 'OK'})
+ }
+ else {
+ res.status(404).json({status: 'Not found'});
+ }
+ });
+});
+
+router.get('/user/key', (req, res, next) => {
+ if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'basic')) return;
+
+ UserModel.findOne({name: req.authDetails.username}).lean().exec( (err, data:any) => {
+ if (err) return next(err);
+ res.json({key: data.key});
+ });
+});
+
+router.post('/user/new', async (req, res, next) => {
+ if (!req.auth(res, ['admin'], 'basic')) return;
+
+ // validate input
+ const {error, value: user} = UserValidate.input(req.body, 'new');
+ if (error) return res400(error, res);
+
+ // check that user does not already exist
+ if (!await usernameCheck(user.name, res, next)) return;
+
+ user.key = mongoose.Types.ObjectId(); // use object id as unique API key
+ bcrypt.hash(user.pass, 10, (err, hash) => { // password hashing
+ user.pass = hash;
+ new UserModel(user).save((err, data) => { // store user
+ if (err) return next(err);
+ db.log(req, 'users', {_id: data._id}, data.toObject());
+ res.json(UserValidate.output(data.toObject()));
+ });
+ });
+});
+
+router.post('/user/passreset', (req, res, next) => {
+ // check if user/email combo exists
+ UserModel.find({name: req.body.name, email: req.body.email}).lean().exec( (err, data: any) => {
+ if (err) return next(err);
+ if (data.length === 1) { // it exists
+ const newPass = Math.random().toString(36).substring(2); // generate temporary password
+ bcrypt.hash(newPass, 10, (err, hash) => { // password hashing
+ if (err) return next(err);
+
+ UserModel.findByIdAndUpdate(data[0]._id, {pass: hash}).log(req).exec(err => { // write new password
+ if (err) return next(err);
+
+ // send email
+ mail(data[0].email, 'Your new password for the DFOP database', 'Hi,
You requested to reset your password.
Your new password is:
' + newPass + '
If you did not request a password reset, talk to the sysadmin quickly!
Have a nice day.
The DFOP team', err => {
+ if (err) return next(err);
+ res.json({status: 'OK'});
+ });
+ });
+ });
+ }
+ else {
+ res.status(404).json({status: 'Not found'});
+ }
+ });
+});
+
+
+module.exports = router;
+
+function getUsername (req, res) { // returns username or false if action is not allowed
+ req.params.username = req.params[0]; // because of path regex
+ if (req.params.username !== undefined) { // different username than request user
+ if (!req.auth(res, ['admin'], 'basic')) return false;
+ return req.params.username;
+ }
+ else {
+ return req.authDetails.username;
+ }
+}
+
+async function usernameCheck (name, res, next) { // check if username is already taken
+ const userData = await UserModel.findOne({name: name}).lean().exec().catch(err => next(err)) as any;
+ if (userData instanceof Error) return false;
+ if (userData || UserValidate.isSpecialName(name)) {
+ res.status(400).json({status: 'Username already taken'});
+ return false;
+ }
+ return true;
+}
\ No newline at end of file
diff --git a/src/routes/validate/id.ts b/src/routes/validate/id.ts
new file mode 100644
index 0000000..6b7b677
--- /dev/null
+++ b/src/routes/validate/id.ts
@@ -0,0 +1,29 @@
+import Joi from '@hapi/joi';
+
+export default class IdValidate {
+ private static id = Joi.string().pattern(new RegExp('[0-9a-f]{24}')).length(24);
+
+ static get () { // return joi validation
+ return this.id;
+ }
+
+ static valid (id) { // validate id
+ return this.id.validate(id).error === undefined;
+ }
+
+ static parameter () { // :id url parameter
+ return ':id([0-9a-f]{24})';
+ }
+
+ static stringify (data) { // convert all ObjectID objects to plain strings
+ Object.keys(data).forEach(key => {
+ if (data[key] !== null && data[key].hasOwnProperty('_bsontype') && data[key]._bsontype === 'ObjectID') { // stringify id
+ data[key] = data[key].toString();
+ }
+ else if (typeof data[key] === 'object' && data[key] !== null) { // deeper into recursion
+ data[key] = this.stringify(data[key]);
+ }
+ });
+ return data;
+ }
+}
\ No newline at end of file
diff --git a/src/routes/validate/material.ts b/src/routes/validate/material.ts
new file mode 100644
index 0000000..969ac43
--- /dev/null
+++ b/src/routes/validate/material.ts
@@ -0,0 +1,116 @@
+import Joi from '@hapi/joi';
+
+import IdValidate from './id';
+
+export default class MaterialValidate { // validate input for material
+ private static material = {
+ name: Joi.string()
+ .max(128),
+
+ supplier: Joi.string()
+ .max(128),
+
+ group: Joi.string()
+ .max(128),
+
+ mineral: Joi.number()
+ .integer()
+ .min(0)
+ .max(100),
+
+ glass_fiber: Joi.number()
+ .integer()
+ .min(0)
+ .max(100),
+
+ carbon_fiber: Joi.number()
+ .integer()
+ .min(0)
+ .max(100),
+
+ numbers: Joi.array()
+ .items(Joi.object({
+ color: Joi.string()
+ .max(128)
+ .required(),
+ number: Joi.string()
+ .max(128)
+ .allow('')
+ .required()
+ }))
+ };
+
+ static input (data, param) { // validate input, set param to 'new' to make all attributes required
+ if (param === 'new') {
+ return Joi.object({
+ name: this.material.name.required(),
+ supplier: this.material.supplier.required(),
+ group: this.material.group.required(),
+ mineral: this.material.mineral.required(),
+ glass_fiber: this.material.glass_fiber.required(),
+ carbon_fiber: this.material.carbon_fiber.required(),
+ numbers: this.material.numbers.required()
+ }).validate(data);
+ }
+ else if (param === 'change') {
+ return Joi.object({
+ name: this.material.name,
+ supplier: this.material.supplier,
+ group: this.material.group,
+ mineral: this.material.mineral,
+ glass_fiber: this.material.glass_fiber,
+ carbon_fiber: this.material.carbon_fiber,
+ numbers: this.material.numbers
+ }).validate(data);
+ }
+ else {
+ return{error: 'No parameter specified!', value: {}};
+ }
+ }
+
+ static output (data) { // validate output and strip unwanted properties, returns null if not valid
+ data = IdValidate.stringify(data);
+ data.group = data.group_id.name;
+ data.supplier = data.supplier_id.name;
+ const {value, error} = Joi.object({
+ _id: IdValidate.get(),
+ name: this.material.name,
+ supplier: this.material.supplier,
+ group: this.material.group,
+ mineral: this.material.mineral,
+ glass_fiber: this.material.glass_fiber,
+ carbon_fiber: this.material.carbon_fiber,
+ numbers: this.material.numbers
+ }).validate(data, {stripUnknown: true});
+ return error !== undefined? null : value;
+ }
+
+ static outputGroups (data) {// validate groups output and strip unwanted properties, returns null if not valid
+ const {value, error} = this.material.group.validate(data, {stripUnknown: true});
+ return error !== undefined? null : value;
+ }
+
+ static outputSuppliers (data) {// validate suppliers output and strip unwanted properties, returns null if not valid
+ const {value, error} = this.material.supplier.validate(data, {stripUnknown: true});
+ return error !== undefined? null : value;
+ }
+
+ static outputV() { // return output validator
+ return Joi.object({
+ _id: IdValidate.get(),
+ name: this.material.name,
+ supplier: this.material.supplier,
+ group: this.material.group,
+ mineral: this.material.mineral,
+ glass_fiber: this.material.glass_fiber,
+ carbon_fiber: this.material.carbon_fiber,
+ numbers: this.material.numbers
+ });
+ }
+
+ static query (data) {
+ return Joi.object({
+ status: Joi.string().valid('validated', 'new', 'all')
+ }).validate(data);
+ }
+}
\ No newline at end of file
diff --git a/src/routes/validate/measurement.ts b/src/routes/validate/measurement.ts
new file mode 100644
index 0000000..0af8fbd
--- /dev/null
+++ b/src/routes/validate/measurement.ts
@@ -0,0 +1,56 @@
+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()
+ )
+ .allow(null)
+ )
+ };
+
+ static input (data, param) { // validate input, set param to 'new' to make all attributes required
+ if (param === 'new') {
+ return Joi.object({
+ sample_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) { // validate output and strip unwanted properties, returns null if not valid
+ data = IdValidate.stringify(data);
+ const {value, error} = Joi.object({
+ _id: IdValidate.get(),
+ sample_id: IdValidate.get(),
+ values: this.measurement.values,
+ measurement_template: IdValidate.get()
+ }).validate(data, {stripUnknown: true});
+ return error !== undefined? null : value;
+ }
+
+ static outputV() { // return output validator
+ return Joi.object({
+ _id: IdValidate.get(),
+ sample_id: IdValidate.get(),
+ values: this.measurement.values,
+ measurement_template: IdValidate.get()
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/routes/validate/note_field.ts b/src/routes/validate/note_field.ts
new file mode 100644
index 0000000..68856c9
--- /dev/null
+++ b/src/routes/validate/note_field.ts
@@ -0,0 +1,18 @@
+import Joi from '@hapi/joi';
+
+export default class NoteFieldValidate {
+ private static note_field = {
+ name: Joi.string()
+ .max(128),
+
+ qty: Joi.number()
+ };
+
+ static output (data) { // validate output and strip unwanted properties, returns null if not valid
+ const {value, error} = Joi.object({
+ name: this.note_field.name,
+ qty: this.note_field.qty
+ }).validate(data, {stripUnknown: true});
+ return error !== undefined? null : value;
+ }
+}
\ No newline at end of file
diff --git a/src/routes/validate/parameters.ts b/src/routes/validate/parameters.ts
new file mode 100644
index 0000000..e6070b0
--- /dev/null
+++ b/src/routes/validate/parameters.ts
@@ -0,0 +1,48 @@
+import Joi from '@hapi/joi';
+
+export default class ParametersValidate {
+ static input (data, parameters, param) { // data to validate, parameters from template, param: 'new', 'change', 'null'(null values are allowed)
+ let joiObject = {};
+ parameters.forEach(parameter => {
+ if (parameter.range.hasOwnProperty('values')) { // append right validation method according to parameter
+ joiObject[parameter.name] = Joi.alternatives()
+ .try(Joi.string().max(128), Joi.number(), Joi.boolean())
+ .valid(...parameter.range.values);
+ }
+ else if (parameter.range.hasOwnProperty('min') && parameter.range.hasOwnProperty('max')) {
+ joiObject[parameter.name] = Joi.number()
+ .min(parameter.range.min)
+ .max(parameter.range.max);
+ }
+ else if (parameter.range.hasOwnProperty('min')) {
+ joiObject[parameter.name] = Joi.number()
+ .min(parameter.range.min);
+ }
+ else if (parameter.range.hasOwnProperty('max')) {
+ joiObject[parameter.name] = Joi.number()
+ .max(parameter.range.max);
+ }
+ else if (parameter.range.hasOwnProperty('type')) {
+ switch (parameter.range.type) {
+ case 'array':
+ joiObject[parameter.name] = Joi.array();
+ break;
+ default:
+ joiObject[parameter.name] = Joi.string().max(128);
+ break;
+ }
+ }
+ else {
+ joiObject[parameter.name] = Joi.alternatives()
+ .try(Joi.string().max(128), Joi.number(), Joi.boolean());
+ }
+ if (param === 'new') {
+ joiObject[parameter.name] = joiObject[parameter.name].required()
+ }
+ else if (param === 'null') {
+ joiObject[parameter.name] = joiObject[parameter.name].allow(null)
+ }
+ });
+ return Joi.object(joiObject).validate(data);
+ }
+}
\ No newline at end of file
diff --git a/src/routes/validate/res400.ts b/src/routes/validate/res400.ts
new file mode 100644
index 0000000..e4595c8
--- /dev/null
+++ b/src/routes/validate/res400.ts
@@ -0,0 +1,5 @@
+// respond with 400 and include error details from the joi validation
+
+export default function res400 (error, res) {
+ res.status(400).json({status: 'Invalid body format', details: error.details[0].message});
+}
\ No newline at end of file
diff --git a/src/routes/validate/root.ts b/src/routes/validate/root.ts
new file mode 100644
index 0000000..3d05f9b
--- /dev/null
+++ b/src/routes/validate/root.ts
@@ -0,0 +1,50 @@
+import Joi from '@hapi/joi';
+import IdValidate from './id';
+
+export default class RootValidate { // validate input for root methods
+ private static changelog = {
+ timestamp: Joi.date()
+ .iso()
+ .min('1970-01-01T00:00:00.000Z'),
+
+ page: Joi.number()
+ .integer()
+ .min(0)
+ .default(0),
+
+ pagesize: Joi.number()
+ .integer()
+ .min(0)
+ .default(25),
+
+ action: Joi.string(),
+
+ collection: Joi.string(),
+
+ conditions: Joi.object(),
+
+ data: Joi.object()
+ };
+
+ static changelogParams (data) {
+ return Joi.object({
+ timestamp: this.changelog.timestamp.required(),
+ page: this.changelog.page,
+ pagesize: this.changelog.pagesize
+ }).validate(data);
+ }
+
+ static changelogOutput (data) {
+ data.date = data._id.getTimestamp();
+ data.collection = data.collectionName;
+ data = IdValidate.stringify(data);
+ const {value, error} = Joi.object({
+ date: this.changelog.timestamp,
+ action: this.changelog.action,
+ collection: this.changelog.collection,
+ conditions: this.changelog.conditions,
+ data: this.changelog.data,
+ }).validate(data, {stripUnknown: true});
+ return error !== undefined? null : value;
+ }
+}
\ No newline at end of file
diff --git a/src/routes/validate/sample.ts b/src/routes/validate/sample.ts
new file mode 100644
index 0000000..3fb28d9
--- /dev/null
+++ b/src/routes/validate/sample.ts
@@ -0,0 +1,223 @@
+import Joi from '@hapi/joi';
+
+import IdValidate from './id';
+import UserValidate from './user';
+import MaterialValidate from './material';
+import MeasurementValidate from './measurement';
+
+export default class SampleValidate {
+ private static sample = {
+ number: Joi.string()
+ .max(128),
+
+ color: Joi.string()
+ .max(128)
+ .allow(''),
+
+ type: Joi.string()
+ .max(128),
+
+ batch: Joi.string()
+ .max(128)
+ .allow(''),
+
+ condition: Joi.object(),
+
+ notes: Joi.object({
+ comment: Joi.string()
+ .max(512)
+ .allow(''),
+
+ sample_references: Joi.array()
+ .items(Joi.object({
+ sample_id: IdValidate.get(),
+
+ relation: Joi.string()
+ .max(128)
+ })),
+
+ custom_fields: Joi.object()
+ .pattern(/.*/, Joi.alternatives()
+ .try(
+ Joi.string().max(128),
+ Joi.number(),
+ Joi.boolean(),
+ Joi.date()
+ )
+ )
+ }),
+
+ added: Joi.date()
+ .iso()
+ .min('1970-01-01T00:00:00.000Z')
+ };
+
+ private static sortKeys = [
+ '_id',
+ 'color',
+ 'number',
+ 'type',
+ 'batch',
+ 'added',
+ 'material.name',
+ 'material.supplier',
+ 'material.group',
+ 'material.mineral',
+ 'material.glass_fiber',
+ 'material.carbon_fiber',
+ 'material.number',
+ 'measurements.(?!spectrum)*'
+ ];
+
+ private static fieldKeys = [
+ ...SampleValidate.sortKeys,
+ 'condition',
+ 'material_id',
+ 'material',
+ 'note_id',
+ 'user_id',
+ 'material._id',
+ 'material.numbers',
+ 'measurements.spectrum.dpt'
+ ];
+
+ static input (data, param) { // validate input, set param to 'new' to make all attributes required
+ if (param === 'new') {
+ return Joi.object({
+ color: this.sample.color.required(),
+ type: this.sample.type.required(),
+ batch: this.sample.batch.required(),
+ condition: this.sample.condition.required(),
+ material_id: IdValidate.get().required(),
+ notes: this.sample.notes.required()
+ }).validate(data);
+ }
+ else if (param === 'change') {
+ return Joi.object({
+ color: this.sample.color,
+ type: this.sample.type,
+ batch: this.sample.batch,
+ condition: this.sample.condition,
+ material_id: IdValidate.get(),
+ notes: this.sample.notes,
+ }).validate(data);
+ }
+ else if (param === 'new-admin') {
+ return Joi.object({
+ number: this.sample.number,
+ color: this.sample.color.required(),
+ type: this.sample.type.required(),
+ batch: this.sample.batch.required(),
+ condition: this.sample.condition.required(),
+ material_id: IdValidate.get().required(),
+ notes: this.sample.notes.required()
+ }).validate(data);
+ }
+ else {
+ return{error: 'No parameter specified!', value: {}};
+ }
+ }
+
+ static output (data, param = 'refs+added', additionalParams = []) { // validate output and strip unwanted properties, returns null if not valid
+ if (param === 'refs+added') {
+ param = 'refs';
+ data.added = data._id.getTimestamp();
+ }
+ data = IdValidate.stringify(data);
+ let joiObject;
+ if (param === 'refs') {
+ joiObject = {
+ _id: IdValidate.get(),
+ number: this.sample.number,
+ color: this.sample.color,
+ type: this.sample.type,
+ batch: this.sample.batch,
+ condition: this.sample.condition,
+ material_id: IdValidate.get(),
+ material: MaterialValidate.outputV().append({number: Joi.string().max(128).allow('')}),
+ note_id: IdValidate.get().allow(null),
+ user_id: IdValidate.get(),
+ added: this.sample.added
+ };
+ }
+ else if(param === 'details') {
+ joiObject = {
+ _id: IdValidate.get(),
+ number: this.sample.number,
+ color: this.sample.color,
+ type: this.sample.type,
+ batch: this.sample.batch,
+ condition: this.sample.condition,
+ material: MaterialValidate.outputV(),
+ measurements: Joi.array().items(MeasurementValidate.outputV()),
+ notes: this.sample.notes,
+ user: UserValidate.username()
+ }
+ }
+ else {
+ return null;
+ }
+ additionalParams.forEach(param => {
+ joiObject[param] = Joi.any();
+ });
+ const {value, error} = Joi.object(joiObject).validate(data, {stripUnknown: true});
+ return error !== undefined? null : value;
+ }
+
+ static query (data) {
+ if (data.filters && data.filters.length) {
+ const filterValidation = Joi.array().items(Joi.string()).validate(data.filters);
+ if (filterValidation.error) return filterValidation;
+ try {
+ for (let i in data.filters) {
+ data.filters[i] = JSON.parse(data.filters[i]);
+ data.filters[i].values = data.filters[i].values.map(e => { // validate filter values
+ let validator;
+ let field = data.filters[i].field
+ if (/material\./.test(field)) { // select right validation model
+ validator = MaterialValidate.outputV().append({number: Joi.string().max(128).allow('')});
+ field = field.replace('material.', '');
+ }
+ else if (/measurements\./.test(field)) {
+ validator = Joi.object({
+ value: Joi.alternatives()
+ .try(
+ Joi.number(),
+ Joi.string().max(128),
+ Joi.boolean(),
+ Joi.array()
+ )
+ .allow(null)
+ });
+ field = 'value';
+ }
+ else {
+ validator = Joi.object(this.sample);
+ }
+ const {value, error} = validator.validate({[field]: e});
+ console.log(value);
+ if (error) throw error; // reject invalid values // TODO: return exact error description, handle in frontend filters
+ return value[field];
+ });
+ }
+ }
+ catch {
+ return {error: {details: [{message: 'Invalid JSON string for filter parameter'}]}, value: null}
+ }
+ }
+ return Joi.object({
+ status: Joi.string().valid('validated', 'new', 'all'),
+ 'from-id': IdValidate.get(),
+ 'to-page': Joi.number().integer(),
+ 'page-size': Joi.number().integer().min(1),
+ sort: Joi.string().pattern(new RegExp('^(' + this.sortKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')-(asc|desc)$', 'm')).default('_id-asc'),
+ csv: Joi.boolean().default(false),
+ fields: Joi.array().items(Joi.string().pattern(new RegExp('^(' + this.fieldKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')$', 'm'))).default(['_id','number','type','batch','material_id','color','condition','note_id','user_id','added']),
+ filters: Joi.array().items(Joi.object({
+ mode: Joi.string().valid('eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'nin'),
+ field: Joi.string().pattern(new RegExp('^(' + this.fieldKeys.join('|').replace(/\./g, '\\.').replace(/\*/g, '.+') + ')$', 'm')),
+ values: Joi.array().items(Joi.alternatives().try(Joi.string().max(128), Joi.number(), Joi.boolean(), Joi.date().iso())).min(1)
+ })).default([])
+ }).with('to-page', 'page-size').validate(data);
+ }
+}
\ No newline at end of file
diff --git a/src/routes/validate/template.ts b/src/routes/validate/template.ts
new file mode 100644
index 0000000..ae9426a
--- /dev/null
+++ b/src/routes/validate/template.ts
@@ -0,0 +1,70 @@
+import Joi from '@hapi/joi';
+import IdValidate from './id';
+
+// TODO: do not allow a . in the name
+export default class TemplateValidate {
+ private static template = {
+ name: Joi.string()
+ .max(128),
+
+ version: Joi.number()
+ .min(1),
+
+ parameters: Joi.array()
+ .items(
+ Joi.object({
+ name: Joi.string()
+ .max(128)
+ .invalid('condition_template')
+ .required(),
+
+ range: Joi.object({
+ values: Joi.array()
+ .min(1),
+
+ min: Joi.number(),
+
+ max: Joi.number(),
+
+ type: Joi.string()
+ .valid('array')
+ })
+ .oxor('values', 'min')
+ .oxor('values', 'max')
+ .oxor('type', 'values')
+ .oxor('type', 'min')
+ .oxor('type', 'max')
+ .required()
+ })
+ )
+ };
+
+ static input (data, param) { // validate input, set param to 'new' to make all attributes required
+ if (param === 'new') {
+ return Joi.object({
+ name: this.template.name.required(),
+ parameters: this.template.parameters.required()
+ }).validate(data);
+ }
+ else if (param === 'change') {
+ return Joi.object({
+ name: this.template.name,
+ parameters: this.template.parameters
+ }).validate(data);
+ }
+ else {
+ return{error: 'No parameter specified!', value: {}};
+ }
+ }
+
+ static output (data) { // validate output and strip unwanted properties, returns null if not valid
+ data = IdValidate.stringify(data);
+ const {value, error} = Joi.object({
+ _id: IdValidate.get(),
+ name: this.template.name,
+ version: this.template.version,
+ parameters: this.template.parameters
+ }).validate(data, {stripUnknown: true});
+ return error !== undefined? null : value;
+ }
+}
\ No newline at end of file
diff --git a/src/routes/validate/user.ts b/src/routes/validate/user.ts
new file mode 100644
index 0000000..9c0c7d1
--- /dev/null
+++ b/src/routes/validate/user.ts
@@ -0,0 +1,91 @@
+import Joi from '@hapi/joi';
+import globals from '../../globals';
+
+import IdValidate from './id';
+
+export default class UserValidate { // validate input for user
+ private static user = {
+ name: Joi.string()
+ .lowercase()
+ .pattern(new RegExp('^[a-z0-9-_.]+$'))
+ .max(128),
+
+ email: Joi.string()
+ .email({minDomainSegments: 2})
+ .lowercase()
+ .max(128),
+
+ pass: Joi.string()
+ .pattern(/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&'()*+,-.\/:;<=>?@[\]^_`{|}~])(?=\S+$)[a-zA-Z0-9!"#%&'()*+,\-.\/:;<=>?@[\]^_`{|}~]{8,}$/)
+ .max(128),
+
+ level: Joi.string()
+ .valid(...globals.levels),
+
+ location: Joi.string()
+ .alphanum()
+ .max(128),
+
+ device_name: Joi.string()
+ .allow('')
+ .max(128),
+ };
+
+ private static specialUsernames = ['admin', 'user', 'key', 'new', 'passreset']; // names a user cannot take
+
+ static input (data, param) { // validate input, set param to 'new' to make all attributes required
+ if (param === 'new') {
+ return Joi.object({
+ name: this.user.name.required(),
+ email: this.user.email.required(),
+ pass: this.user.pass.required(),
+ level: this.user.level.required(),
+ location: this.user.location.required(),
+ device_name: this.user.device_name.required()
+ }).validate(data);
+ }
+ else if (param === 'change') {
+ return Joi.object({
+ name: this.user.name,
+ email: this.user.email,
+ pass: this.user.pass,
+ location: this.user.location,
+ device_name: this.user.device_name
+ }).validate(data);
+ }
+ else if (param === 'changeadmin') {
+ return Joi.object({
+ name: this.user.name,
+ email: this.user.email,
+ pass: this.user.pass,
+ level: this.user.level,
+ location: this.user.location,
+ device_name: this.user.device_name
+ }).validate(data);
+ }
+ else {
+ return{error: 'No parameter specified!', value: {}};
+ }
+ }
+
+ static output (data) { // validate output and strip unwanted properties, returns null if not valid
+ data = IdValidate.stringify(data);
+ const {value, error} = Joi.object({
+ _id: IdValidate.get(),
+ name: this.user.name,
+ email: this.user.email,
+ level: this.user.level,
+ location: this.user.location,
+ device_name: this.user.device_name
+ }).validate(data, {stripUnknown: true});
+ return error !== undefined? null : value;
+ }
+
+ static isSpecialName (name) { // true if name belongs to special names
+ return this.specialUsernames.indexOf(name) > -1;
+ }
+
+ static username() {
+ return this.user.name;
+ }
+}
diff --git a/src/test/db.json b/src/test/db.json
new file mode 100644
index 0000000..99ae417
--- /dev/null
+++ b/src/test/db.json
@@ -0,0 +1,673 @@
+{
+ "collections": {
+ "samples": [
+ {
+ "_id": {"$oid":"400000000000000000000001"},
+ "number": "1",
+ "type": "granulate",
+ "color": "black",
+ "batch": "",
+ "condition": {
+ "material": "copper",
+ "weeks": 3,
+ "condition_template": {"$oid":"200000000000000000000001"}
+ },
+ "material_id": {"$oid":"100000000000000000000004"},
+ "note_id": null,
+ "user_id": {"$oid":"000000000000000000000002"},
+ "status": 10,
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"400000000000000000000002"},
+ "number": "21",
+ "type": "granulate",
+ "color": "natural",
+ "batch": "1560237365",
+ "condition": {
+ "material": "copper",
+ "weeks": 3,
+ "condition_template": {"$oid":"200000000000000000000001"}
+ },
+ "material_id": {"$oid":"100000000000000000000001"},
+ "note_id": {"$oid":"500000000000000000000001"},
+ "user_id": {"$oid":"000000000000000000000002"},
+ "status": 10,
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"400000000000000000000003"},
+ "number": "33",
+ "type": "part",
+ "color": "black",
+ "batch": "1704-005",
+ "condition": {
+ "material": "copper",
+ "weeks": 3,
+ "condition_template": {"$oid":"200000000000000000000001"}
+ },
+ "material_id": {"$oid":"100000000000000000000005"},
+ "note_id": {"$oid":"500000000000000000000002"},
+ "user_id": {"$oid":"000000000000000000000003"},
+ "status": 0,
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"400000000000000000000004"},
+ "number": "32",
+ "type": "granulate",
+ "color": "black",
+ "batch": "1653000308",
+ "condition": {
+ "p1": 44,
+ "condition_template": {"$oid":"200000000000000000000004"}
+ },
+ "material_id": {"$oid":"100000000000000000000005"},
+ "note_id": {"$oid":"500000000000000000000003"},
+ "user_id": {"$oid":"000000000000000000000003"},
+ "status": 0,
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"400000000000000000000005"},
+ "number": "Rng33",
+ "type": "granulate",
+ "color": "black",
+ "batch": "1653000308",
+ "condition": {
+ "condition_template": {"$oid":"200000000000000000000003"}
+ },
+ "material_id": {"$oid":"100000000000000000000005"},
+ "note_id": null,
+ "user_id": {"$oid":"000000000000000000000003"},
+ "status": -1,
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"400000000000000000000006"},
+ "number": "Rng36",
+ "type": "granulate",
+ "color": "black",
+ "batch": "",
+ "condition": {},
+ "material_id": {"$oid":"100000000000000000000004"},
+ "note_id": null,
+ "user_id": {"$oid":"000000000000000000000002"},
+ "status": 0,
+ "__v": 0
+ }
+ ],
+ "notes": [
+ {
+ "_id": {"$oid":"500000000000000000000001"},
+ "comment": "Stoff gesperrt",
+ "sample_references": [],
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"500000000000000000000002"},
+ "comment": "",
+ "sample_references": [{
+ "sample_id": {"$oid":"400000000000000000000004"},
+ "relation": "granulate to sample"
+ }],
+ "custom_fields": {
+ "not allowed for new applications": true
+ },
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"500000000000000000000003"},
+ "comment": "",
+ "sample_references": [{
+ "sample_id": {"$oid":"400000000000000000000003"},
+ "relation": "part to sample"
+ }],
+ "custom_fields": {
+ "not allowed for new applications": true,
+ "another_field": "is there"
+ },
+ "__v": 0
+ }
+ ],
+ "note_fields": [
+ {
+ "_id": {"$oid":"600000000000000000000001"},
+ "name": "not allowed for new applications",
+ "qty": 2,
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"600000000000000000000002"},
+ "name": "another_field",
+ "qty": 1,
+ "__v": 0
+ }
+ ],
+ "materials": [
+ {
+ "_id": {"$oid":"100000000000000000000001"},
+ "name": "Stanyl TW 200 F8",
+ "supplier_id": {"$oid":"110000000000000000000001"},
+ "group_id": {"$oid":"900000000000000000000001"},
+ "mineral": 0,
+ "glass_fiber": 40,
+ "carbon_fiber": 0,
+ "numbers": [
+ {
+ "color": "black",
+ "number": "5514263423"
+ },
+ {
+ "color": "natural",
+ "number": "5514263422"
+ }
+ ],
+ "status": 10,
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"100000000000000000000002"},
+ "name": "Ultramid T KR 4355 G7",
+ "supplier_id": {"$oid":"110000000000000000000002"},
+ "group_id": {"$oid":"900000000000000000000002"},
+ "mineral": 0,
+ "glass_fiber": 35,
+ "carbon_fiber": 0,
+ "numbers": [
+ {
+ "color": "black",
+ "number": "5514212901"
+ },
+ {
+ "color": "signalviolet",
+ "number": "5514612901"
+ }
+ ],
+ "status": 10,
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"100000000000000000000003"},
+ "name": "PA GF 50 black (2706)",
+ "supplier_id": {"$oid":"110000000000000000000003"},
+ "group_id": {"$oid":"900000000000000000000003"},
+ "mineral": 0,
+ "glass_fiber": 0,
+ "carbon_fiber": 0,
+ "numbers": [
+ ],
+ "status": 10,
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"100000000000000000000004"},
+ "name": "Schulamid 66 GF 25 H",
+ "supplier_id": {"$oid":"110000000000000000000004"},
+ "group_id": {"$oid":"900000000000000000000004"},
+ "mineral": 0,
+ "glass_fiber": 25,
+ "carbon_fiber": 0,
+ "numbers": [
+ {
+ "color": "black",
+ "number": "5513933405"
+ }
+ ],
+ "status": 10,
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"100000000000000000000005"},
+ "name": "Amodel A 1133 HS",
+ "supplier_id": {"$oid":"110000000000000000000005"},
+ "group_id": {"$oid":"900000000000000000000005"},
+ "mineral": 0,
+ "glass_fiber": 33,
+ "carbon_fiber": 0,
+ "numbers": [
+ {
+ "color": "black",
+ "number": "5514262406"
+ }
+ ],
+ "status": 10,
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"100000000000000000000006"},
+ "name": "PK-HM natural (4773)",
+ "supplier_id": {"$oid":"110000000000000000000003"},
+ "group_id": {"$oid":"900000000000000000000006"},
+ "mineral": 0,
+ "glass_fiber": 0,
+ "carbon_fiber": 0,
+ "numbers": [
+ {
+ "color": "natural",
+ "number": "10000000"
+ }
+ ],
+ "status": -1,
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"100000000000000000000007"},
+ "name": "Ultramid A4H",
+ "supplier_id": {"$oid":"110000000000000000000002"},
+ "group_id": {"$oid":"900000000000000000000004"},
+ "mineral": 0,
+ "glass_fiber": 0,
+ "carbon_fiber": 0,
+ "numbers": [
+ {
+ "color": "black",
+ "number": ""
+ }
+ ],
+ "status": 0,
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"100000000000000000000008"},
+ "name": "Latamid 66 H 2 G 30",
+ "supplier_id": {"$oid":"110000000000000000000006"},
+ "group_id": {"$oid":"900000000000000000000004"},
+ "mineral": 0,
+ "glass_fiber": 30,
+ "carbon_fiber": 0,
+ "numbers": [
+ {
+ "color": "blue",
+ "number": "5513943509"
+ }
+ ],
+ "status": -1,
+ "__v": 0
+ }
+ ],
+ "material_groups": [
+ {
+ "_id": {"$oid":"900000000000000000000001"},
+ "name": "PA46",
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"900000000000000000000002"},
+ "name": "PA6/6T",
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"900000000000000000000003"},
+ "name": "PA66+PA6I/6T",
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"900000000000000000000004"},
+ "name": "PA66",
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"900000000000000000000005"},
+ "name": "PPA",
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"900000000000000000000006"},
+ "name": "PK",
+ "__v": 0
+ }
+ ],
+ "material_suppliers": [
+ {
+ "_id": {"$oid":"110000000000000000000001"},
+ "name": "DSM",
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"110000000000000000000002"},
+ "name": "BASF",
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"110000000000000000000003"},
+ "name": "Akro-Plastic",
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"110000000000000000000004"},
+ "name": "Schulmann",
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"110000000000000000000005"},
+ "name": "Solvay",
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"110000000000000000000006"},
+ "name": "LATI",
+ "__v": 0
+ }
+ ],
+ "measurements": [
+ {
+ "_id": {"$oid":"800000000000000000000001"},
+ "sample_id": {"$oid":"400000000000000000000001"},
+ "values": {
+ "dpt": [
+ [3997.12558,98.00555],
+ [3995.08519,98.03253],
+ [3993.04480,98.02657]
+ ]
+ },
+ "status": 10,
+ "measurement_template": {"$oid":"300000000000000000000001"},
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"800000000000000000000002"},
+ "sample_id": {"$oid":"400000000000000000000002"},
+ "values": {
+ "weight %": 0.5,
+ "standard deviation": 0.2
+ },
+ "status": 10,
+ "measurement_template": {"$oid":"300000000000000000000002"},
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"800000000000000000000003"},
+ "sample_id": {"$oid":"400000000000000000000003"},
+ "values": {
+ "val1": 1
+ },
+ "status": 0,
+ "measurement_template": {"$oid":"300000000000000000000003"},
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"800000000000000000000004"},
+ "sample_id": {"$oid":"400000000000000000000003"},
+ "values": {
+ "val1": 1
+ },
+ "status": -1,
+ "measurement_template": {"$oid":"300000000000000000000003"},
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"800000000000000000000005"},
+ "sample_id": {"$oid":"400000000000000000000002"},
+ "values": {
+ "weight %": 0.5,
+ "standard deviation":null
+ },
+ "status": 10,
+ "measurement_template": {"$oid":"300000000000000000000002"},
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"800000000000000000000006"},
+ "sample_id": {"$oid":"400000000000000000000006"},
+ "values": {
+ "weight %": 0.6,
+ "standard deviation":null
+ },
+ "status": 0,
+ "measurement_template": {"$oid":"300000000000000000000002"},
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"800000000000000000000007"},
+ "sample_id": {"$oid":"400000000000000000000001"},
+ "values": {
+ "dpt": [
+ [3996.12558,98.00555],
+ [3995.08519,98.03253],
+ [3993.04480,98.02657]
+ ]
+ },
+ "status": 10,
+ "measurement_template": {"$oid":"300000000000000000000001"},
+ "__v": 0
+ }
+ ],
+ "condition_templates": [
+ {
+ "_id": {"$oid":"200000000000000000000001"},
+ "first_id": {"$oid":"200000000000000000000001"},
+ "name": "heat treatment",
+ "version": 1,
+ "parameters": [
+ {
+ "name": "material",
+ "range": {
+ "values": [
+ "copper",
+ "hot air"
+ ]
+ }
+ },
+ {
+ "name": "weeks",
+ "range": {
+ "min": 1,
+ "max": 10
+ }
+ }
+ ],
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"200000000000000000000003"},
+ "first_id": {"$oid":"200000000000000000000003"},
+ "name": "raw material",
+ "version": 1,
+ "parameters": [
+ ],
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"200000000000000000000004"},
+ "first_id": {"$oid":"200000000000000000000004"},
+ "name": "old condition",
+ "version": 1,
+ "parameters": [
+ {
+ "name": "p1",
+ "range": {}
+ }
+ ],
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"200000000000000000000005"},
+ "first_id": {"$oid":"200000000000000000000004"},
+ "name": "new condition",
+ "version": 2,
+ "parameters": [
+ {
+ "name": "p11",
+ "range": {}
+ }
+ ],
+ "__v": 0
+ }
+ ],
+ "measurement_templates": [
+ {
+ "_id": {"$oid":"300000000000000000000001"},
+ "first_id": {"$oid":"300000000000000000000001"},
+ "name": "spectrum",
+ "version": 1,
+ "parameters": [
+ {
+ "name": "dpt",
+ "range": {
+ "type": "array"
+ }
+ }
+ ],
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"300000000000000000000002"},
+ "first_id": {"$oid":"300000000000000000000002"},
+ "name": "kf",
+ "version": 1,
+ "parameters": [
+ {
+ "name": "weight %",
+ "range": {
+ "min": 0,
+ "max": 1.5
+ }
+ },
+ {
+ "name": "standard deviation",
+ "range": {
+ "min": 0,
+ "max": 0.5
+ }
+ }
+ ],
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"300000000000000000000003"},
+ "first_id": {"$oid":"300000000000000000000003"},
+ "name": "mt 3",
+ "version": 1,
+ "parameters": [
+ {
+ "name": "val1",
+ "range": {
+ "values": [1,2,3]
+ }
+ }
+ ],
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"300000000000000000000004"},
+ "first_id": {"$oid":"300000000000000000000003"},
+ "name": "mt 31",
+ "version": 2,
+ "parameters": [
+ {
+ "name": "val2",
+ "range": {
+ "values": [1,2,3,4]
+ }
+ }
+ ],
+ "__v": 0
+ }
+ ],
+ "users": [
+ {
+ "_id": {"$oid":"000000000000000000000001"},
+ "email": "user@bosch.com",
+ "name": "user",
+ "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi",
+ "level": "read",
+ "location": "Rng",
+ "device_name": "Alpha I",
+ "key": "000000000000000000001001",
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"000000000000000000000002"},
+ "email": "jane.doe@bosch.com",
+ "name": "janedoe",
+ "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi",
+ "level": "write",
+ "location": "Rng",
+ "device_name": "Alpha I",
+ "key": "000000000000000000001002",
+ "__v": 0
+ },
+ {
+ "_id": {"$oid":"000000000000000000000003"},
+ "email": "a.d.m.i.n@bosch.com",
+ "name": "admin",
+ "pass": "$2a$10$i872o3qR5V3JnbDArD8Z.eDo.BNPDBaR7dUX9KSEtl9pUjLyucy2K",
+ "level": "admin",
+ "location": "Rng",
+ "device_name": "",
+ "key": "000000000000000000001003",
+ "__v": "0"
+ },
+ {
+ "_id": {"$oid":"000000000000000000000004"},
+ "email": "johnny.doe@bosch.com",
+ "name": "johnnydoe",
+ "pass": "$2a$10$di26XKF63OG0V00PL1kSK.ceCcTxDExBMOg.jkHiCnXcY7cN7DlPi",
+ "level": "write",
+ "location": "Fe",
+ "device_name": "Alpha I",
+ "key": "000000000000000000001004",
+ "__v": 0
+ }
+ ],
+ "changelogs": [
+ {
+ "_id" : {"$oid": "120000010000000000000000"},
+ "action" : "PUT /sample/400000000000000000000001",
+ "collectionName" : "samples",
+ "conditions" : {
+ "_id" : {"$oid": "400000000000000000000001"}
+ },
+ "data" : {
+ "type" : "part",
+ "status" : 0
+ },
+ "user_id" : {"$oid": "000000000000000000000003"},
+ "__v" : 0
+ },
+ {
+ "_id" : {"$oid": "120000020000000000000000"},
+ "action" : "PUT /sample/400000000000000000000001",
+ "collectionName" : "samples",
+ "conditions" : {
+ "_id" : {"$oid": "400000000000000000000001"}
+ },
+ "data" : {
+ "type" : "part",
+ "status" : 0
+ },
+ "user_id" : {"$oid": "000000000000000000000003"},
+ "__v" : 0
+ },
+ {
+ "_id" : {"$oid": "120000030000000000000000"},
+ "action" : "PUT /sample/400000000000000000000001",
+ "collectionName" : "samples",
+ "conditions" : {
+ "_id" : {"$oid": "400000000000000000000001"}
+ },
+ "data" : {
+ "type" : "part",
+ "status" : 0
+ },
+ "user_id" : {"$oid": "000000000000000000000003"},
+ "__v" : 0
+ },
+ {
+ "_id" : {"$oid": "120000040000000000000000"},
+ "action" : "PUT /sample/400000000000000000000001",
+ "collectionName" : "samples",
+ "conditions" : {
+ "_id" : {"$oid": "400000000000000000000001"}
+ },
+ "data" : {
+ "type" : "part",
+ "status" : 0
+ },
+ "user_id" : {"$oid": "000000000000000000000003"},
+ "__v" : 0
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/src/test/helper.ts b/src/test/helper.ts
new file mode 100644
index 0000000..44085f7
--- /dev/null
+++ b/src/test/helper.ts
@@ -0,0 +1,135 @@
+import supertest from 'supertest';
+import should from 'should/as-function';
+import _ from 'lodash';
+import db from '../db';
+import ChangelogModel from '../models/changelog';
+import IdValidate from '../routes/validate/id';
+
+
+export default class TestHelper {
+ public static auth = { // test user credentials
+ admin: {pass: 'Abc123!#', key: '000000000000000000001003', id: '000000000000000000000003'},
+ janedoe: {pass: 'Xyz890*)', key: '000000000000000000001002', id: '000000000000000000000002'},
+ user: {pass: 'Xyz890*)', key: '000000000000000000001001', id: '000000000000000000000001'},
+ johnnydoe: {pass: 'Xyz890*)', key: '000000000000000000001004', id: '000000000000000000000004'}
+ }
+
+ public static res = { // default responses
+ 400: {status: 'Bad request'},
+ 401: {status: 'Unauthorized'},
+ 403: {status: 'Forbidden'},
+ 404: {status: 'Not found'},
+ 500: {status: 'Internal server error'}
+ }
+
+ static before (done) {
+ process.env.port = '2999';
+ process.env.NODE_ENV = 'test';
+ db.connect('test', done);
+ }
+
+ static beforeEach (server, done) {
+ delete require.cache[require.resolve('../index')]; // prevent loading from cache
+ server = require('../index');
+ db.drop(err => { // reset database
+ if (err) return done(err);
+ db.loadJson(require('./db.json'), done);
+ });
+ return server
+ }
+
+ static request (server, done, options) { // options in form: {method, url, contentType, auth: {key/basic: 'name' or 'key'/{name, pass}}, httpStatus, req, res, default (set to false if you want to dismiss default .end handling)}
+ let st = supertest(server);
+ if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('key')) { // resolve API key
+ options.url += '?key=' + (this.auth.hasOwnProperty(options.auth.key)? this.auth[options.auth.key].key : options.auth.key);
+ }
+ switch (options.method) { // http method
+ case 'get':
+ st = st.get(options.url)
+ break;
+ case 'post':
+ st = st.post(options.url)
+ break;
+ case 'put':
+ st = st.put(options.url)
+ break;
+ case 'delete':
+ st = st.delete(options.url)
+ break;
+ }
+ if (options.hasOwnProperty('reqType')) { // request body
+ st = st.type(options.reqType);
+ }
+ if (options.hasOwnProperty('req')) { // request body
+ st = st.send(options.req);
+ }
+ if (options.hasOwnProperty('auth') && options.auth.hasOwnProperty('basic')) { // resolve basic auth
+ if (this.auth.hasOwnProperty(options.auth.basic)) {
+ st = st.auth(options.auth.basic, this.auth[options.auth.basic].pass)
+ }
+ else {
+ st = st.auth(options.auth.basic.name, options.auth.basic.pass)
+ }
+ }
+ if (options.hasOwnProperty('contentType')) {
+ st = st.expect('Content-type', options.contentType).expect(options.httpStatus);
+ }
+ else {
+ st = st.expect('Content-type', /json/).expect(options.httpStatus);
+ }
+ if (options.hasOwnProperty('res')) { // evaluate result
+ return st.end((err, res) => {
+ if (err) return done (err);
+ should(res.body).be.eql(options.res);
+ done();
+ });
+ }
+ else if (this.res.hasOwnProperty(options.httpStatus) && options.default !== false) { // evaluate default results
+ return st.end((err, res) => {
+ if (err) return done (err);
+ should(res.body).be.eql(this.res[options.httpStatus]);
+ done();
+ });
+ }
+ else if (options.hasOwnProperty('log')) { // check changelog, takes log: {collection, skip, data/(dataAdd, dataIgn)}
+ return st.end(err => {
+ if (err) return done (err);
+ ChangelogModel.findOne({}).sort({_id: -1}).skip(options.log.skip? options.log.skip : 0).lean().exec((err, data) => { // latest entry
+ if (err) return done(err);
+ should(data).have.only.keys('_id', 'action', 'collectionName', 'conditions', 'data', 'user_id', '__v');
+ should(data).have.property('action', options.method.toUpperCase() + ' ' + options.url);
+ should(data).have.property('collectionName', options.log.collection);
+ if (options.log.hasOwnProperty('data')) {
+ should(data).have.property('data', options.log.data);
+ }
+ else {
+ const ignore = ['_id', '__v'];
+ if (options.log.hasOwnProperty('dataIgn')) {
+ ignore.push(...options.log.dataIgn);
+ }
+ let tmp = options.req ? options.req : {};
+ if (options.log.hasOwnProperty('dataAdd')) {
+ _.assign(tmp, options.log.dataAdd)
+ }
+ should(IdValidate.stringify(_.omit(data.data, ignore))).be.eql(_.omit(tmp, ignore));
+ }
+ if (data.user_id) {
+ should(data.user_id.toString()).be.eql(this.auth[options.auth.basic].id);
+ }
+ done();
+ });
+ });
+ }
+ else { // return object to do .end() manually
+ return st;
+ }
+ }
+
+ static afterEach (server, done) {
+ server.close(done);
+ }
+
+ static after(done) {
+ db.disconnect(done);
+ }
+}
\ No newline at end of file
diff --git a/src/test/loadDev.ts b/src/test/loadDev.ts
new file mode 100644
index 0000000..15a6868
--- /dev/null
+++ b/src/test/loadDev.ts
@@ -0,0 +1,14 @@
+import db from '../db';
+
+// script to load test db into dev db for a clean start
+
+db.connect('dev', () => {
+ console.info('dropping data...');
+ db.drop(() => { // reset database
+ console.info('loading data...');
+ db.loadJson(require('./db.json'), () => {
+ console.info('done');
+ process.exit(0);
+ });
+ });
+});
diff --git a/static/img/bosch-logo.svg b/static/img/bosch-logo.svg
new file mode 100644
index 0000000..fae963f
--- /dev/null
+++ b/static/img/bosch-logo.svg
@@ -0,0 +1,201 @@
+
+
+
diff --git a/static/styles/swagger.css b/static/styles/swagger.css
new file mode 100644
index 0000000..9760ed4
--- /dev/null
+++ b/static/styles/swagger.css
@@ -0,0 +1,323 @@
+/*Bosch styling for swagger*/
+
+/*GET: dark blue*/
+/*POST: dark green*/
+/*PUT: turquoise*/
+/*DELETE: fuchsia*/
+
+:root {
+ --red: #ea0016;
+ --dark-blue: #005691;
+ --dark-blue-w75: #bfd5e3;
+ --dark-green: #006249;
+ --dark-green-w75: #bfd8d1;
+ --turquoise: #00a8b0;
+ --turquoise-w75: #bfe9eb;
+ --fuchsia: #b90276;
+ --fuchsia-w75: #edc0dd;
+ --light-grey: #bfc0c2;
+ --light-grey-w75: #efeff0;
+ --light-green: #78be20;
+}
+
+body {
+ background: #fff;
+}
+
+body:before {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 16px;
+ content: '';
+ background-repeat: no-repeat;
+ background-size: cover;
+ background-image: url(data:image/svg+xml;base64,PHN2ZwogIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICB4bWw6c3BhY2U9InByZXNlcnZlIgogIGhlaWdodD0iMzAwIgogIHdpZHRoPSI3MjAiCiAgdmVyc2lvbj0iMS4xIgogIHk9IjAiCiAgeD0iMCIKICB2aWV3Qm94PSIwIDAgNzIwIDMwMCI+CiAgPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KICAgIC5zdDAgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF8xXyIpOwogICAgfQogICAgLnN0MSB7CiAgICAgIGZpbGw6IHVybCgiI1NWR0lEXzJfIik7CiAgICB9CiAgICAuc3QyIHsKICAgICAgZmlsbDogdXJsKCIjU1ZHSURfM18iKTsKICAgIH0KICAgIC5zdDMgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF80XyIpOwogICAgfQogICAgLnN0NCB7CiAgICAgIGZpbGw6IHVybCgiI1NWR0lEXzVfIik7CiAgICB9CiAgICAuc3Q1IHsKICAgICAgZmlsbDogI0FGMjAyNDsKICAgIH0KICAgIC5zdDYgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF82XyIpOwogICAgfQogICAgLnN0NyB7CiAgICAgIGZpbGw6ICM5NDFCMUU7CiAgICB9CiAgICAuc3Q4IHsKICAgICAgZmlsbDogI0IxMjczOTsKICAgIH0KICAgIC5zdDkgewogICAgICBmaWxsOiAjOTUyNDMyOwogICAgfQogICAgLnN0MTAgewogICAgICBmaWxsOiAjRDQyMDI3OwogICAgfQogICAgLnN0MTEgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF83XyIpOwogICAgfQogICAgLnN0MTIgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF84XyIpOwogICAgfQogICAgLnN0MTMgewogICAgICBmaWxsOiAjMUM5QTQ4OwogICAgfQogICAgLnN0MTQgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF85XyIpOwogICAgfQogICAgLnN0MTUgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF8xMF8iKTsKICAgIH0KICAgIC5zdDE2IHsKICAgICAgZmlsbDogIzJBMzg4NjsKICAgIH0KICAgIC5zdDE3IHsKICAgICAgZmlsbDogdXJsKCIjU1ZHSURfMTFfIik7CiAgICB9CiAgICAuc3QxOCB7CiAgICAgIGZpbGw6IHVybCgiI1NWR0lEXzEyXyIpOwogICAgfQogICAgLnN0MTkgewogICAgICBmaWxsOiB1cmwoIiNTVkdJRF8xM18iKTsKICAgIH0KICAgIC5zdDIwIHsKICAgICAgZmlsbDogdXJsKCIjU1ZHSURfMTRfIik7CiAgICB9CiAgPC9zdHlsZT4KICA8ZyB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMS41NSwtMy4zKSI+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzFfIiB5Mj0iLTMyLjY2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHkxPSItMzIuNjYzIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9Ijg0Mi4wOCIgeDE9IjExOC45OCI+PHN0b3Agc3RvcC1jb2xvcj0iIzk1MjMzMSIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzkyMUMxRCIgb2Zmc2V0PSIuMDM2MDk0Ii8+PHN0b3Agc3RvcC1jb2xvcj0iI0IwMjczOSIgb2Zmc2V0PSIuMDg0NjQ5Ii8+PHN0b3Agc3RvcC1jb2xvcj0iI0FEMUYyNCIgb2Zmc2V0PSIuMTIzNyIvPjxzdG9wIHN0b3AtY29sb3I9IiNDNzIwMjYiIG9mZnNldD0iLjE1MDkiLz48c3RvcCBzdG9wLWNvbG9yPSIjRDQyMDI3IiBvZmZzZXQ9Ii4xNjk3Ii8+PHN0b3Agc3RvcC1jb2xvcj0iI0NDMjQzMSIgb2Zmc2V0PSIuMTc1OCIvPjxzdG9wIHN0b3AtY29sb3I9IiNCNzJCNEMiIG9mZnNldD0iLjE4ODgiLz48c3RvcCBzdG9wLWNvbG9yPSIjOTUzMzcxIiBvZmZzZXQ9Ii4yMDc0Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzg4MzU3RiIgb2Zmc2V0PSIuMjE0MiIvPjxzdG9wIHN0b3AtY29sb3I9IiM4NTM2ODEiIG9mZnNldD0iLjI0MzYiLz48c3RvcCBzdG9wLWNvbG9yPSIjNkYzNjhCIiBvZmZzZXQ9Ii4yNjM4Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzM5NDI4RiIgb2Zmc2V0PSIuMjkxMSIvPjxzdG9wIHN0b3AtY29sb3I9IiMyMzNEN0QiIG9mZnNldD0iLjMyNDIiLz48c3RvcCBzdG9wLWNvbG9yPSIjMzIyQzZGIiBvZmZzZXQ9Ii40MTgxIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzJBMzg4NSIgb2Zmc2V0PSIuNDk0Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzFENjJBMSIgb2Zmc2V0PSIuNTU4MSIvPjxzdG9wIHN0b3AtY29sb3I9IiMyNzZDQTUiIG9mZnNldD0iLjU3MDIiLz48c3RvcCBzdG9wLWNvbG9yPSIjNDM4RUIzIiBvZmZzZXQ9Ii42MTAzIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzU1QTVCQyIgb2Zmc2V0PSIuNjM5OSIvPjxzdG9wIHN0b3AtY29sb3I9IiM1Q0FGQkYiIG9mZnNldD0iLjY1NTYiLz48c3RvcCBzdG9wLWNvbG9yPSIjNTZBQkJEIiBvZmZzZXQ9Ii42Nzc3Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzQzOUZCOCIgb2Zmc2V0PSIuNzA1OCIvPjxzdG9wIHN0b3AtY29sb3I9IiMxODhFQUYiIG9mZnNldD0iLjczNzIiLz48c3RvcCBzdG9wLWNvbG9yPSIjMDM4QkFFIiBvZmZzZXQ9Ii43NDI2Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzA2OTI5MiIgb2Zmc2V0PSIuNzg5OCIvPjxzdG9wIHN0b3AtY29sb3I9IiMwNUExNEIiIG9mZnNldD0iLjg4NzUiLz48c3RvcCBzdG9wLWNvbG9yPSIjMDM5MjdFIiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50PjxyZWN0IHdpZHRoPSI3MjMuMSIgeT0iMCIgeD0iMCIgaGVpZ2h0PSIzMDYuNCIgY2xhc3M9InN0MCIgZmlsbD0idXJsKCNTVkdJRF8xXykiLz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMl8iIHkyPSItMTA5LjI2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9Ii0xMDkuMjYiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgLTExOC45OCAxMjAuNTQpIiB4Mj0iMjM1Ljk4IiB4MT0iMzI1LjA4Ij48c3RvcCBzdG9wLWNvbG9yPSIjODkzNjgwIiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjODkzNjgwIiBvZmZzZXQ9Ii4zMzU0Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzhEMzE2RCIgb2Zmc2V0PSIuNTAyNSIvPjxzdG9wIHN0b3AtY29sb3I9IiM5MDI5NEQiIG9mZnNldD0iLjgzOTgiLz48c3RvcCBzdG9wLWNvbG9yPSIjOTAyNTQxIiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50Pjxwb2x5Z29uIHBvaW50cz0iMTc1LjEgMTUzLjIgMTE3IDMwNi40IDIwNi4xIDMwNi40IiBmaWxsPSJ1cmwoI1NWR0lEXzJfKSIgY2xhc3M9InN0MSIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF8zXyIgeTI9Ii04Mi4yODQiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTIwLjI0IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjQ0Ni41NSIgeDE9IjQ3OC45MyI+PHN0b3Agc3RvcC1jb2xvcj0iIzMyMkM2RiIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzMyMkM2RiIgb2Zmc2V0PSIuMjQyNyIvPjxzdG9wIHN0b3AtY29sb3I9IiMzMDJGNzIiIG9mZnNldD0iLjQ1OTkiLz48c3RvcCBzdG9wLWNvbG9yPSIjMkEzQTdFIiBvZmZzZXQ9Ii43MTU1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzE1NEE5MyIgb2Zmc2V0PSIuOTg5NiIvPjxzdG9wIHN0b3AtY29sb3I9IiMxMzRCOTQiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PHBvbHlnb24gcG9pbnRzPSIyODguNCAxNTMuMiAzMTAuNyAzMDYuNCAzNTguMSAzMDYuNCAzNTguMSAwIDMxMi45IDAiIGZpbGw9InVybCgjU1ZHSURfM18pIiBjbGFzcz0ic3QyIi8+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzRfIiB5Mj0iLTMyLjY2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIHkxPSItMzIuNjYzIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjM3Mi44OCIgeDE9IjI5NC4wOCI+PHN0b3Agc3RvcC1jb2xvcj0iIzZGMzc4RCIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzNBNDI5MSIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cG9seWdvbiBwb2ludHM9IjE3NS4xIDE1My4yIDIwNi4xIDMwNi40IDI1My45IDE1My4yIDIwOS40IDAgMjA5LjQgMCIgZmlsbD0idXJsKCNTVkdJRF80XykiIGNsYXNzPSJzdDMiLz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfNV8iIHkyPSItMzIuNjYzIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9Ii0zMi42NjMiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgLTExOC45OCAxMjAuNTQpIiB4Mj0iMzI1LjA4IiB4MT0iNDMxLjg4Ij48c3RvcCBzdG9wLWNvbG9yPSIjMjMzRDdEIiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjMjkzRDdEIiBvZmZzZXQ9Ii4yNDk1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzNBM0M4MCIgb2Zmc2V0PSIuNTQ0NiIvPjxzdG9wIHN0b3AtY29sb3I9IiM1MTNCODQiIG9mZnNldD0iLjg2MTYiLz48c3RvcCBzdG9wLWNvbG9yPSIjNUQzQTg2IiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50Pjxwb2x5Z29uIHBvaW50cz0iMjUzLjkgMTUzLjIgMjA2LjEgMzA2LjQgMzEwLjcgMzA2LjQgMjg4LjQgMTUzLjIgMzEyLjkgMCAyMDkuNCAwIiBmaWxsPSJ1cmwoI1NWR0lEXzVfKSIgY2xhc3M9InN0NCIvPjxwb2x5Z29uIHBvaW50cz0iMTE2LjEgMCA1NS43IDAgNTUuNyA5NC44IDg5LjkgMTUzLjIgNTUuNyAyMTEuNiA1NS43IDMwNi40IDExNyAzMDYuNCA5NS4yIDE1My4yIiBmaWxsPSIjYWYyMDI0IiBjbGFzcz0ic3Q1Ii8+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzZfIiB5Mj0iNDMuOTM3IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9IjQzLjkzNyIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAtMTE4Ljk4IDEyMC41NCkiIHgyPSIyMzIuNjciIHgxPSIzMjkuMTEiPjxzdG9wIHN0b3AtY29sb3I9IiM4OTM2ODAiIG9mZnNldD0iMCIvPjxzdG9wIHN0b3AtY29sb3I9IiM4OTM2ODAiIG9mZnNldD0iLjMzNTQiLz48c3RvcCBzdG9wLWNvbG9yPSIjOEQzMTZEIiBvZmZzZXQ9Ii41MDI1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzkwMjk0RCIgb2Zmc2V0PSIuODM5OCIvPjxzdG9wIHN0b3AtY29sb3I9IiM5MDI1NDEiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PHBvbHlnb24gcG9pbnRzPSIxNzUuMSAxNTMuMiAyMDkuNCAwIDExNi4xIDAiIGZpbGw9InVybCgjU1ZHSURfNl8pIiBjbGFzcz0ic3Q2Ii8+PHBvbHlnb24gcG9pbnRzPSI1NS43IDk0LjggNTUuNyAwIDAgMCIgZmlsbD0iIzk0MWIxZSIgY2xhc3M9InN0NyIvPjxwb2x5Z29uIHBvaW50cz0iNTUuNyAyMTEuNiA4OS45IDE1My4yIDU1LjcgOTQuOCIgZmlsbD0iI2IxMjczOSIgY2xhc3M9InN0OCIvPjxwb2x5Z29uIHBvaW50cz0iNTUuNyAyMTEuNiAwIDMwNi40IDU1LjcgMzA2LjQiIGZpbGw9IiM5NDFiMWUiIGNsYXNzPSJzdDciLz48cG9seWdvbiBwb2ludHM9IjU1LjcgOTQuOCAwIDAgMCAzMDYuNCA1NS43IDIxMS42IiBmaWxsPSIjOTUyNDMyIiBjbGFzcz0ic3Q5Ii8+PHBvbHlnb24gcG9pbnRzPSIxMTYuMSAwIDk1LjIgMTUzLjIgMTE3IDMwNi40IDE3NS4xIDE1My4yIiBmaWxsPSIjZDQyMDI3IiBjbGFzcz0ic3QxMCIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF83XyIgeTI9Ii0xODYuMDYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTIwLjQ0IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9Ijc0OC45NiIgeDE9Ijc0OC45NiI+PHN0b3Agc3RvcC1jb2xvcj0iIzk0QkU1NSIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzkzQkQ1OCIgb2Zmc2V0PSIuMDQ0MzQwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzhCQkM2QSIgb2Zmc2V0PSIuMzg5MSIvPjxzdG9wIHN0b3AtY29sb3I9IiM4NkJDNzUiIG9mZnNldD0iLjcxNDkiLz48c3RvcCBzdG9wLWNvbG9yPSIjODRCQzc5IiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50PjxwYXRoCiAgICAgIGQ9Im02NDEuNiAyNTkuNmMxLjctMjUuNCAxMC01NC42IDE4LjgtODUuNiAxLjQtNSAyLjgtMTAgNC4yLTE1LjEtMS40LTUuNS0yLjgtMTAuOS00LjItMTYuMi04LjgtMzMuMy0xNy02NC43LTE4LjgtOTItMS40LTIxLjIgMS40LTM3IDguOS01MC42aC00NS45Yy03LjUgMTguMy0xMC4zIDI5LjEtOC45IDUwLjMgMS43IDI3LjMgMTAgNTguNyAxOC44IDkyIDEzIDQ5LjMgMjggMTA2LjIgMjMuMiAxNjQuMmgxMi45Yy03LjYtMTIuOC0xMC40LTI3LjMtOS00N3oiCiAgICAgIGNsYXNzPSJzdDExIgogICAgICBmaWxsPSJ1cmwoI1NWR0lEXzdfKSIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF84XyIgeTI9Ii0xODQuNDUiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTE3LjI5IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjczMy40OSIgeDE9IjY1My43NiI+PHN0b3Agc3RvcC1jb2xvcj0iIzA4QTI0QiIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzBBQTE0RSIgb2Zmc2V0PSIuMTY3OCIvPjxzdG9wIHN0b3AtY29sb3I9IiMwQjlFNTciIG9mZnNldD0iLjQwNDciLz48c3RvcCBzdG9wLWNvbG9yPSIjMDk5QTY3IiBvZmZzZXQ9Ii42ODI3Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzA0OTQ3RCIgb2Zmc2V0PSIuOTg5OCIvPjxzdG9wIHN0b3AtY29sb3I9IiMwNDkzN0UiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PHBhdGggZD0ibTYxNC41IDE0Mi4zYy04LjgtMzMuMy0xNy02NC43LTE4LjgtOTItMS40LTIxLjIgMS40LTMyIDguOS01MC4zaC0zNS40YzUuNyA1My45LTMuOCAxMDYuNy0xMy42IDE2Ni44LTUuNyAzNS0xMS43IDcxLjMtMTMuMiAxMDAuNi0xLjEgMjEuMSAwLjQgMzIuOCAxLjggMzloOTMuNWM0LjgtNTcuOS0xMC4zLTExNC44LTIzLjItMTY0LjF6IiBjbGFzcz0ic3QxMiIgZmlsbD0idXJsKCNTVkdJRF84XykiLz48cGF0aCBjbGFzcz0ic3QxMyIgZmlsbD0iIzFjOWE0OCIgZD0ibTY2NC42IDE1OC45Yy0xLjQgNS4xLTIuOCAxMC4xLTQuMiAxNS4xLTguOCAzMS0xNyA2MC4yLTE4LjggODUuNi0xLjQgMTkuNyAxLjQgMzQuMiA5IDQ2LjloMzNjNC4yLTUxLjgtNy4yLTEwMi4zLTE5LTE0Ny42eiIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF85XyIgeTI9Ii0xODUuOTYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTIwLjU0IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjgxMi44MyIgeDE9IjgxMi44MyI+PHN0b3Agc3RvcC1jb2xvcj0iIzY5QTA2MCIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzYzOUQ1QyIgb2Zmc2V0PSIuMDM5ODk1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzRDOTQ0RiIgb2Zmc2V0PSIuMjE5MiIvPjxzdG9wIHN0b3AtY29sb3I9IiMzNzhFNDciIG9mZnNldD0iLjQxODQiLz48c3RvcCBzdG9wLWNvbG9yPSIjMjk4QjQ0IiBvZmZzZXQ9Ii42NTE1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzIzOEE0MyIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cGF0aCBkPSJtNjgwLjUgMGMxMC43IDU1LjMtMi41IDExMC40LTE1LjkgMTU4LjkgMTEuNyA0NS4zIDIzLjIgOTUuOCAxOC45IDE0Ny42aDM5LjZ2LTMwNi41aC00Mi42eiIgY2xhc3M9InN0MTQiIGZpbGw9InVybCgjU1ZHSURfOV8pIi8+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzEwXyIgeTI9Ii0xODUuODYiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTIwLjU0IiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjY1Mi40NSIgeDE9IjY1Mi40NSI+PHN0b3Agc3RvcC1jb2xvcj0iIzA1QjVEQyIgb2Zmc2V0PSIwIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzA0QjBENyIgb2Zmc2V0PSIuMjE5NyIvPjxzdG9wIHN0b3AtY29sb3I9IiMwNUE0QzkiIG9mZnNldD0iLjUzNzEiLz48c3RvcCBzdG9wLWNvbG9yPSIjMDU5MUI0IiBvZmZzZXQ9Ii45MTIyIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzA1OENBRSIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cGF0aCBkPSJtNTQyLjMgMjY3LjRjMS41LTI5LjQgNy41LTY1LjYgMTMuMi0xMDAuNiA5LjgtNjAuMSAxOS4zLTExMi44IDEzLjYtMTY2LjhoLTcwLjhjLTEuNCAxMS40LTIuOSAxOS4yLTEuOCA0MS44IDEuNSAzMS42IDcuNSA3MC41IDEzLjIgMTA4LjIgOC40IDU1LjQgMTYuNiAxMDguOCAxNS4xIDE1Ni40aDE5LjJjLTEuMy02LjItMi44LTE3LjktMS43LTM5eiIgY2xhc3M9InN0MTUiIGZpbGw9InVybCgjU1ZHSURfMTBfKSIvPjxwb2x5Z29uIHBvaW50cz0iMzc1LjcgMTUzLjIgMzU4LjEgMCAzNTguMSAzMDYuNCIgZmlsbD0iIzJhMzg4NiIgY2xhc3M9InN0MTYiLz4KICAgIDxsaW5lYXJHcmFkaWVudCBpZD0iU1ZHSURfMTFfIiB5Mj0iNzcuMTM2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9Ii00LjMyODEiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgLTExOC45OCAxMjAuNTQpIiB4Mj0iNzk2LjcxIiB4MT0iNzUxLjA1Ij48c3RvcCBzdG9wLWNvbG9yPSIjNjJCMTZFIiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjODdCOTU3IiBvZmZzZXQ9IjEiLz48L2xpbmVhckdyYWRpZW50PjxwYXRoIGQ9Im02NDEuNiA1MC42YzEuNyAyNy4zIDEwIDU4LjcgMTguOCA5MiAxLjQgNS4zIDIuOCAxMC43IDQuMiAxNi4yIDEzLjUtNDguNCAyNi42LTEwMy41IDE1LjktMTU4LjhoLTMwYy03LjUgMTMuNi0xMC4zIDI5LjQtOC45IDUwLjZ6IiBjbGFzcz0ic3QxNyIgZmlsbD0idXJsKCNTVkdJRF8xMV8pIi8+CiAgICA8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzEyXyIgeTI9Ii0xODkuMjgiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iMTEzLjcxIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIC0xMTguOTggMTIwLjU0KSIgeDI9IjYzMS41OSIgeDE9IjU1MC40Ij48c3RvcCBzdG9wLWNvbG9yPSIjMDY5QUQ0IiBvZmZzZXQ9IjAiLz48c3RvcCBzdG9wLWNvbG9yPSIjMzBBMENFIiBvZmZzZXQ9Ii4zNTI1Ii8+PHN0b3Agc3RvcC1jb2xvcj0iIzVCQjBDMCIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cGF0aCBkPSJtNTA5LjggMTUwYy01LjctMzcuNy0xMS43LTc2LjYtMTMuMi0xMDguMi0xLjEtMjIuNyAwLjQtMzAuNCAxLjgtNDEuOGgtNDEuNWMxLjUgNDAuMS0xLjUgODUuMy03IDE2MC44LTMuMSA0My41LTggMTEwLjUtNyAxNDUuN2g4Mi4xYzEuNC00Ny43LTYuOC0xMDEuMS0xNS4yLTE1Ni41eiIgY2xhc3M9InN0MTgiIGZpbGw9InVybCgjU1ZHSURfMTJfKSIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF8xM18iIHkyPSItMTg1Ljg2IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeTE9IjEyMC41NCIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAtMTE4Ljk4IDEyMC41NCkiIHgyPSI1MDUuMzMiIHgxPSI1MDUuMzMiPjxzdG9wIHN0b3AtY29sb3I9IiMxRTQ1OEUiIG9mZnNldD0iMCIvPjxzdG9wIHN0b3AtY29sb3I9IiMxRjRGOTYiIG9mZnNldD0iLjI0MTEiLz48c3RvcCBzdG9wLWNvbG9yPSIjMkI2QUFCIiBvZmZzZXQ9Ii43MjkyIi8+PHN0b3Agc3RvcC1jb2xvcj0iIzMzN0JCOSIgb2Zmc2V0PSIxIi8+PC9saW5lYXJHcmFkaWVudD48cG9seWdvbiBwb2ludHM9IjM1OC4xIDMwNi40IDQxNC42IDMwNi40IDQxNC42IDAgMzU4LjEgMCAzNzUuNyAxNTMuMiIgZmlsbD0idXJsKCNTVkdJRF8xM18pIiBjbGFzcz0ic3QxOSIvPgogICAgPGxpbmVhckdyYWRpZW50IGlkPSJTVkdJRF8xNF8iIHkyPSIxMjAuNTQiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiB5MT0iLTE4NS44NiIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAtMTE4Ljk4IDEyMC41NCkiIHgyPSI1NTQuOTIiIHgxPSI1NTQuOTIiPjxzdG9wIHN0b3AtY29sb3I9IiMzRjlBQzkiIG9mZnNldD0iMCIvPjxzdG9wIHN0b3AtY29sb3I9IiMyMDYyQTIiIG9mZnNldD0iMSIvPjwvbGluZWFyR3JhZGllbnQ+PHBhdGggZD0ibTQ0OS45IDE2MC44YzUuNS03NS41IDguNS0xMjAuNiA3LTE2MC44aC00Mi4ybC0wLjEgMzA2LjRoMjguM2MtMS0zNS4xIDMuOC0xMDIuMSA3LTE0NS42eiIgY2xhc3M9InN0MjAiIGZpbGw9InVybCgjU1ZHSURfMTRfKSIvPjwvZz4KPC9zdmc+Cg==);
+}
+
+body:after {
+ position: absolute;
+ right: 25px;
+ top: 36px;
+ width: 135px;
+ height: 48px;
+ content: '';
+ background-repeat: no-repeat;
+ background-size: cover;
+ background-image: url(/static/img/bosch-logo.svg);
+}
+
+.swagger-ui {
+ font-family: "Bosch Sans", sans-serif;
+}
+
+/*custom docs*/
+.docs {
+ position: relative;
+ font-size: 14px;
+}
+
+.docs > summary {
+ position: absolute;
+ right: 0;
+ top: -25px;
+ cursor: pointer;
+}
+
+.docs-open:hover {
+ text-decoration: underline;
+}
+
+/*Remove topbar*/
+.swagger-ui .topbar {
+ display: none
+}
+
+/*Remove models view*/
+.swagger-ui .models {
+ display: none;
+}
+
+/*Remove application/json select*/
+.swagger-ui .opblock .opblock-section-header > label, .swagger-ui .response-controls {
+ display: none;
+}
+
+/*Remove border radius*/
+.swagger-ui .opblock, .swagger-ui .opblock .opblock-summary-method, .swagger-ui select {
+ border-radius: 0;
+ box-shadow: none;
+}
+
+/*remove links in response*/
+.swagger-ui .response-col_links {
+ display: none;
+}
+
+/*remove version*/
+.swagger-ui .info .title span {
+ display: none;
+}
+
+/*separator before methods*/
+.swagger-ui .scheme-container {
+ box-shadow: none;
+ border-bottom: 1px solid var(--light-grey);
+}
+
+/*tag separator*/
+.swagger-ui .opblock-tag {
+ border-bottom: 1px solid var(--light-grey);
+}
+
+/*parameters/responses bar*/
+.swagger-ui .opblock .opblock-section-header {
+ box-shadow: none;
+ background: #fff;
+}
+
+/*select*/
+.swagger-ui select {
+ background-color: var(--light-grey-w75);
+ border: none;
+ height: 36px;
+}
+
+/*button*/
+.swagger-ui .btn {
+ border-radius: 0;
+ box-shadow: none;
+}
+
+.swagger-ui .btn:hover {
+ box-shadow: none;
+}
+
+/*authorize button */
+.swagger-ui .btn.authorize {
+ color: var(--light-green);
+ border-color: var(--light-green);
+}
+
+.swagger-ui .btn.authorize svg {
+ fill: var(--light-green);
+}
+
+/*auth inputs*/
+.swagger-ui .auth-container input[type="password"], .swagger-ui .auth-container input[type="text"] {
+ border-radius: 0;
+ box-shadow: none;
+ border-color: var(--light-grey);
+}
+
+.swagger-ui .dialog-ux .modal-ux {
+ border-radius: 0;
+}
+
+/*cancel button*/
+.swagger-ui .btn.cancel {
+ color: var(--red);
+ border-color: var(--red);
+}
+
+/*download button*/
+.swagger-ui .download-contents {
+ border-radius: 0;
+ height: 28px;
+ width: 80px;
+}
+
+/*model*/
+.swagger-ui .model-box {
+ border-radius: 0;
+}
+
+/*execute button*/
+.swagger-ui .btn.execute {
+ background-color: var(--dark-blue);
+ border-color: var(--dark-blue);
+ height: 30px;
+ line-height: 0.7;
+}
+
+.swagger-ui .btn-group .btn:last-child {
+ border-radius: 0;
+ height: 30px;
+ border-color: var(--dark-blue);
+}
+
+.swagger-ui .btn-group .btn:first-child {
+ border-radius: 0;
+}
+
+.swagger-ui .btn-group {
+ padding: 0 20px;
+}
+
+/*parameter input*/
+.swagger-ui .parameters-col_description input[type="text"] {
+ border-radius: 0;
+}
+
+/*required label*/
+.swagger-ui .parameter__name.required > span {
+ color: var(--red) !important;
+}
+
+.swagger-ui .parameter__name.required::after {
+ color: var(--red);
+}
+/*Remove colored parameters bar*/
+.swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span::after, .swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after {
+ background: none;
+}
+
+/*code*/
+.swagger-ui .opblock-body pre.microlight {
+ border-radius: 0;
+}
+
+.swagger-ui .highlight-code > .microlight {
+ min-height: 0;
+}
+
+/*request body*/
+.swagger-ui textarea {
+ border-radius: 0;
+}
+
+/*parameters smaller padding*/
+.swagger-ui .execute-wrapper {
+ padding-top: 0;
+ padding-bottom: 0;
+}
+
+.swagger-ui .btn.execute {
+ margin-bottom: 20px;
+}
+
+.swagger-ui .opblock-description-wrapper {
+ margin-top: 20px;
+}
+
+.swagger-ui .opblock-description-wrapper {
+ margin-top: 5px;
+}
+
+.opblock-section .opblock-section-request-body > div > div {
+ padding-top: 18px;
+}
+
+/*response element positions*/
+.swagger-ui .model-example {
+ position: relative;
+ margin-top: 0;
+}
+
+.swagger-ui .tab {
+ position: absolute;
+ top: -35px;
+ right: 0;
+}
+
+.swagger-ui table tbody tr td {
+ padding: 0;
+}
+
+.swagger-ui .renderedMarkdown p {
+ margin: 8px auto;
+}
+
+/*Method colors*/
+.swagger-ui .opblock.opblock-get .opblock-summary-method {
+ background: var(--dark-blue);
+}
+
+.swagger-ui .opblock.opblock-get .opblock-summary {
+ border-color: var(--dark-blue);
+}
+
+.swagger-ui .opblock.opblock-get {
+ background: var(--dark-blue-w75);
+ border-color: var(--dark-blue);
+}
+
+.swagger-ui .opblock.opblock-post .opblock-summary-method {
+ background: var(--dark-green);
+}
+
+.swagger-ui .opblock.opblock-post .opblock-summary {
+ border-color: var(--dark-green);
+}
+
+.swagger-ui .opblock.opblock-post {
+ background: var(--dark-green-w75);
+ border-color: var(--dark-green);
+}
+
+.swagger-ui .opblock.opblock-put .opblock-summary-method {
+ background: var(--turquoise);
+}
+
+.swagger-ui .opblock.opblock-put .opblock-summary {
+ border-color: var(--turquoise);
+}
+
+.swagger-ui .opblock.opblock-put {
+ background: var(--turquoise-w75);
+ border-color: var(--turquoise);
+}
+
+.swagger-ui .opblock.opblock-delete .opblock-summary-method {
+ background: var(--fuchsia);
+}
+
+.swagger-ui .opblock.opblock-delete .opblock-summary {
+ border-color: var(--fuchsia);
+}
+
+.swagger-ui .opblock.opblock-delete {
+ background: var(--fuchsia-w75);
+ border-color: var(--fuchsia);
+}
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index c49a622..b43a5fb 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -4,13 +4,21 @@
"target": "es5",
"outDir": "dist",
"sourceMap": true,
- "esModuleInterop": true
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "incremental": true,
+ "diagnostics": true,
+ "typeRoots": [
+ "src/customTypings",
+ "node_modules/@types"
+ ]
},
"files": [
"./node_modules/@types/node/index.d.ts"
],
"include": [
- "src/**/*.ts"
+ "src/**/*.ts",
+ "src/**/*.json"
],
"exclude": [
"node_modules"