Archived
2

implemented more /sample methods

This commit is contained in:
VLE2FE 2020-05-07 21:55:29 +02:00
parent 20f57acd2a
commit 63a5f5ebd1
21 changed files with 1322 additions and 161 deletions

11
.idea/dataSources.xml Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="@localhost" uuid="46f112fc-d60d-4217-873f-f5ffea06180c">
<driver-ref>mongo</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
<jdbc-url>mongodb://localhost:27017</jdbc-url>
</data-source>
</component>
</project>

458
.idea/dbnavigator.xml Normal file
View File

@ -0,0 +1,458 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DBNavigator.Project.DataEditorManager">
<record-view-column-sorting-type value="BY_INDEX" />
<value-preview-text-wrapping value="true" />
<value-preview-pinned value="false" />
</component>
<component name="DBNavigator.Project.DataExportManager">
<export-instructions>
<create-header value="true" />
<quote-values-containing-separator value="true" />
<quote-all-values value="false" />
<value-separator value="" />
<file-name value="" />
<file-location value="" />
<scope value="GLOBAL" />
<destination value="FILE" />
<format value="EXCEL" />
<charset value="windows-1252" />
</export-instructions>
</component>
<component name="DBNavigator.Project.DatabaseBrowserManager">
<autoscroll-to-editor value="false" />
<autoscroll-from-editor value="true" />
<show-object-properties value="true" />
<loaded-nodes />
</component>
<component name="DBNavigator.Project.DatabaseFileManager">
<open-files />
</component>
<component name="DBNavigator.Project.EditorStateManager">
<last-used-providers />
</component>
<component name="DBNavigator.Project.MethodExecutionManager">
<method-browser />
<execution-history>
<group-entries value="true" />
<execution-inputs />
</execution-history>
<argument-values-cache />
</component>
<component name="DBNavigator.Project.ObjectDependencyManager">
<last-used-dependency-type value="INCOMING" />
</component>
<component name="DBNavigator.Project.ObjectQuickFilterManager">
<last-used-operator value="EQUAL" />
<filters />
</component>
<component name="DBNavigator.Project.ScriptExecutionManager" clear-outputs="true">
<recently-used-interfaces />
</component>
<component name="DBNavigator.Project.Settings">
<connections />
<browser-settings>
<general>
<display-mode value="TABBED" />
<navigation-history-size value="100" />
<show-object-details value="false" />
</general>
<filters>
<object-type-filter>
<object-type name="SCHEMA" enabled="true" />
<object-type name="USER" enabled="true" />
<object-type name="ROLE" enabled="true" />
<object-type name="PRIVILEGE" enabled="true" />
<object-type name="CHARSET" enabled="true" />
<object-type name="TABLE" enabled="true" />
<object-type name="VIEW" enabled="true" />
<object-type name="MATERIALIZED_VIEW" enabled="true" />
<object-type name="NESTED_TABLE" enabled="true" />
<object-type name="COLUMN" enabled="true" />
<object-type name="INDEX" enabled="true" />
<object-type name="CONSTRAINT" enabled="true" />
<object-type name="DATASET_TRIGGER" enabled="true" />
<object-type name="DATABASE_TRIGGER" enabled="true" />
<object-type name="SYNONYM" enabled="true" />
<object-type name="SEQUENCE" enabled="true" />
<object-type name="PROCEDURE" enabled="true" />
<object-type name="FUNCTION" enabled="true" />
<object-type name="PACKAGE" enabled="true" />
<object-type name="TYPE" enabled="true" />
<object-type name="TYPE_ATTRIBUTE" enabled="true" />
<object-type name="ARGUMENT" enabled="true" />
<object-type name="DIMENSION" enabled="true" />
<object-type name="CLUSTER" enabled="true" />
<object-type name="DBLINK" enabled="true" />
</object-type-filter>
</filters>
<sorting>
<object-type name="COLUMN" sorting-type="NAME" />
<object-type name="FUNCTION" sorting-type="NAME" />
<object-type name="PROCEDURE" sorting-type="NAME" />
<object-type name="ARGUMENT" sorting-type="POSITION" />
</sorting>
<default-editors>
<object-type name="VIEW" editor-type="SELECTION" />
<object-type name="PACKAGE" editor-type="SELECTION" />
<object-type name="TYPE" editor-type="SELECTION" />
</default-editors>
</browser-settings>
<navigation-settings>
<lookup-filters>
<lookup-objects>
<object-type name="SCHEMA" enabled="true" />
<object-type name="USER" enabled="false" />
<object-type name="ROLE" enabled="false" />
<object-type name="PRIVILEGE" enabled="false" />
<object-type name="CHARSET" enabled="false" />
<object-type name="TABLE" enabled="true" />
<object-type name="VIEW" enabled="true" />
<object-type name="MATERIALIZED VIEW" enabled="true" />
<object-type name="NESTED TABLE" enabled="false" />
<object-type name="COLUMN" enabled="false" />
<object-type name="INDEX" enabled="true" />
<object-type name="CONSTRAINT" enabled="true" />
<object-type name="DATASET TRIGGER" enabled="true" />
<object-type name="DATABASE TRIGGER" enabled="true" />
<object-type name="SYNONYM" enabled="false" />
<object-type name="SEQUENCE" enabled="true" />
<object-type name="PROCEDURE" enabled="true" />
<object-type name="FUNCTION" enabled="true" />
<object-type name="PACKAGE" enabled="true" />
<object-type name="TYPE" enabled="true" />
<object-type name="TYPE ATTRIBUTE" enabled="false" />
<object-type name="ARGUMENT" enabled="false" />
<object-type name="DIMENSION" enabled="false" />
<object-type name="CLUSTER" enabled="false" />
<object-type name="DBLINK" enabled="true" />
</lookup-objects>
<force-database-load value="false" />
<prompt-connection-selection value="true" />
<prompt-schema-selection value="true" />
</lookup-filters>
</navigation-settings>
<dataset-grid-settings>
<general>
<enable-zooming value="true" />
<enable-column-tooltip value="true" />
</general>
<sorting>
<nulls-first value="true" />
<max-sorting-columns value="4" />
</sorting>
<tracking-columns>
<columnNames value="" />
<visible value="true" />
<editable value="false" />
</tracking-columns>
</dataset-grid-settings>
<dataset-editor-settings>
<text-editor-popup>
<active value="false" />
<active-if-empty value="false" />
<data-length-threshold value="100" />
<popup-delay value="1000" />
</text-editor-popup>
<values-actions-popup>
<show-popup-button value="true" />
<element-count-threshold value="1000" />
<data-length-threshold value="250" />
</values-actions-popup>
<general>
<fetch-block-size value="100" />
<fetch-timeout value="30" />
<trim-whitespaces value="true" />
<convert-empty-strings-to-null value="true" />
<select-content-on-cell-edit value="true" />
<large-value-preview-active value="true" />
</general>
<filters>
<prompt-filter-dialog value="true" />
<default-filter-type value="BASIC" />
</filters>
<qualified-text-editor text-length-threshold="300">
<content-types>
<content-type name="Text" enabled="true" />
<content-type name="XML" enabled="true" />
<content-type name="DTD" enabled="true" />
<content-type name="HTML" enabled="true" />
<content-type name="XHTML" enabled="true" />
<content-type name="CSS" enabled="true" />
<content-type name="SQL" enabled="true" />
<content-type name="PL/SQL" enabled="true" />
<content-type name="JavaScript" enabled="true" />
<content-type name="JSON" enabled="true" />
<content-type name="JSON5" enabled="true" />
<content-type name="JSP" enabled="true" />
<content-type name="JSPx" enabled="true" />
<content-type name="ASP" enabled="true" />
<content-type name="YAML" enabled="true" />
</content-types>
</qualified-text-editor>
<record-navigation>
<navigation-target value="VIEWER" />
</record-navigation>
</dataset-editor-settings>
<code-editor-settings>
<general>
<show-object-navigation-gutter value="false" />
<show-spec-declaration-navigation-gutter value="true" />
<enable-spellchecking value="true" />
<enable-reference-spellchecking value="false" />
</general>
<confirmations>
<save-changes value="false" />
<revert-changes value="true" />
</confirmations>
</code-editor-settings>
<code-completion-settings>
<filters>
<basic-filter>
<filter-element type="RESERVED_WORD" id="keyword" selected="true" />
<filter-element type="RESERVED_WORD" id="function" selected="true" />
<filter-element type="RESERVED_WORD" id="parameter" selected="true" />
<filter-element type="RESERVED_WORD" id="datatype" selected="true" />
<filter-element type="RESERVED_WORD" id="exception" selected="true" />
<filter-element type="OBJECT" id="schema" selected="true" />
<filter-element type="OBJECT" id="role" selected="true" />
<filter-element type="OBJECT" id="user" selected="true" />
<filter-element type="OBJECT" id="privilege" selected="true" />
<user-schema>
<filter-element type="OBJECT" id="table" selected="true" />
<filter-element type="OBJECT" id="view" selected="true" />
<filter-element type="OBJECT" id="materialized view" selected="true" />
<filter-element type="OBJECT" id="index" selected="true" />
<filter-element type="OBJECT" id="constraint" selected="true" />
<filter-element type="OBJECT" id="trigger" selected="true" />
<filter-element type="OBJECT" id="synonym" selected="false" />
<filter-element type="OBJECT" id="sequence" selected="true" />
<filter-element type="OBJECT" id="procedure" selected="true" />
<filter-element type="OBJECT" id="function" selected="true" />
<filter-element type="OBJECT" id="package" selected="true" />
<filter-element type="OBJECT" id="type" selected="true" />
<filter-element type="OBJECT" id="dimension" selected="true" />
<filter-element type="OBJECT" id="cluster" selected="true" />
<filter-element type="OBJECT" id="dblink" selected="true" />
</user-schema>
<public-schema>
<filter-element type="OBJECT" id="table" selected="false" />
<filter-element type="OBJECT" id="view" selected="false" />
<filter-element type="OBJECT" id="materialized view" selected="false" />
<filter-element type="OBJECT" id="index" selected="false" />
<filter-element type="OBJECT" id="constraint" selected="false" />
<filter-element type="OBJECT" id="trigger" selected="false" />
<filter-element type="OBJECT" id="synonym" selected="false" />
<filter-element type="OBJECT" id="sequence" selected="false" />
<filter-element type="OBJECT" id="procedure" selected="false" />
<filter-element type="OBJECT" id="function" selected="false" />
<filter-element type="OBJECT" id="package" selected="false" />
<filter-element type="OBJECT" id="type" selected="false" />
<filter-element type="OBJECT" id="dimension" selected="false" />
<filter-element type="OBJECT" id="cluster" selected="false" />
<filter-element type="OBJECT" id="dblink" selected="false" />
</public-schema>
<any-schema>
<filter-element type="OBJECT" id="table" selected="true" />
<filter-element type="OBJECT" id="view" selected="true" />
<filter-element type="OBJECT" id="materialized view" selected="true" />
<filter-element type="OBJECT" id="index" selected="true" />
<filter-element type="OBJECT" id="constraint" selected="true" />
<filter-element type="OBJECT" id="trigger" selected="true" />
<filter-element type="OBJECT" id="synonym" selected="true" />
<filter-element type="OBJECT" id="sequence" selected="true" />
<filter-element type="OBJECT" id="procedure" selected="true" />
<filter-element type="OBJECT" id="function" selected="true" />
<filter-element type="OBJECT" id="package" selected="true" />
<filter-element type="OBJECT" id="type" selected="true" />
<filter-element type="OBJECT" id="dimension" selected="true" />
<filter-element type="OBJECT" id="cluster" selected="true" />
<filter-element type="OBJECT" id="dblink" selected="true" />
</any-schema>
</basic-filter>
<extended-filter>
<filter-element type="RESERVED_WORD" id="keyword" selected="true" />
<filter-element type="RESERVED_WORD" id="function" selected="true" />
<filter-element type="RESERVED_WORD" id="parameter" selected="true" />
<filter-element type="RESERVED_WORD" id="datatype" selected="true" />
<filter-element type="RESERVED_WORD" id="exception" selected="true" />
<filter-element type="OBJECT" id="schema" selected="true" />
<filter-element type="OBJECT" id="user" selected="true" />
<filter-element type="OBJECT" id="role" selected="true" />
<filter-element type="OBJECT" id="privilege" selected="true" />
<user-schema>
<filter-element type="OBJECT" id="table" selected="true" />
<filter-element type="OBJECT" id="view" selected="true" />
<filter-element type="OBJECT" id="materialized view" selected="true" />
<filter-element type="OBJECT" id="index" selected="true" />
<filter-element type="OBJECT" id="constraint" selected="true" />
<filter-element type="OBJECT" id="trigger" selected="true" />
<filter-element type="OBJECT" id="synonym" selected="true" />
<filter-element type="OBJECT" id="sequence" selected="true" />
<filter-element type="OBJECT" id="procedure" selected="true" />
<filter-element type="OBJECT" id="function" selected="true" />
<filter-element type="OBJECT" id="package" selected="true" />
<filter-element type="OBJECT" id="type" selected="true" />
<filter-element type="OBJECT" id="dimension" selected="true" />
<filter-element type="OBJECT" id="cluster" selected="true" />
<filter-element type="OBJECT" id="dblink" selected="true" />
</user-schema>
<public-schema>
<filter-element type="OBJECT" id="table" selected="true" />
<filter-element type="OBJECT" id="view" selected="true" />
<filter-element type="OBJECT" id="materialized view" selected="true" />
<filter-element type="OBJECT" id="index" selected="true" />
<filter-element type="OBJECT" id="constraint" selected="true" />
<filter-element type="OBJECT" id="trigger" selected="true" />
<filter-element type="OBJECT" id="synonym" selected="true" />
<filter-element type="OBJECT" id="sequence" selected="true" />
<filter-element type="OBJECT" id="procedure" selected="true" />
<filter-element type="OBJECT" id="function" selected="true" />
<filter-element type="OBJECT" id="package" selected="true" />
<filter-element type="OBJECT" id="type" selected="true" />
<filter-element type="OBJECT" id="dimension" selected="true" />
<filter-element type="OBJECT" id="cluster" selected="true" />
<filter-element type="OBJECT" id="dblink" selected="true" />
</public-schema>
<any-schema>
<filter-element type="OBJECT" id="table" selected="true" />
<filter-element type="OBJECT" id="view" selected="true" />
<filter-element type="OBJECT" id="materialized view" selected="true" />
<filter-element type="OBJECT" id="index" selected="true" />
<filter-element type="OBJECT" id="constraint" selected="true" />
<filter-element type="OBJECT" id="trigger" selected="true" />
<filter-element type="OBJECT" id="synonym" selected="true" />
<filter-element type="OBJECT" id="sequence" selected="true" />
<filter-element type="OBJECT" id="procedure" selected="true" />
<filter-element type="OBJECT" id="function" selected="true" />
<filter-element type="OBJECT" id="package" selected="true" />
<filter-element type="OBJECT" id="type" selected="true" />
<filter-element type="OBJECT" id="dimension" selected="true" />
<filter-element type="OBJECT" id="cluster" selected="true" />
<filter-element type="OBJECT" id="dblink" selected="true" />
</any-schema>
</extended-filter>
</filters>
<sorting enabled="true">
<sorting-element type="RESERVED_WORD" id="keyword" />
<sorting-element type="RESERVED_WORD" id="datatype" />
<sorting-element type="OBJECT" id="column" />
<sorting-element type="OBJECT" id="table" />
<sorting-element type="OBJECT" id="view" />
<sorting-element type="OBJECT" id="materialized view" />
<sorting-element type="OBJECT" id="index" />
<sorting-element type="OBJECT" id="constraint" />
<sorting-element type="OBJECT" id="trigger" />
<sorting-element type="OBJECT" id="synonym" />
<sorting-element type="OBJECT" id="sequence" />
<sorting-element type="OBJECT" id="procedure" />
<sorting-element type="OBJECT" id="function" />
<sorting-element type="OBJECT" id="package" />
<sorting-element type="OBJECT" id="type" />
<sorting-element type="OBJECT" id="dimension" />
<sorting-element type="OBJECT" id="cluster" />
<sorting-element type="OBJECT" id="dblink" />
<sorting-element type="OBJECT" id="schema" />
<sorting-element type="OBJECT" id="role" />
<sorting-element type="OBJECT" id="user" />
<sorting-element type="RESERVED_WORD" id="function" />
<sorting-element type="RESERVED_WORD" id="parameter" />
</sorting>
<format>
<enforce-code-style-case value="true" />
</format>
</code-completion-settings>
<execution-engine-settings>
<statement-execution>
<fetch-block-size value="100" />
<execution-timeout value="20" />
<debug-execution-timeout value="600" />
<focus-result value="false" />
<prompt-execution value="false" />
</statement-execution>
<script-execution>
<command-line-interfaces />
<execution-timeout value="300" />
</script-execution>
<method-execution>
<execution-timeout value="30" />
<debug-execution-timeout value="600" />
<parameter-history-size value="10" />
</method-execution>
</execution-engine-settings>
<operation-settings>
<transactions>
<uncommitted-changes>
<on-project-close value="ASK" />
<on-disconnect value="ASK" />
<on-autocommit-toggle value="ASK" />
</uncommitted-changes>
<multiple-uncommitted-changes>
<on-commit value="ASK" />
<on-rollback value="ASK" />
</multiple-uncommitted-changes>
</transactions>
<session-browser>
<disconnect-session value="ASK" />
<kill-session value="ASK" />
<reload-on-filter-change value="false" />
</session-browser>
<compiler>
<compile-type value="KEEP" />
<compile-dependencies value="ASK" />
<always-show-controls value="false" />
</compiler>
<debugger>
<debugger-type value="JDBC" />
<use-generic-runners value="true" />
</debugger>
</operation-settings>
<ddl-file-settings>
<extensions>
<mapping file-type-id="VIEW" extensions="vw" />
<mapping file-type-id="TRIGGER" extensions="trg" />
<mapping file-type-id="PROCEDURE" extensions="prc" />
<mapping file-type-id="FUNCTION" extensions="fnc" />
<mapping file-type-id="PACKAGE" extensions="pkg" />
<mapping file-type-id="PACKAGE_SPEC" extensions="pks" />
<mapping file-type-id="PACKAGE_BODY" extensions="pkb" />
<mapping file-type-id="TYPE" extensions="tpe" />
<mapping file-type-id="TYPE_SPEC" extensions="tps" />
<mapping file-type-id="TYPE_BODY" extensions="tpb" />
</extensions>
<general>
<lookup-ddl-files value="true" />
<create-ddl-files value="false" />
<synchronize-ddl-files value="true" />
<use-qualified-names value="false" />
<make-scripts-rerunnable value="true" />
</general>
</ddl-file-settings>
<general-settings>
<regional-settings>
<date-format value="MEDIUM" />
<number-format value="UNGROUPED" />
<locale value="SYSTEM_DEFAULT" />
<use-custom-formats value="false" />
</regional-settings>
<environment>
<environment-types>
<environment-type id="development" name="Development" description="Development environment" color="-2430209/-12296320" readonly-code="false" readonly-data="false" />
<environment-type id="integration" name="Integration" description="Integration environment" color="-2621494/-12163514" readonly-code="true" readonly-data="false" />
<environment-type id="production" name="Production" description="Productive environment" color="-11574/-10271420" readonly-code="true" readonly-data="true" />
<environment-type id="other" name="Other" description="" color="-1576/-10724543" readonly-code="false" readonly-data="false" />
</environment-types>
<visibility-settings>
<connection-tabs value="true" />
<dialog-headers value="true" />
<object-editor-tabs value="true" />
<script-editor-tabs value="false" />
<execution-result-tabs value="true" />
</visibility-settings>
</environment>
</general-settings>
</component>
<component name="DBNavigator.Project.StatementExecutionManager">
<execution-variables />
</component>
</project>

