diff --git a/api/api.yaml b/api/api.yaml
index d281206..a1966fa 100644
--- a/api/api.yaml
+++ b/api/api.yaml
@@ -39,10 +39,10 @@ info:
servers:
+ - url: https://definma-api.apps.de1.bosch-iot-cloud.com
+ description: server on the BIC
- url: http://localhost:3000
description: local server
- - url: https://digital-fingerprint-of-plastics-api.apps.de1.bosch-iot-cloud.com/
- description: server on the BIC
security:
diff --git a/api/material.yaml b/api/material.yaml
index 378628d..593afb1 100644
--- a/api/material.yaml
+++ b/api/material.yaml
@@ -5,6 +5,13 @@
x-doc: returns only materials with status 10
tags:
- /material
+ parameters:
+ - name: status
+ description: 'values: validated|new|all, defaults to validated'
+ in: query
+ schema:
+ type: string
+ example: all
responses:
200:
description: all material details
diff --git a/api/sample.yaml b/api/sample.yaml
index eae0ddc..2b0ce31 100644
--- a/api/sample.yaml
+++ b/api/sample.yaml
@@ -2,20 +2,83 @@
get:
summary: all samples in overview
description: 'Auth: all, levels: read, write, maintain, dev, admin'
- x-doc: returns only samples with status 10
+ x-doc: 'Limitations: paging and csv output does not work when including the spectrum measurement fields as well as the returned number of total samples'
tags:
- /sample
+ parameters:
+ - name: status
+ description: 'values: validated|new|all, defaults to validated'
+ in: query
+ schema:
+ type: string
+ example: all
+ - name: from-id
+ description: first id of the requested page, if not given the results are displayed from start
+ in: query
+ schema:
+ type: string
+ example: 5ea0450ed851c30a90e70894
+ - name: to-page
+ description: relative change of pages, use negative values to get back, defaults to 0, works only together with page-size
+ in: query
+ schema:
+ type: string
+ example: 1
+ - name: page-size
+ description: number of items per page
+ in: query
+ schema:
+ type: string
+ example: 30
+ - name: sort
+ description: sorting of results, in format 'key-asc/desc'
+ in: query
+ schema:
+ type: string
+ example: color-asc
+ - name: csv
+ description: output as csv
+ in: query
+ schema:
+ type: boolean
+ example: false
+ - name: fields[]
+ description: the fields to include in the output as array, defaults to ['_id','number','type','batch','material_id','color','condition','note_id','user_id','added']
+ in: query
+ schema:
+ type: array
+ items:
+ type: string
+ example: ['number', 'batch']
+ - name: filters[]
+ description: "the filters to apply as an array of URIComponent encoded objects in the form {mode: 'eq/ne/lt/lte/gt/gte/in/nin', field: 'material.m', values: ['15']} using encodeURIComponent(JSON.stringify({}))"
+ in: query
+ schema:
+ type: array
+ items:
+ type: string
+ example: ["%7B%22mode%22%3A%22eq%22%2C%22field%22%3A%22material.m%22%2C%22values%22%3A%5B%2215%22%5D%7D", "%7B%22mode%22%3A%22isin%22%2C%22field%22%3A%22material.supplier%22%2C%22values%22%3A%5B%22BASF%22%2C%22DSM%22%5D%7D"]
responses:
200:
- description: samples overview
+ description: samples overview (if the csv parameter is set, this is in CSV instead of JSON format)
+ headers:
+ x-total-items:
+ description: Total number of available items when from-id is not specified and spectrum field is not included
+ schema:
+ type: integer
+ example: 243
content:
application/json:
schema:
type: array
items:
$ref: 'api.yaml#/components/schemas/SampleRefs'
+ 400:
+ $ref: 'api.yaml#/components/responses/400'
401:
$ref: 'api.yaml#/components/responses/401'
+ 404:
+ $ref: 'api.yaml#/components/responses/404'
500:
$ref: 'api.yaml#/components/responses/500'
@@ -42,12 +105,31 @@
500:
$ref: 'api.yaml#/components/responses/500'
+/samples/count:
+ get:
+ summary: total number of samples
+ description: 'Auth: all, levels: read, write, maintain, dev, admin'
+ tags:
+ - /sample
+ responses:
+ 200:
+ description: sample count
+ content:
+ application/json:
+ schema:
+ properties:
+ count:
+ type: number
+ example: 864
+ 500:
+ $ref: 'api.yaml#/components/responses/500'
+
/sample/{id}:
parameters:
- $ref: 'api.yaml#/components/parameters/Id'
get:
- summary: TODO sample details
- description: 'Auth: all, levels: read, write, maintain, dev, admin'
+ summary: sample details
+ description: 'Auth: all, levels: read, write, maintain, dev, admin
Returns validated as well as new measurements'
x-doc: deleted samples are available only for maintain/admin
tags:
- /sample
@@ -216,12 +298,14 @@
content:
application/json:
schema:
- properties:
- name:
- type: string
- qty:
- type: number
- example: 20
+ type: array
+ items:
+ properties:
+ name:
+ type: string
+ qty:
+ type: number
+ example: 20
401:
$ref: 'api.yaml#/components/responses/401'
500:
diff --git a/api/schemas.yaml b/api/schemas.yaml
index 21ceddf..99f7998 100644
--- a/api/schemas.yaml
+++ b/api/schemas.yaml
@@ -46,6 +46,9 @@ SampleRefs:
$ref: 'api.yaml#/components/schemas/Id'
user_id:
$ref: 'api.yaml#/components/schemas/Id'
+ added:
+ type: string
+ example: 1970-01-01T00:00:00.000Z
Sample:
allOf:
- $ref: 'api.yaml#/components/schemas/_Id'
@@ -69,6 +72,8 @@ Sample:
relation:
type: string
example: part to this sample
+ custom_fields:
+ type: object
SampleDetail:
allOf:
diff --git a/data_import/import.js b/data_import/import.js
index e69de29..dc8c8d8 100644
--- a/data_import/import.js
+++ b/data_import/import.js
@@ -0,0 +1,579 @@
+const csv = require('csv-parser');
+const fs = require('fs');
+const axios = require('axios');
+const {Builder} = require('selenium-webdriver'); // selenium and the chrome driver must be installed and configured separately
+const chrome = require('selenium-webdriver/chrome');
+const pdfReader = require('pdfreader');
+const iconv = require('iconv-lite');
+
+const metaDoc = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\metadata.csv'; // metadata files
+const kfDoc = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\kf.csv';
+const vzDoc = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\vz.csv';
+const nmDocs = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\nmDocs'; // NormMaster Documents
+const dptFiles = 'C:\\Users\\vle2fe\\Documents\\Data\\Rng_200707\\DPT'; // Spectrum files
+const host = 'http://localhost:3000';
+// const host = 'https://definma-api.apps.de1.bosch-iot-cloud.com';
+let data = []; // metadata contents
+let materials = {};
+let samples = [];
+let normMaster = {};
+let sampleDevices = {};
+
+// TODO: BASF twice, BASF as color
+// TODO: duplicate kf values
+// TODO: conditions
+// TODO: comment and reference handling
+
+
+// TODO: check last color errors (filter out already taken) use location and device for user, upload to BIC
+
+main();
+
+async function main() {
+ if (0) { // materials
+ await getNormMaster();
+ await importCsv(metaDoc);
+ await allMaterials();
+ await saveMaterials();
+ await importCsv(kfDoc);
+ await allMaterials();
+ await saveMaterials();
+ await importCsv(vzDoc);
+ await allMaterials();
+ await saveMaterials();
+ }
+ if (0) { // samples
+ sampleDeviceMap();
+ if (1) {
+ console.log('-------- META ----------');
+ await importCsv(metaDoc);
+ await allSamples();
+ await saveSamples();
+ }
+ if (1) {
+ console.log('-------- KF ----------');
+ await importCsv(kfDoc);
+ await allSamples();
+ await saveSamples();
+ await allKfVz();
+ }
+ if (1) {
+ console.log('-------- VZ ----------');
+ await importCsv(vzDoc);
+ await allSamples();
+ await saveSamples();
+ await allKfVz();
+ }
+ }
+ if (1) { // DPT
+ await allDpts();
+ }
+ if (0) { // pdf test
+ console.log(await readPdf('N28_BN05-OX013_2016-03-11.pdf'));
+ }
+}
+
+async function importCsv(doc) {
+ data = [];
+ await new Promise(resolve => {
+ fs.createReadStream(doc)
+ .pipe(iconv.decodeStream('win1252'))
+ .pipe(csv())
+ .on('data', (row) => {
+ data.push(row);
+ })
+ .on('end', () => {
+ console.info('CSV file successfully processed');
+ if (data[0]['Farbe']) { // fix German column names
+ data.map(e => {e['Color'] = e['Farbe']; return e; });
+ }
+ resolve();
+ });
+ });
+}
+
+async function allDpts() {
+ let res = await axios({
+ method: 'get',
+ url: host + '/template/measurements',
+ auth: {
+ username: 'admin',
+ password: 'Abc123!#'
+ }
+ });
+ const measurement_template = res.data.find(e => e.name === 'spectrum')._id;
+ res = await axios({
+ method: 'get',
+ url: host + '/samples?status=all',
+ auth: {
+ username: 'admin',
+ password: 'Abc123!#'
+ }
+ });
+ const sampleIds = {};
+ res.data.forEach(sample => {
+ sampleIds[sample.number] = sample._id;
+ });
+ const dptRegex = /.*?_(.*?)_(\d+|\d+_\d+).DPT/;
+ const dpts = fs.readdirSync(dptFiles);
+ for (let i in dpts) {
+ const regexRes = dptRegex.exec(dpts[i])
+ if (regexRes && sampleIds[regexRes[1]]) { // found matching sample
+ console.log(dpts[i]);
+ const f = fs.readFileSync(dptFiles + '\\' + dpts[i], 'utf-8');
+ const data = {
+ sample_id: sampleIds[regexRes[1]],
+ values: {},
+ measurement_template
+ };
+ data.values.dpt = f.split('\r\n').map(e => e.split(','));
+ let rescale = false;
+ for (let i in data.values.dpt) {
+ if (data.values.dpt[i][1] > 2) {
+ rescale = true;
+ break;
+ }
+ }
+ if (rescale) {
+ data.values.dpt = data.values.dpt.map(e => [e[0], e[1] / 100]);
+ }
+ await axios({
+ method: 'post',
+ url: host + '/measurement/new',
+ auth: {
+ username: 'admin',
+ password: 'Abc123!#'
+ },
+ data
+ }).catch(err => {
+ console.log(dpts[i]);
+ console.error(err.response.data);
+ });
+ }
+ else {
+ console.log(`Could not find sample for ${dpts[i]} !!!!!!`);
+ }
+ }
+}
+
+async function allKfVz() {
+ let res = await axios({
+ method: 'get',
+ url: host + '/template/measurements',
+ auth: {
+ username: 'admin',
+ password: 'Abc123!#'
+ }
+ });
+ const kf_template = res.data.find(e => e.name === 'kf')._id;
+ const vz_template = res.data.find(e => e.name === 'vz')._id;
+ res = await axios({
+ method: 'get',
+ url: host + '/samples?status=all',
+ auth: {
+ username: 'admin',
+ password: 'Abc123!#'
+ }
+ });
+ const sampleIds = {};
+ res.data.forEach(sample => {
+ sampleIds[sample.number] = sample._id;
+ });
+ for (let index in data) {
+ console.info(`${index}/${data.length}`);
+ let sample = data[index];
+ if (sample['Sample number'] !== '') {
+ let credentials = ['admin', 'Abc123!#'];
+ if (sampleDevices[sample['Sample number']]) {
+ credentials = [sampleDevices[sample['Sample number']], '2020DeFinMachen!']
+ }
+ if (sample['KF in Gew%']) {
+ await axios({
+ method: 'post',
+ url: host + '/measurement/new',
+ auth: {
+ username: credentials[0],
+ password: credentials[1]
+ },
+ data: {
+ sample_id: sampleIds[sample['Sample number']],
+ measurement_template: kf_template,
+ values: {
+ 'weight %': sample['KF in Gew%'],
+ 'standard deviation': sample['Stabwn']
+ }
+ }
+ }).catch(err => {
+ console.log(sample['Sample number']);
+ console.error(err.response.data);
+ });
+ }
+ if (sample['VZ (ml/g)']) {
+ await axios({
+ method: 'post',
+ url: host + '/measurement/new',
+ auth: {
+ username: credentials[0],
+ password: credentials[1]
+ },
+ data: {
+ sample_id: sampleIds[sample['Sample number']],
+ measurement_template: vz_template,
+ values: {
+ vz: sample['VZ (ml/g)']
+ }
+ }
+ }).catch(err => {
+ console.log(sample['Sample number']);
+ console.error(err.response.data);
+ });
+ }
+ }
+ }
+}
+
+async function allSamples() {
+ samples = [];
+ let res = await axios({
+ method: 'get',
+ url: host + '/materials?status=all',
+ auth: {
+ username: 'admin',
+ password: 'Abc123!#'
+ }
+ });
+ const dbMaterials = {}
+ res.data.forEach(m => {
+ dbMaterials[m.name] = m;
+ })
+ res = await axios({
+ method: 'get',
+ url: host + '/samples?status=all',
+ auth: {
+ username: 'admin',
+ password: 'Abc123!#'
+ }
+ });
+ const sampleColors = {};
+ res.data.forEach(sample => {
+ sampleColors[sample.number] = sample.color;
+ });
+
+
+ for (let index in data) {
+ console.info(`${index}/${data.length}`);
+ let sample = data[index];
+ if (sample['Sample number'] !== '') { // TODO: what about samples without color
+ if (sample['Supplier'] === '') { // empty supplier fields
+ sample['Supplier'] = 'unknown';
+ }
+ if (sample['Granulate/Part'] === '') { // empty supplier fields
+ sample['Granulate/Part'] = 'unknown';
+ }
+ const material = dbMaterials[trim(sample['Material name'])];
+ if (!material) { // could not find material, skipping sample
+ continue;
+ }
+ console.log(sample['Material name']);
+ console.log(material._id);
+ samples.push({
+ number: sample['Sample number'],
+ type: sample['Granulate/Part'],
+ batch: sample['Charge/batch granulate/part'] || '',
+ material_id: material._id,
+ notes: {
+ comment: sample['Comments']
+ }
+ });
+ const si = samples.length - 1;
+ if (sample['Material number'] !== '' && material.numbers.find(e => e.number === sample['Material number'])) { // TODO: fix because of false material/material number
+ samples[si].color = material.numbers.find(e => e.number === sample['Material number']).color;
+ }
+ else if (sample['Color'] && sample['Color'] !== '') {
+ let number = material.numbers.find(e => e.color.indexOf(trim(sample['Color'])) >= 0);
+ if (!number && /black/.test(sample['Color'])) { // special case bk for black
+ number = material.numbers.find(e => e.color.toLowerCase().indexOf('bk') >= 0);
+ if (!number) { // try German word
+ number = material.numbers.find(e => e.color.toLowerCase().indexOf('schwarz') >= 0);
+ }
+ }
+ samples[si].color = number.color;
+ }
+ else if (sampleColors[sample['Sample number'].split('_')[0]]) { // derive color from main sample for kf/vz
+ samples[si].color = sampleColors[sample['Sample number'].split('_')[0]];
+ }
+ else {
+ samples[si].color = '';
+ }
+ }
+ }
+}
+
+async function saveSamples() {
+ for (let i in samples) {
+ console.info(`${i}/${samples.length}`);
+ let credentials = ['admin', 'Abc123!#'];
+ if (sampleDevices[samples[i].number]) {
+ credentials = [sampleDevices[samples[i].number], '2020DeFinMachen!']
+ }
+ await axios({
+ method: 'post',
+ url: host + '/sample/new',
+ auth: {
+ username: credentials[0],
+ password: credentials[1]
+ },
+ data: samples[i]
+ }).catch(err => {
+ if (err.response.data.status && err.response.data.status !== 'Sample number already taken') {
+ console.log(samples[i]);
+ console.error(err.response.data);
+ }
+ });
+ }
+ console.info('saved all samples');
+}
+
+async function allMaterials() {
+ materials = {};
+ for (let index in data) {
+ let sample = data[index];
+ if (sample['Sample number'] && sample['Sample number'] !== '') {
+ if (sample['Supplier'] === '') { // empty supplier fields
+ sample['Supplier'] = 'unknown';
+ }
+ if (sample['Material name'] === '') { // empty name fields
+ sample['Material name'] = sample['Material'];
+ }
+ if (!sample['Material']) { // column Material is named Plastic in VZ metadata
+ sample['Material'] = sample['Plastic'];
+ }
+ sample['Material name'] = trim(sample['Material name']);
+ if (materials.hasOwnProperty(sample['Material name'])) { // material already found at least once
+ if (sample['Material number'] && sample['Material number'] !== '') {
+ if (materials[sample['Material name']].numbers.length === 0 || !materials[sample['Material name']].numbers.find(e => e.number === stripSpaces(sample['Material number']))) { // new material number
+ if (materials[sample['Material name']].numbers.find(e => e.color === sample['Color'] && e.number === '')) { // color already in list, only number missing
+ materials[sample['Material name']].numbers.find(e => e.color === sample['Color'] && e.number === '').number = stripSpaces(sample['Material number']);
+ }
+ else {
+ materials[sample['Material name']].numbers.push({color: trim(sample['Color']), number: stripSpaces(sample['Material number'])});
+ }
+ }
+ }
+ else if (sample['Color'] && sample['Color'] !== '') {
+ if (!materials[sample['Material name']].numbers.find(e => e.color === stripSpaces(sample['Color']))) { // new material color
+ materials[sample['Material name']].numbers.push({color: trim(sample['Color']), number: ''});
+ }
+ }
+ }
+ else { // new material
+ console.info(`${index}/${data.length} ${sample['Material name']}`);
+ materials[sample['Material name']] = {
+ name: sample['Material name'],
+ supplier: trim(sample['Supplier']),
+ group: trim(sample['Material'])
+ };
+ let tmp = /M(\d+)/.exec(sample['Reinforcing material']);
+ materials[sample['Material name']].mineral = tmp ? tmp[1] : 0;
+ tmp = /GF(\d+)/.exec(sample['Reinforcing material']);
+ materials[sample['Material name']].glass_fiber = tmp ? tmp[1] : 0;
+ tmp = /CF(\d+)/.exec(sample['Reinforcing material']);
+ materials[sample['Material name']].carbon_fiber = tmp ? tmp[1] : 0;
+ materials[sample['Material name']].numbers = await numbersFetch(sample);
+ console.log(materials[sample['Material name']]);
+ }
+ }
+ }
+}
+
+async function saveMaterials() {
+ const mKeys = Object.keys(materials)
+ for (let i in mKeys) {
+ console.info(`${i}/${mKeys.length}`);
+ await axios({
+ method: 'post',
+ url: host + '/material/new',
+ auth: {
+ username: 'admin',
+ password: 'Abc123!#'
+ },
+ data: materials[mKeys[i]]
+ }).catch(err => {
+ if (err.response.data.status && err.response.data.status !== 'Material name already taken') {
+ console.info(materials[mKeys[i]]);
+ console.error(err.response.data);
+ }
+ });
+ }
+ console.info('saved all materials');
+}
+
+async function numbersFetch(sample) {
+ let nm = [];
+ let res = [];
+ if (sample['Material number']) { // sample has a material number
+ nm = normMaster[stripSpaces(sample['Material number'])]? [normMaster[stripSpaces(sample['Material number'])]] : [];
+ }
+ else { // try finding via material name
+ nm = Object.keys(normMaster).filter(e => normMaster[e].nameSpaceless === stripSpaces(sample['Material name'])).map(e => normMaster[e]);
+ }
+ if (nm.length > 0) {
+ for (let i in nm) {
+ // if (!fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)) { // document not loaded
+ // await getNormMasterDoc(nm[i].url.replace(/ /g, '%20'));
+ // }
+ // if (!fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)) { // document not loaded
+ // console.info('Retrying download...');
+ // await getNormMasterDoc(nm[i].url.replace(/ /g, '%20'), 2.2);
+ // }
+ // if (!fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)) { // document not loaded
+ // console.info('Retrying download again...');
+ // await getNormMasterDoc(nm[i].url.replace(/ /g, '%20'), 5);
+ // }
+ if (fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0)) { // document loaded
+ res = await readPdf(fs.readdirSync(nmDocs).find(e => e.indexOf(nm[i].doc.replace(/ /g, '_')) >= 0));
+ }
+ if (res.length > 0) { // no results
+ break;
+ }
+ else if (i + 1 >= nm.length) {
+ console.error('Download failed!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!');
+ }
+ }
+ }
+ if (res.length === 0) { // no results
+ if ((sample['Color'] && sample['Color'] !== '') || (sample['Material number'] &&sample['Material number'] !== '')) {
+ return [{color: trim(sample['Color']), number: sample['Material number']}];
+ }
+ else {
+ return [];
+ }
+ }
+ else {
+ if (sample['Material number'] && !res.find(e => e.number === sample['Material number'])) { // sometimes norm master does not include sample number even if listed
+ res.push({color: trim(sample['Color']), number: sample['Material number']});
+ }
+ return res;
+ }
+}
+
+async function getNormMaster(fetchAgain = false) {
+ if (fetchAgain) {
+ console.info('fetching norm master...');
+ const res = await axios({
+ method: 'get',
+ url: 'http://rb-normen.bosch.com/cgi-bin/searchRBNorm4TradeName'
+ });
+
+ console.info('finding documents...');
+ let match;
+ // const regex = /
> 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/measurement.ts b/src/models/measurement.ts index 1136e6b..55267ec 100644 --- a/src/models/measurement.ts +++ b/src/models/measurement.ts @@ -17,5 +17,7 @@ MeasurementSchema.query.log = function >('measurement', MeasurementSchema); \ No newline at end of file diff --git a/src/models/sample.ts b/src/models/sample.ts index 0e457d8..8eec7bd 100644 --- a/src/models/sample.ts +++ b/src/models/sample.ts @@ -22,5 +22,8 @@ SampleSchema.query.log = function> ( 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/routes/material.spec.ts b/src/routes/material.spec.ts index e91e87e..e412615 100644 --- a/src/routes/material.spec.ts +++ b/src/routes/material.spec.ts @@ -72,6 +72,43 @@ describe('/material', () => { 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', diff --git a/src/routes/material.ts b/src/routes/material.ts index 8373c9d..3f34e3a 100644 --- a/src/routes/material.ts +++ b/src/routes/material.ts @@ -19,7 +19,24 @@ const router = express.Router(); router.get('/materials', (req, res, next) => { if (!req.auth(res, ['read', 'write', 'maintain', 'dev', 'admin'], 'all')) return; - MaterialModel.find({status:globals.status.validated}).populate('group_id').populate('supplier_id').lean().exec((err, data) => { + 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 diff --git a/src/routes/root.ts b/src/routes/root.ts index 946948f..1547844 100644 --- a/src/routes/root.ts +++ b/src/routes/root.ts @@ -17,6 +17,7 @@ router.get('/authorized', (req, res) => { 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; diff --git a/src/routes/sample.spec.ts b/src/routes/sample.spec.ts index 97b9eb3..7dc5f24 100644 --- a/src/routes/sample.spec.ts +++ b/src/routes/sample.spec.ts @@ -10,6 +10,7 @@ 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; @@ -18,6 +19,9 @@ describe('/sample', () => { 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, { @@ -30,7 +34,7 @@ describe('/sample', () => { 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'); + 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'); @@ -41,6 +45,7 @@ describe('/sample', () => { 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(); }); @@ -56,7 +61,7 @@ describe('/sample', () => { 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'); + 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'); @@ -67,10 +72,405 @@ describe('/sample', () => { 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', @@ -93,7 +493,7 @@ describe('/sample', () => { 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'); + 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'); @@ -106,6 +506,7 @@ describe('/sample', () => { 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) { @@ -127,7 +528,7 @@ describe('/sample', () => { 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'); + 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'); @@ -140,6 +541,7 @@ describe('/sample', () => { 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) { @@ -174,6 +576,42 @@ describe('/sample', () => { }); }); + 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, { @@ -181,7 +619,7 @@ describe('/sample', () => { 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}}, user: 'admin'} + 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 => { @@ -190,7 +628,7 @@ describe('/sample', () => { 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}}, user: 'admin'} + 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 => { @@ -199,7 +637,7 @@ describe('/sample', () => { 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: {}, user: 'admin'} + 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 => { @@ -235,7 +673,7 @@ describe('/sample', () => { }); }); - describe('PUT /sample/{id}', () => { + describe('PUT /sample/{id}', () => { // TODO: fix tests, work on /samples it('returns the right sample', done => { TestHelper.request(server, done, { method: 'put', @@ -243,7 +681,7 @@ describe('/sample', () => { 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'} + 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 => { @@ -255,7 +693,7 @@ describe('/sample', () => { 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'}); + 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'); @@ -282,7 +720,7 @@ describe('/sample', () => { 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'}); + 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); @@ -299,7 +737,7 @@ describe('/sample', () => { 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'}); + 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); @@ -316,7 +754,7 @@ describe('/sample', () => { 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'}); + 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'); @@ -593,7 +1031,7 @@ describe('/sample', () => { 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'} + 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 => { @@ -613,7 +1051,7 @@ describe('/sample', () => { 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'} + 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 => { @@ -660,7 +1098,7 @@ describe('/sample', () => { 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'} + 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 => { @@ -1051,7 +1489,7 @@ describe('/sample', () => { 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'); + 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'); @@ -1061,6 +1499,8 @@ describe('/sample', () => { 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(); }); }); @@ -1164,7 +1604,7 @@ describe('/sample', () => { 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'); + 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'); @@ -1173,6 +1613,8 @@ describe('/sample', () => { 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(); }); }); @@ -1185,7 +1627,7 @@ describe('/sample', () => { 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'); + 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'); @@ -1195,6 +1637,8 @@ describe('/sample', () => { 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(); }); }); @@ -1237,7 +1681,7 @@ describe('/sample', () => { 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'); + 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'); @@ -1247,6 +1691,8 @@ describe('/sample', () => { 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(); }); }); diff --git a/src/routes/sample.ts b/src/routes/sample.ts index 3966c9b..91ada86 100644 --- a/src/routes/sample.ts +++ b/src/routes/sample.ts @@ -6,6 +6,7 @@ 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'; @@ -15,17 +16,350 @@ 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(); -router.get('/samples', (req, res, next) => { +// 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; - SampleModel.find({status: globals.status.validated}).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 - }) + 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) => { @@ -37,6 +371,15 @@ router.get('/samples/:state(new|deleted)', (req, res, next) => { }); }); +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; @@ -54,7 +397,7 @@ router.get('/sample/' + IdValidate.parameter(), (req, res, next) => { 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)}).lean().exec((err, data) => { + 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')); }); @@ -272,13 +615,23 @@ 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() + // .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 ? Number(sampleData.number.replace(/[^0-9]+/g, '')) + 1 : 1); + return req.authDetails.location + (sampleData[0] ? Number(sampleData[0].number.replace(/[^0-9]+/g, '')) + 1 : 1); } async function numberCheck(sample, res, next) { @@ -297,7 +650,7 @@ async function materialCheck (sample, res, next, id = sample.material_id) { // res.status(400).json({status: 'Material not available'}); return false; } - if (sample.hasOwnProperty('color') && !materialData.numbers.find(e => e.color === sample.color)) { // color for material not specified + 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; } @@ -374,4 +727,54 @@ function customFieldsChange (fields, amount, req) { // update custom_fields and } }); }); +} + +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/validate/material.ts b/src/routes/validate/material.ts index 7a2c3fb..969ac43 100644 --- a/src/routes/validate/material.ts +++ b/src/routes/validate/material.ts @@ -107,4 +107,10 @@ export default class MaterialValidate { // validate input for material 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 index 74c2409..0af8fbd 100644 --- a/src/routes/validate/measurement.ts +++ b/src/routes/validate/measurement.ts @@ -44,4 +44,13 @@ export default class MeasurementValidate { }).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/sample.ts b/src/routes/validate/sample.ts index 58c33ba..3fb28d9 100644 --- a/src/routes/validate/sample.ts +++ b/src/routes/validate/sample.ts @@ -3,6 +3,7 @@ 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 = { @@ -10,7 +11,8 @@ export default class SampleValidate { .max(128), color: Joi.string() - .max(128), + .max(128) + .allow(''), type: Joi.string() .max(128), @@ -43,9 +45,42 @@ export default class SampleValidate { 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({ @@ -83,7 +118,11 @@ export default class SampleValidate { } } - static output (data, param = 'refs') { // validate output and strip unwanted properties, returns null if not valid + 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') { @@ -95,8 +134,10 @@ export default class SampleValidate { 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() + user_id: IdValidate.get(), + added: this.sample.added }; } else if(param === 'details') { @@ -108,6 +149,7 @@ export default class SampleValidate { batch: this.sample.batch, condition: this.sample.condition, material: MaterialValidate.outputV(), + measurements: Joi.array().items(MeasurementValidate.outputV()), notes: this.sample.notes, user: UserValidate.username() } @@ -115,7 +157,67 @@ export default class SampleValidate { 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 index 7a63d1d..ae9426a 100644 --- a/src/routes/validate/template.ts +++ b/src/routes/validate/template.ts @@ -1,6 +1,7 @@ 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() diff --git a/src/test/db.json b/src/test/db.json index ef26a63..99ae417 100644 --- a/src/test/db.json +++ b/src/test/db.json @@ -411,12 +411,26 @@ "_id": {"$oid":"800000000000000000000006"}, "sample_id": {"$oid":"400000000000000000000006"}, "values": { - "weight %": 0.5, + "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": [ diff --git a/src/test/helper.ts b/src/test/helper.ts index e1e8eec..44085f7 100644 --- a/src/test/helper.ts +++ b/src/test/helper.ts @@ -38,15 +38,7 @@ export default class TestHelper { return server } - static afterEach (server, done) { - server.close(done); - } - - static after(done) { - db.disconnect(done); - } - - static request (server, done, options) { // options in form: {method, url, auth: {key/basic: 'name' or 'key'/{name, pass}}, httpStatus, req, res, default (set to false if you want to dismiss default .end handling)} + 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); @@ -79,8 +71,12 @@ export default class TestHelper { st = st.auth(options.auth.basic.name, options.auth.basic.pass) } } - st = st.expect('Content-type', /json/) - .expect(options.httpStatus); + 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); @@ -128,4 +124,12 @@ export default class TestHelper { return st; } } + + static afterEach (server, done) { + server.close(done); + } + + static after(done) { + db.disconnect(done); + } } \ No newline at end of file