View File

@ -41,8 +41,8 @@
500:
$ref: 'api.yaml#/components/responses/500'
put:
summary: TODO change sample
description: 'Auth: basic, levels: write, maintain, dev, admin'
summary: change sample
description: 'Auth: basic, levels: write, maintain, dev, admin, only maintain and admin are allowed to edit samples created by another user'
tags:
- /sample
security:
@ -59,7 +59,7 @@
content:
application/json:
schema:
$ref: 'api.yaml#/components/schemas/SampleDetail'
$ref: 'api.yaml#/components/schemas/SampleRefs'
400:
$ref: 'api.yaml#/components/responses/400'
401:
@ -71,8 +71,8 @@
500:
$ref: 'api.yaml#/components/responses/500'
delete:
summary: TODO delete sample
description: 'Auth: basic, levels: write, maintain, dev, admin'
summary: delete sample
description: 'Auth: basic, levels: write, maintain, dev, admin, only maintain and admin are allowed to edit samples created by another user'
tags:
- /sample
security:

View File

@ -42,19 +42,19 @@ export default class db {
});
mongoose.connection.on('error', console.error.bind(console, 'connection error:'));
mongoose.connection.on('disconnected', () => { // reset state on disconnect
console.log('Database disconnected');
console.info('Database disconnected');
this.state.db = 0;
done();
});
process.on('SIGINT', () => { // close connection when app is terminated
mongoose.connection.close(() => {
console.log('Mongoose default connection disconnected through app termination');
console.info('Mongoose default connection disconnected through app termination');
process.exit(0);
});
});
mongoose.connection.once('open', () => {
mongoose.set('useFindAndModify', false);
console.log(process.env.NODE_ENV === 'test' ? '' : `Connected to ${connectionString}`);
console.info(process.env.NODE_ENV === 'test' ? '' : `Connected to ${connectionString}`);
this.state.db = mongoose.connection;
done();
});
@ -90,13 +90,7 @@ export default class db {
let loadCounter = 0; // count number of loaded collections to know when to return done()
Object.keys(json.collections).forEach(collectionName => { // create each collection
for(let i in json.collections[collectionName]) { // convert $oid fields to actual ObjectIds
Object.keys(json.collections[collectionName][i]).forEach(key => {
if (json.collections[collectionName][i][key] !== null && json.collections[collectionName][i][key].hasOwnProperty('$oid')) {
json.collections[collectionName][i][key] = mongoose.Types.ObjectId(json.collections[collectionName][i][key].$oid);
}
})
}
json.collections[collectionName] = this.oidResolve(json.collections[collectionName]);
this.state.db.createCollection(collectionName, (err, collection) => {
collection.insertMany(json.collections[collectionName], () => { // insert JSON data
if (++ loadCounter >= Object.keys(json.collections).length) { // all collections loaded
@ -106,4 +100,16 @@ export default class db {
});
});
}
private static oidResolve (object: any) { // resolve $oid fields to actual ObjectIds recursively
Object.keys(object).forEach(key => {
if (object[key] !== null && object[key].hasOwnProperty('$oid')) {
object[key] = mongoose.Types.ObjectId(object[key].$oid);
}
else if (typeof object[key] === 'object' && object[key] !== null) {
object[key] = this.oidResolve(object[key]);
}
});
return object;
}
};

View File

@ -30,7 +30,7 @@ export default (mailAddress, subject, content, f) => { // callback, executed em
});
}
else if (process.env.NODE_ENV === 'test') {
console.log('Sending mail to ' + mailAddress + ': -- ' + subject + ' -- ' + content);
console.info('Sending mail to ' + mailAddress + ': -- ' + subject + ' -- ' + content);
f();
}
else { // dev

View File

@ -8,7 +8,7 @@ import db from './db';
// tell if server is running in debug or production environment
console.log(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT =====');
console.info(process.env.NODE_ENV === 'production' ? '===== PRODUCTION =====' : process.env.NODE_ENV === 'test' ? '' :'===== DEVELOPMENT =====');
// mongodb connection
@ -75,7 +75,7 @@ app.use((err, req, res, ignore) => { // internal server error handling
// hook up server to port
const server = app.listen(port, () => {
console.log(process.env.NODE_ENV === 'test' ? '' : `Listening on http://localhost:${port}`);
console.info(process.env.NODE_ENV === 'test' ? '' : `Listening on http://localhost:${port}`);
});
module.exports = server;

View File

@ -171,14 +171,54 @@ describe('/material', () => {
res: {status: 'Material name already taken'}
});
});
it('rejects wrong material properties', done => {
it('rejects a wrong mineral property', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/material/100000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {mineral: 'x', glass_fiber: 'x', carbon_fiber: 'x', numbers: [{colorxx: 'black', number: 'xxx'}]},
res: {status: 'Invalid body format'}
req: {mineral: 'x'},
res: {status: 'Invalid body format', details: '"mineral" must be a number'}
});
});
it('rejects a wrong glass_fiber property', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/material/100000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {glass_fiber: 'x'},
res: {status: 'Invalid body format', details: '"glass_fiber" must be a number'}
});
});
it('rejects a wrong carbon_fiber property', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/material/100000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {carbon_fiber: 'x'},
res: {status: 'Invalid body format', details: '"carbon_fiber" must be a number'}
});
});
it('rejects a wrong color name property', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/material/100000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {numbers: [{colorxx: 'black', number: 55}]},
res: {status: 'Invalid body format', details: '"numbers[0].color" is required'}
});
});
it('rejects a wrong color number property', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/material/100000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {numbers: [{color: 'black', number: 'xxx'}]},
res: {status: 'Invalid body format', details: '"numbers[0].number" must be a number'}
});
});
it('rejects an invalid id', done => {
@ -347,24 +387,94 @@ describe('/material', () => {
res: {status: 'Material name already taken'}
});
});
it('rejects wrong material properties', done => {
it('rejects a missing name', done => {
TestHelper.request(server, done, {
method: 'post',
url: '/material/new',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 'x', glass_fiber: 'x', carbon_fiber: 'x', numbers: [{colorxx: 'black', number: 'xxx'}]},
res: {status: 'Invalid body format'}
req: {supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]},
res: {status: 'Invalid body format', details: '"name" is required'}
});
});
it('rejects incomplete material properties', done => {
it('rejects a missing supplier', done => {
TestHelper.request(server, done, {
method: 'post',
url: '/material/new',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {name: 'Crastin CE 2510'},
res: {status: 'Invalid body format'}
req: {name: 'Crastin CE 2510', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]},
res: {status: 'Invalid body format', details: '"supplier" is required'}
});
});
it('rejects a missing group', done => {
TestHelper.request(server, done, {
method: 'post',
url: '/material/new',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {name: 'Crastin CE 2510', supplier: 'Du Pont', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]},
res: {status: 'Invalid body format', details: '"group" is required'}
});
});
it('rejects a missing mineral property', done => {
TestHelper.request(server, done, {
method: 'post',
url: '/material/new',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]},
res: {status: 'Invalid body format', details: '"mineral" is required'}
});
});
it('rejects a missing glass_fiber property', done => {
TestHelper.request(server, done, {
method: 'post',
url: '/material/new',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, carbon_fiber: 0, numbers: [{color: 'black', number: 5515798402}]},
res: {status: 'Invalid body format', details: '"glass_fiber" is required'}
});
});
it('rejects a missing carbon_fiber property', done => {
TestHelper.request(server, done, {
method: 'post',
url: '/material/new',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, numbers: [{color: 'black', number: 5515798402}]},
res: {status: 'Invalid body format', details: '"carbon_fiber" is required'}
});
});
it('rejects a missing numbers array', done => {
TestHelper.request(server, done, {
method: 'post',
url: '/material/new',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0},
res: {status: 'Invalid body format', details: '"numbers" is required'}
});
});
it('rejects a missing color name', done => {
TestHelper.request(server, done, {
method: 'post',
url: '/material/new',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{number: 5515798402}]},
res: {status: 'Invalid body format', details: '"numbers[0].color" is required'}
});
});
it('rejects a missing color number', done => {
TestHelper.request(server, done, {
method: 'post',
url: '/material/new',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {name: 'Crastin CE 2510', supplier: 'Du Pont', group: 'PBT', mineral: 0, glass_fiber: 30, carbon_fiber: 0, numbers: [{color: 'black'}]},
res: {status: 'Invalid body format', details: '"numbers[0].number" is required'}
});
});
it('rejects an API key', done => {

View File

@ -3,6 +3,7 @@ import express from 'express';
import MaterialValidate from './validate/material';
import MaterialModel from '../models/material'
import IdValidate from './validate/id';
import res400 from './validate/res400';
const router = express.Router();
@ -34,10 +35,7 @@ router.put('/material/' + IdValidate.parameter(), (req, res, next) => {
if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return;
const {error, value: material} = MaterialValidate.input(req.body, 'change');
if (error) {
res.status(400).json({status: 'Invalid body format'});
return;
}
if (error) return res400(error, res);
if (material.hasOwnProperty('name')) {
MaterialModel.find({name: material.name}).lean().exec((err, data) => {
@ -87,10 +85,7 @@ router.post('/material/new', (req, res, next) => {
// validate input
const {error, value: material} = MaterialValidate.input(req.body, 'new');
if (error) {
res.status(400).json({status: 'Invalid body format'});
return;
}
if (error) return res400(error, res);
MaterialModel.find({name: material.name}).lean().exec((err, data) => {
if (err) return next(err);

View File

@ -69,6 +69,387 @@ describe('/sample', () => {
});
});
describe('PUT /sample/{id}', () => {
it('returns the right sample', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 200,
req: {},
res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}
});
});
it('keeps unchanged properties', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 200,
req: {number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', notes: {}},
res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}
});
});
it('changes the given properties', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 200,
req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}
}).end(err => {
if (err) return done (err);
SampleModel.findById('400000000000000000000001').lean().exec((err, data: any) => {
if (err) return done (err);
should(data).have.only.keys('_id', 'number', 'color', 'type', 'batch', 'validated', 'material_id', 'note_id', 'user_id', '__v');
should(data).have.property('_id');
should(data).have.property('number', '10');
should(data).have.property('color', 'signalviolet');
should(data).have.property('type', 'part');
should(data).have.property('batch', '114531');
should(data).have.property('validated').be.type('boolean');
should(data.material_id.toString()).be.eql('100000000000000000000002');
should(data.user_id.toString()).be.eql('000000000000000000000002');
should(data).have.property('note_id');
NoteModel.findById(data.note_id).lean().exec((err, data: any) => {
if (err) return done (err);
should(data).have.property('_id');
should(data).have.property('comment', 'Testcomment');
should(data).have.property('sample_references');
should(data.sample_references).have.lengthOf(1);
should(data.sample_references[0].id.toString()).be.eql('400000000000000000000003');
should(data.sample_references[0]).have.property('relation', 'part to this sample');
done();
});
})
});
});
it('adjusts the note_fields correctly', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000003',
auth: {basic: 'admin'},
httpStatus: 200,
req: {notes: {comment: 'Testcomment', sample_references: [], custom_fields: {field1: 'value 1'}}}
}).end(err => {
if (err) return done(err);
NoteFieldModel.findOne({name: 'not allowed for new applications'}).lean().exec((err, data) => {
console.log(data);
if (err) return done(err);
should(data).have.property('qty', 1);
NoteFieldModel.findOne({name: 'field1'}).lean().exec((err, data) => {
if (err) return done(err);
console.log(data);
should(data).have.property('qty', 1);
done();
});
});
});
});
it('deletes old note_fields', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000004',
auth: {basic: 'admin'},
httpStatus: 200,
req: {notes: {comment: 'Testcomment', sample_references: []}}
}).end(err => {
if (err) return done (err);
NoteFieldModel.findOne({name: 'another_field'}).lean().exec((err, data) => {
if (err) return done (err);
should(data).be.null();
done();
});
});
});
it('keeps untouched notes', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000002',
auth: {basic: 'janedoe'},
httpStatus: 200,
req: {number: '111'}
}).end((err, res) => {
if (err) return done (err);
NoteModel.findById(res.body.note_id).lean().exec((err, data) => {
if (err) return done (err);
console.log(data);
should(data).not.be.null();
should(data).have.property('comment', 'Stoff gesperrt');
should(data).have.property('sample_references').have.lengthOf(0);
done();
});
});
});
it('deletes old notes', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000004',
auth: {basic: 'admin'},
httpStatus: 200,
req: {notes: {comment: 'Testcomment', sample_references: []}}
}).end(err => {
if (err) return done (err);
NoteModel.findById('500000000000000000000003').lean().exec((err, data) => {
if (err) return done (err);
should(data).be.null();
done();
});
});
});
it('rejects a color not defined for the material', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
res: {status: 'Color not available for material'}
});
});
it('rejects an unknown material id', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '000000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
res: {status: 'Material not available'}
});
});
it('rejects a sample number in use', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {number: '21', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
res: {status: 'Sample number already taken'}
});
});
it('rejects an invalid sample reference', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '000000000000000000000003', relation: 'part to this sample'}]}},
res: {status: 'Sample reference not available'}
});
});
it('rejects an invalid material id', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'}
});
});
it('rejects an invalid id', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/10000000000h000000000001',
auth: {basic: 'janedoe'},
httpStatus: 404,
req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
});
});
it('rejects an API key', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000001',
auth: {key: 'janedoe'},
httpStatus: 401,
req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
});
});
it('rejects changes for samples from another user for a write user', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000003',
auth: {basic: 'janedoe'},
httpStatus: 403,
req: {}
});
});
it('accepts changes for samples from another user for a maintain/admin user', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000001',
auth: {basic: 'admin'},
httpStatus: 200,
req: {},
res: {_id: '400000000000000000000001', number: '1', type: 'granulate', color: 'black', batch: '', material_id: '100000000000000000000004', note_id: null, user_id: '000000000000000000000002'}
});
});
it('rejects requests from a read user', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000001',
auth: {basic: 'user'},
httpStatus: 403,
req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
});
});
it('returns 404 for an unknown sample', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/000000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 404,
req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}}
});
})
it('rejects unauthorized requests', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/sample/400000000000000000000001',
httpStatus: 401,
req: {number: '10', type: 'part', color: 'signalviolet', batch: '114531', material_id: '100000000000000000000002', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
});
});
});
describe('DELETE /sample/{id}', () => {
it('deletes the sample', done => {
TestHelper.request(server, done, {
method: 'delete',
url: '/sample/400000000000000000000001',
auth: {basic: 'janedoe'},
httpStatus: 200
}).end((err, res) => {
if (err) return done(err);
should(res.body).be.eql({status: 'OK'});
SampleModel.findById('400000000000000000000001').lean().exec((err, data) => {
if (err) return done(err);
should(data).be.null();
done();
});
});
});
it('deletes the notes of the sample', done => {
TestHelper.request(server, done, {
method: 'delete',
url: '/sample/400000000000000000000002',
auth: {basic: 'janedoe'},
httpStatus: 200
}).end((err, res) => {
if (err) return done(err);
should(res.body).be.eql({status: 'OK'});
NoteModel.findById('500000000000000000000001').lean().exec((err, data) => {
if (err) return done(err);
should(data).be.null();
done();
});
});
});
it('adjusts the note_fields correctly', done => {
TestHelper.request(server, done, {
method: 'delete',
url: '/sample/400000000000000000000004',
auth: {basic: 'admin'},
httpStatus: 200
}).end((err, res) => {
if (err) return done(err);
should(res.body).be.eql({status: 'OK'});
NoteFieldModel.findOne({name: 'not allowed for new applications'}).lean().exec((err, data) => {
if (err) return done(err);
should(data).have.property('qty', 1);
NoteFieldModel.findOne({name: 'another_field'}).lean().exec((err, data) => {
if (err) return done(err);
should(data).be.null();
done();
});
});
});
});
it('resets references to this sample', done => {
TestHelper.request(server, done, {
method: 'delete',
url: '/sample/400000000000000000000003',
auth: {basic: 'admin'},
httpStatus: 200
}).end((err, res) => {
if (err) return done(err);
should(res.body).be.eql({status: 'OK'});
setTimeout(() => { // background action takes some time before we can check
NoteModel.findById('500000000000000000000003').lean().exec((err, data) => {
if (err) return done(err);
console.log(data);
should(data).have.property('sample_references').with.lengthOf(0);
done();
});
}, 100);
});
});
it('lets admin/maintain users delete samples of other users', done => {
TestHelper.request(server, done, {
method: 'delete',
url: '/sample/400000000000000000000001',
auth: {basic: 'admin'},
httpStatus: 200
}).end((err, res) => {
if (err) return done(err);
should(res.body).be.eql({status: 'OK'});
SampleModel.findById('400000000000000000000001').lean().exec((err, data) => {
if (err) return done(err);
should(data).be.null();
done();
});
});
});
it('rejects deleting samples of other users for write users', done => {
TestHelper.request(server, done, {
method: 'delete',
url: '/sample/400000000000000000000004',
auth: {basic: 'janedoe'},
httpStatus: 403
});
});
it('rejects an invalid id', done => {
TestHelper.request(server, done, {
method: 'delete',
url: '/sample/400000000000h00000000004',
auth: {basic: 'janedoe'},
httpStatus: 404
});
});
it('rejects requests from a read user', done => {
TestHelper.request(server, done, {
method: 'delete',
url: '/sample/400000000000000000000004',
auth: {basic: 'user'},
httpStatus: 403
});
});
it('returns 404 for an unknown id', done => {
TestHelper.request(server, done, {
method: 'delete',
url: '/sample/000000000000000000000004',
auth: {basic: 'janedoe'},
httpStatus: 404
});
});
it('rejects an API key', done => {
TestHelper.request(server, done, {
method: 'delete',
url: '/sample/400000000000000000000001',
auth: {key: 'janedoe'},
httpStatus: 401
});
});
it('rejects unauthorized requests', done => {
TestHelper.request(server, done, {
method: 'delete',
url: '/sample/400000000000000000000001',
httpStatus: 401
});
});
});
describe('POST /sample/new', () => {
it('returns the right sample', done => {
TestHelper.request(server, done, {
@ -209,7 +590,7 @@ describe('/sample', () => {
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {number: 'Rng172', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"color" is required'}
});
});
it('rejects a missing sample number', done => {
@ -219,7 +600,7 @@ describe('/sample', () => {
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {color: 'black', type: 'granulate', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"number" is required'}
});
});
it('rejects a missing type', done => {
@ -229,7 +610,7 @@ describe('/sample', () => {
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {number: 'Rng172', color: 'black', batch: '1560237365', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"type" is required'}
});
});
it('rejects a missing batch', done => {
@ -239,7 +620,7 @@ describe('/sample', () => {
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {number: 'Rng172', color: 'black', type: 'granulate', material_id: '100000000000000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"batch" is required'}
});
});
it('rejects a missing material id', done => {
@ -249,7 +630,7 @@ describe('/sample', () => {
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"material_id" is required'}
});
});
it('rejects an invalid material id', done => {
@ -259,7 +640,7 @@ describe('/sample', () => {
auth: {basic: 'janedoe'},
httpStatus: 400,
req: {number: 'Rng172', color: 'black', type: 'granulate', batch: '1560237365', material_id: '10000000000h000000000001', notes: {comment: 'Testcomment', sample_references: [{id: '400000000000000000000003', relation: 'part to this sample'}]}},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"material_id" with value "10000000000h000000000001" fails to match the required pattern: /[0-9a-f]{24}/'}
});
});
it('rejects an API key', done => {

View File

@ -2,10 +2,12 @@ import express from 'express';
import SampleValidate from './validate/sample';
import NoteFieldValidate from './validate/note_field';
import res400 from './validate/res400';
import SampleModel from '../models/sample'
import MaterialModel from '../models/material';
import NoteModel from '../models/note';
import NoteFieldModel from '../models/note_field';
import IdValidate from './validate/id';
@ -20,66 +22,118 @@ router.get('/samples', (req, res, next) => {
})
});
router.post('/sample/new', (req, res, next) => {
router.put('/sample/' + IdValidate.parameter(), (req, res, next) => {
if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return;
const {error, value: sample} = SampleValidate.input(req.body, 'new');
if (error) {
return res.status(400).json({status: 'Invalid body format'});
}
const {error, value: sample} = SampleValidate.input(req.body, 'change');
if (error) return res400(error, res);
MaterialModel.findById(sample.material_id).lean().exec((err, data: any) => { // validate material_id
SampleModel.findById(req.params.id).lean().exec(async (err, sampleData: any) => { // check if id exists
if (err) return next(err);
if (!data) { // could not find material_id
return res.status(400).json({status: 'Material not available'});
if (!sampleData) {
return res.status(404).json({status: 'Not found'});
}
if (!data.numbers.find(e => e.color === sample.color)) { // color for material not specified
return res.status(400).json({status: 'Color not available for material'});
}
SampleModel.findOne({number: sample.number}).lean().exec((err, data) => { // validate sample number
if (err) return next(err);
if (data) { // found entry with sample number
return res.status(400).json({status: 'Sample number already taken'});
}
// only maintain and admin are allowed to edit other user's data
if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return;
if (sample.notes.sample_references.length > 0) { // validate sample_references
let referencesCount = sample.notes.sample_references.length;
sample.notes.sample_references.forEach(reference => {
SampleModel.findById(reference.id).lean().exec((err, data) => {
if (err) return next(err);
if (!data) {
return res.status(400).json({status: 'Sample reference not available'});
}
referencesCount --;
if (referencesCount <= 0) {
f();
}
if (sample.hasOwnProperty('number') && sample.number !== sampleData.number) {
if (!await numberCheck(sample, res, next)) return;
}
if (sample.hasOwnProperty('material_id')) {
if (!await materialCheck(sample, res, next)) return;
}
else if (sample.hasOwnProperty('color')) {
if (!await materialCheck(sample, res, next, sampleData.material_id)) return;
}
if (sample.hasOwnProperty('notes') && sampleData.note_id !== null) { // deal with old notes data
NoteModel.findById(sampleData.note_id).lean().exec((err, data: any) => {
if (err) return console.error(err);
if (data.hasOwnProperty('custom_fields')) { // update note_fields
customFieldsChange(Object.keys(data.custom_fields), -1);
}
NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec(err => { // delete old notes
if (err) return console.error(err);
})
});
}
if (sample.hasOwnProperty('notes') && Object.keys(sample.notes).length > 0) { // save new notes
if (!await sampleRefCheck(sample, res, next)) return;
if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields
customFieldsChange(Object.keys(sample.notes.custom_fields), 1);
}
let data = await new NoteModel(sample.notes).save().catch(err => { return next(err)}); // save new notes
delete sample.notes;
sample.note_id = data._id;
}
SampleModel.findByIdAndUpdate(req.params.id, sample, {new: true}).lean().exec((err, data) => {
if (err) return next(err);
res.json(SampleValidate.output(data));
});
});
});
router.delete('/sample/' + IdValidate.parameter(), (req, res, next) => {
if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return;
SampleModel.findById(req.params.id).lean().exec(async (err, sampleData: any) => { // check if id exists
if (err) return next(err);
if (!sampleData) {
return res.status(404).json({status: 'Not found'});
}
// only maintain and admin are allowed to edit other user's data
if (sampleData.user_id.toString() !== req.authDetails.id && !req.auth(res, ['maintain', 'admin'], 'basic')) return;
SampleModel.findByIdAndDelete(req.params.id).lean().exec(err => { // delete sample
if (err) return next(err);
if (sampleData.note_id !== null) {
NoteModel.findByIdAndDelete(sampleData.note_id).lean().exec((err, data: any) => { // delete notes
if (err) return next(err);
console.log(data);
if (data.hasOwnProperty('custom_fields')) { // update note_fields
customFieldsChange(Object.keys(data.custom_fields), -1);
}
res.json({status: 'OK'});
NoteModel.updateMany({'sample_references.id': req.params.id}, {$unset: {'sample_references.$': null}}).lean().exec(err => { // remove sample_references
if (err) console.error(err);
NoteModel.collection.updateMany({sample_references: null}, {$pull: {sample_references: null}}, err => { // only works with native MongoDB driver somehow
if (err) console.error(err);
});
});
});
}
else {
f();
}
if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) {
customFieldsAdd(Object.keys(sample.notes.custom_fields));
}
function f() { // to resolve async
new NoteModel(sample.notes).save((err, data) => {
if (err) return next(err);
delete sample.notes;
sample.note_id = data._id;
sample.user_id = req.authDetails.id;
new SampleModel(sample).save((err, data) => {
if (err) return next(err);
res.json(SampleValidate.output(data.toObject()));
});
});
res.json({status: 'OK'});
}
});
})
});
});
router.post('/sample/new', async (req, res, next) => {
if (!req.auth(res, ['write', 'maintain', 'dev', 'admin'], 'basic')) return;
const {error, value: sample} = SampleValidate.input(req.body, 'new');
if (error) return res400(error, res);
if (!await numberCheck(sample, res, next)) return;
if (!await materialCheck(sample, res, next)) return;
if (!await sampleRefCheck(sample, res, next)) return;
if (sample.notes.hasOwnProperty('custom_fields') && Object.keys(sample.notes.custom_fields).length > 0) { // new custom_fields
customFieldsChange(Object.keys(sample.notes.custom_fields), 1);
}
new NoteModel(sample.notes).save((err, data) => {
if (err) return next(err);
delete sample.notes;
sample.note_id = data._id;
sample.user_id = req.authDetails.id;
new SampleModel(sample).save((err, data) => {
if (err) return next(err);
res.json(SampleValidate.output(data.toObject()));
});
});
});
router.get('/sample/notes/fields', (req, res, next) => {
@ -95,15 +149,69 @@ router.get('/sample/notes/fields', (req, res, next) => {
module.exports = router;
function customFieldsAdd (fields) {
async function numberCheck (sample, res, next) { // validate number, returns false if invalid
const sampleData = await SampleModel.findOne({number: sample.number}).lean().exec().catch(err => { return next(err)});
if (sampleData) { // found entry with sample number
res.status(400).json({status: 'Sample number already taken'});
return false
}
return true;
}
async function materialCheck (sample, res, next, id = sample.material_id) { // validate material_id and color, returns false if invalid
const materialData = await MaterialModel.findById(id).lean().exec().catch(err => {next(err);}) as any;
if (materialData instanceof Error) {
return false;
}
if (!materialData) { // could not find material_id
res.status(400).json({status: 'Material not available'});
return false;
}
if (sample.hasOwnProperty('color') && !materialData.numbers.find(e => e.color === sample.color)) { // color for material not specified
res.status(400).json({status: 'Color not available for material'});
return false;
}
return true;
}
function sampleRefCheck (sample, res, next) { // validate sample_references, resolves false for invalid reference
return new Promise(resolve => {
if (sample.notes.sample_references.length > 0) { // there are sample_references
let referencesCount = sample.notes.sample_references.length;
sample.notes.sample_references.forEach(reference => {
SampleModel.findById(reference.id).lean().exec((err, data) => {
if (err) {next(err); resolve(false)}
if (!data) {
res.status(400).json({status: 'Sample reference not available'});
return resolve(false);
}
referencesCount --;
if (referencesCount <= 0) {
resolve(true);
}
});
});
}
else {
resolve(true);
}
});
}
function customFieldsChange (fields, amount) {
fields.forEach(field => {
NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: 1}}).lean().exec((err, data) => { // check if field exists
NoteFieldModel.findOneAndUpdate({name: field}, {$inc: {qty: amount}}, {new: true}).lean().exec((err, data: any) => { // check if field exists
if (err) return console.error(err);
if (!data) { // new field
new NoteFieldModel({name: field, qty: 1}).save(err => {
if (err) return console.error(err);
})
}
else if (data.qty <= 0) {
NoteFieldModel.findOneAndDelete({name: field}).lean().exec(err => {
if (err) return console.error(err);
});
}
});
});
}

View File

@ -182,14 +182,54 @@ describe('/template', () => {
});
});
});
it('rejects an incomplete template for a new name', done => {
it('rejects a missing name for a new name', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/template/treatment/heat%20aging',
auth: {basic: 'admin'},
httpStatus: 400,
req: {parameters: [{name: 'time'}]},
res: {status: 'Invalid body format'}
req: {parameters: [{name: 'time', range: {min: 1}}]},
res: {status: 'Invalid body format', details: '"name" is required'}
});
});
it('rejects missing parameters for a new name', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/template/treatment/heat%20aging',
auth: {basic: 'admin'},
httpStatus: 400,
req: {name: 'heat aging'},
res: {status: 'Invalid body format', details: '"parameters" is required'}
});
});
it('rejects a missing parameter name for a new name', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/template/treatment/heat%20aging',
auth: {basic: 'admin'},
httpStatus: 400,
req: {name: 'heat aging', parameters: [{range: {min: 1}}]},
res: {status: 'Invalid body format', details: '"parameters[0].name" is required'}
});
});
it('rejects a missing parameter range for a new name', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/template/treatment/heat%20aging',
auth: {basic: 'admin'},
httpStatus: 400,
req: {name: 'heat aging', parameters: [{name: 'time'}]},
res: {status: 'Invalid body format', details: '"parameters[0].range" is required'}
});
});
it('rejects a an invalid parameter range property for a new name', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/template/treatment/heat%20aging',
auth: {basic: 'admin'},
httpStatus: 400,
req: {name: 'heat aging', parameters: [{name: 'time', range: {xx: 1}}]},
res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'}
});
});
it('rejects already existing names', done => {
@ -209,7 +249,7 @@ describe('/template', () => {
auth: {basic: 'admin'},
httpStatus: 400,
req: {parameters: [{name: 'time'}], xx: 33},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"name" is required'}
});
});
it('rejects an API key', done => {
@ -466,14 +506,54 @@ describe('/template', () => {
});
});
});
it('rejects an incomplete template for a new name', done => {
it('rejects a missing name for a new name', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/template/measurement/vz',
url: '/template/measurement/spectrum2',
auth: {basic: 'admin'},
httpStatus: 400,
req: {parameters: [{name: 'vz'}]},
res: {status: 'Invalid body format'}
req: {parameters: [{name: 'data point table', range: {min: 0, max: 1000}}]},
res: {status: 'Invalid body format', details: '"name" is required'}
});
});
it('rejects missing parameters for a new name', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/template/measurement/spectrum2',
auth: {basic: 'admin'},
httpStatus: 400,
req: {name: 'IR spectrum'},
res: {status: 'Invalid body format', details: '"parameters" is required'}
});
});
it('rejects a missing parameter name for a new name', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/template/measurement/spectrum2',
auth: {basic: 'admin'},
httpStatus: 400,
req: {name: 'IR spectrum', parameters: [{range: {min: 0, max: 1000}}]},
res: {status: 'Invalid body format', details: '"parameters[0].name" is required'}
});
});
it('rejects a missing parameter range for a new name', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/template/measurement/spectrum2',
auth: {basic: 'admin'},
httpStatus: 400,
req: {name: 'IR spectrum', parameters: [{name: 'data point table'}]},
res: {status: 'Invalid body format', details: '"parameters[0].range" is required'}
});
});
it('rejects a an invalid parameter range property for a new name', done => {
TestHelper.request(server, done, {
method: 'put',
url: '/template/measurement/spectrum2',
auth: {basic: 'admin'},
httpStatus: 400,
req: {name: 'IR spectrum', parameters: [{name: 'data point table', range: {xx: 0}}]},
res: {status: 'Invalid body format', details: '"parameters[0].range.xx" is not allowed'}
});
});
it('rejects already existing names', done => {
@ -493,7 +573,7 @@ describe('/template', () => {
auth: {basic: 'admin'},
httpStatus: 400,
req: {parameters: [{name: 'dpt'}], xx: 33},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"parameters[0].range" is required'}
});
});
it('rejects an API key', done => {

View File

@ -3,6 +3,7 @@ import express from 'express';
import TemplateValidate from './validate/template';
import TemplateTreatmentModel from '../models/treatment_template';
import TemplateMeasurementModel from '../models/measurement_template';
import res400 from './validate/res400';
const router = express.Router();
@ -41,10 +42,7 @@ router.put('/template/:collection(measurement|treatment)/:name', (req, res, next
if (err) next (err);
const templateState = data? 'change': 'new';
const {error, value: template} = TemplateValidate.input(req.body, templateState);
if (error) {
res.status(400).json({status: 'Invalid body format'});
return;
}
if (error) return res400(error, res);
if (template.hasOwnProperty('name') && template.name !== req.params.name) {
collectionModel.find({name: template.name}).lean().exec((err, data) => {

View File

@ -224,7 +224,7 @@ describe('/user', () => {
req: {level: 'read'}
}).end((err, res) => {
if (err) return done (err);
should(res.body).be.eql({status: 'Invalid body format'});
should(res.body).be.eql({status: 'Invalid body format', details: '"level" is not allowed'});
UserModel.find({name: 'janedoe'}).lean().exec( (err, data) => {
if (err) return done(err);
should(data).have.lengthOf(1);
@ -267,7 +267,7 @@ describe('/user', () => {
auth: {basic: 'admin'},
httpStatus: 400,
req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', location: 44, device_name: 'Alpha II'},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"location" must be a string'}
});
});
it('rejects an invalid email address', done => {
@ -277,7 +277,7 @@ describe('/user', () => {
auth: {basic: 'admin'},
httpStatus: 400,
req: {email: 'john.doe'},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"email" must be a valid email'}
});
});
it('rejects an invalid password', done => {
@ -287,7 +287,7 @@ describe('/user', () => {
auth: {basic: 'admin'},
httpStatus: 400,
req: {pass: 'password'},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$/'}
});
});
it('rejects requests from non-admins for another user', done => {
@ -515,7 +515,7 @@ describe('/user', () => {
auth: {basic: 'admin'},
httpStatus: 400,
req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 44, device_name: 'Alpha II'},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"location" must be a string'}
});
});
it('rejects an invalid user level', done => {
@ -525,7 +525,7 @@ describe('/user', () => {
auth: {basic: 'admin'},
httpStatus: 400,
req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'Abc123!#', level: 'xxx', location: 'Rng', device_name: 'Alpha II'},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"level" must be one of [read, write, maintain, dev, admin]'}
});
});
it('rejects an invalid email address', done => {
@ -535,7 +535,7 @@ describe('/user', () => {
auth: {basic: 'admin'},
httpStatus: 400,
req: {email: 'john.doe', name: 'johndoe', pass: 'Abc123!#', level: 'read', location: 'Rng', device_name: 'Alpha II'},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"email" must be a valid email'}
});
});
it('rejects an invalid password', done => {
@ -545,7 +545,7 @@ describe('/user', () => {
auth: {basic: 'admin'},
httpStatus: 400,
req: {email: 'john.doe@bosch.com', name: 'johndoe', pass: 'password', level: 'read', location: 'Rng', device_name: 'Alpha II'},
res: {status: 'Invalid body format'}
res: {status: 'Invalid body format', details: '"pass" with value "password" fails to match the required pattern: /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$/'}
});
});
it('rejects requests from non-admins', done => {

View File

@ -5,6 +5,7 @@ import bcrypt from 'bcryptjs';
import UserValidate from './validate/user';
import UserModel from '../models/user';
import mail from '../helpers/mail';
import res400 from './validate/res400';
const router = express.Router();
@ -46,10 +47,7 @@ router.put('/user:username([/](?!key|new).?*|/?)', (req, res, next) => { // thi
username = req.params.username;
}
const {error, value: user} = UserValidate.input(req.body, 'change' + (req.authDetails.level === 'admin'? 'admin' : ''));
if (error) {
res.status(400).json({status: 'Invalid body format'});
return;
}
if (error) return res400(error, res);
if (user.hasOwnProperty('pass')) {
user.pass = bcrypt.hashSync(user.pass, 10);
@ -122,10 +120,7 @@ router.post('/user/new', (req, res, next) => {
// validate input
const {error, value: user} = UserValidate.input(req.body, 'new');
if (error) {
res.status(400).json({status: 'Invalid body format'});
return;
}
if (error) return res400(error, res);
// check that user does not already exist
UserModel.find({name: user.name}).lean().exec( (err, data:any) => {

View File

@ -1,7 +1,7 @@
import joi from '@hapi/joi';
import Joi from '@hapi/joi';
export default class IdValidate {
private static id = joi.string().pattern(new RegExp('[0-9a-f]{24}')).length(24);
private static id = Joi.string().pattern(new RegExp('[0-9a-f]{24}')).length(24);
static get () {
return this.id;

View File

@ -31,9 +31,11 @@ export default class MaterialValidate { // validate input for material
numbers: joi.array()
.items(joi.object({
color: joi.string()
.max(128),
.max(128)
.required(),
number: joi.number()
.min(0)
.required()
}))
};
@ -46,7 +48,7 @@ export default class MaterialValidate { // validate input for material
mineral: this.material.mineral.required(),
glass_fiber: this.material.glass_fiber.required(),
carbon_fiber: this.material.carbon_fiber.required(),
numbers: this.material.numbers
numbers: this.material.numbers.required()
}).validate(data);
}
else if (param === 'change') {

View File

@ -1,15 +1,15 @@
import joi from '@hapi/joi';
import Joi from '@hapi/joi';
export default class NoteFieldValidate {
private static note_field = {
name: joi.string()
name: Joi.string()
.max(128),
qty: joi.number()
qty: Joi.number()
};
static output (data) {
const {value, error} = joi.object({
const {value, error} = Joi.object({
name: this.note_field.name,
qty: this.note_field.qty
}).validate(data, {stripUnknown: true});

View File

@ -0,0 +1,3 @@
export default function res400 (error, res) {
res.status(400).json({status: 'Invalid body format', details: error.details[0].message});
}

View File

@ -1,41 +1,41 @@
import joi from '@hapi/joi';
import Joi from '@hapi/joi';
import IdValidate from './id';
export default class SampleValidate {
private static sample = {
number: joi.string()
number: Joi.string()
.max(128),
color: joi.string()
color: Joi.string()
.max(128),
type: joi.string()
type: Joi.string()
.max(128),
batch: joi.string()
batch: Joi.string()
.max(128)
.allow(''),
notes: joi.object({
comment: joi.string()
notes: Joi.object({
comment: Joi.string()
.max(512),
sample_references: joi.array()
.items(joi.object({
sample_references: Joi.array()
.items(Joi.object({
id: IdValidate.get(),
relation: joi.string()
relation: Joi.string()
.max(128)
})),
custom_fields: joi.object()
.pattern(/.*/, joi.alternatives()
custom_fields: Joi.object()
.pattern(/.*/, Joi.alternatives()
.try(
joi.string().max(128),
joi.number(),
joi.boolean(),
joi.date()
Joi.string().max(128),
Joi.number(),
Joi.boolean(),
Joi.date()
)
)
})
@ -43,7 +43,7 @@ export default class SampleValidate {
static input (data, param) { // validate data, param: new(everything required)/change(available attributes are validated)
if (param === 'new') {
return joi.object({
return Joi.object({
number: this.sample.number.required(),
color: this.sample.color.required(),
type: this.sample.type.required(),
@ -53,7 +53,14 @@ export default class SampleValidate {
}).validate(data);
}
else if (param === 'change') {
return{error: 'Not implemented!', value: {}};
return Joi.object({
number: this.sample.number,
color: this.sample.color,
type: this.sample.type,
batch: this.sample.batch,
material_id: IdValidate.get(),
notes: this.sample.notes,
}).validate(data);
}
else {
return{error: 'No parameter specified!', value: {}};
@ -62,7 +69,7 @@ export default class SampleValidate {
static output (data) {
data = IdValidate.stringify(data);
const {value, error} = joi.object({
const {value, error} = Joi.object({
_id: IdValidate.get(),
number: this.sample.number,
color: this.sample.color,

View File

@ -1,32 +1,32 @@
import joi from '@hapi/joi';
import Joi from '@hapi/joi';
import globals from '../../globals';
import IdValidate from './id';
export default class UserValidate { // validate input for user
private static user = {
name: joi.string()
name: Joi.string()
.alphanum()
.lowercase()
.max(128),
email: joi.string()
email: Joi.string()
.email({minDomainSegments: 2})
.lowercase()
.max(128),
pass: joi.string()
pass: Joi.string()
.pattern(new RegExp('^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[!"#%&\'()*+,-.\\/:;<=>?@[\\]^_`{|}~])(?=\\S+$).{8,}$'))
.max(128),
level: joi.string()
level: Joi.string()
.valid(...globals.levels),
location: joi.string()
location: Joi.string()
.alphanum()
.max(128),
device_name: joi.string()
device_name: Joi.string()
.allow('')
.max(128),
};
@ -35,7 +35,7 @@ export default class UserValidate { // validate input for user
static input (data, param) {
if (param === 'new') {
return joi.object({
return Joi.object({
name: this.user.name.required(),
email: this.user.email.required(),
pass: this.user.pass.required(),
@ -45,7 +45,7 @@ export default class UserValidate { // validate input for user
}).validate(data);
}
else if (param === 'change') {
return joi.object({
return Joi.object({
name: this.user.name,
email: this.user.email,
pass: this.user.pass,
@ -54,7 +54,7 @@ export default class UserValidate { // validate input for user
}).validate(data);
}
else if (param === 'changeadmin') {
return joi.object({
return Joi.object({
name: this.user.name,
email: this.user.email,
pass: this.user.pass,
@ -70,7 +70,7 @@ export default class UserValidate { // validate input for user
static output (data) { // validate output from database for needed properties, strip everything else
data = IdValidate.stringify(data);
const {value, error} = joi.object({
const {value, error} = Joi.object({
_id: IdValidate.get(),
name: this.user.name,
email: this.user.email,

View File

@ -61,7 +61,7 @@
"_id": {"$oid":"500000000000000000000002"},
"comment": "",
"sample_references": [{
"id": "400000000000000000000004",
"id": {"$oid":"400000000000000000000004"},
"relation": "granulate to sample"
}],
"custom_fields": {
@ -73,11 +73,12 @@
"_id": {"$oid":"500000000000000000000003"},
"comment": "",
"sample_references": [{
"id": "400000000000000000000003",
"id": {"$oid":"400000000000000000000003"},
"relation": "part to sample"
}],
"custom_fields": {
"not allowed for new applications": true
"not allowed for new applications": true,
"another_field": "is there"
},
"__v": 0
}
@ -88,6 +89,12 @@
"name": "not allowed for new applications",
"qty": 2,
"__v": 0
},
{
"_id": {"$oid":"600000000000000000000002"},
"name": "another_field",
"qty": 1,
"__v": 0
}
],
"materials": [