diff --git a/backend/package-lock.json b/backend/package-lock.json index d86891c..00b6303 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -707,7 +707,6 @@ "version": "6.10.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz", "integrity": "sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==", - "dev": true, "requires": { "fast-deep-equal": "^2.0.1", "fast-json-stable-stringify": "^2.0.0", @@ -1126,7 +1125,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "dev": true, "requires": { "safer-buffer": "~2.1.0" } @@ -1134,8 +1132,7 @@ "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, "assign-symbols": { "version": "1.0.0", @@ -1185,8 +1182,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "atob": { "version": "2.1.2", @@ -1197,14 +1193,12 @@ "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", - "dev": true + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, "aws4": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", - "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==", - "dev": true + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" }, "axobject-query": { "version": "2.0.2", @@ -1397,7 +1391,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "dev": true, "requires": { "tweetnacl": "^0.14.3" } @@ -1585,8 +1578,7 @@ "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", - "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", - "dev": true + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" }, "busboy": { "version": "0.3.1", @@ -1674,8 +1666,7 @@ "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", - "dev": true + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, "chalk": { "version": "2.4.2", @@ -1888,7 +1879,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.7.tgz", "integrity": "sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -1916,6 +1906,18 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "optional": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, "configstore": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/configstore/-/configstore-3.1.2.tgz", @@ -2114,7 +2116,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -2269,8 +2270,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, "delegates": { "version": "1.0.0", @@ -2359,7 +2359,6 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", - "dev": true, "requires": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -2436,6 +2435,12 @@ "is-symbol": "^1.0.2" } }, + "es6-promise": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.6.tgz", + "integrity": "sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q==", + "optional": true + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -2913,8 +2918,7 @@ "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, "extend-shallow": { "version": "3.0.2", @@ -3024,17 +3028,27 @@ } } }, + "extract-zip": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.6.7.tgz", + "integrity": "sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k=", + "optional": true, + "requires": { + "concat-stream": "1.6.2", + "debug": "2.6.9", + "mkdirp": "0.5.1", + "yauzl": "2.4.1" + } + }, "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "dev": true + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, "fast-deep-equal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", - "dev": true + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" }, "fast-diff": { "version": "1.2.0", @@ -3062,6 +3076,15 @@ "bser": "^2.0.0" } }, + "fd-slicer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", + "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=", + "optional": true, + "requires": { + "pend": "~1.2.0" + } + }, "figures": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", @@ -3181,14 +3204,12 @@ "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", - "dev": true + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, "form-data": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", @@ -3219,6 +3240,17 @@ "resolved": "https://registry.npmjs.org/fs-capacitor/-/fs-capacitor-2.0.1.tgz", "integrity": "sha512-kyV2oaG1/pu9NPosfGACmBym6okgzyg6hEtA5LSUq0dGpGLe278MVfMwVnSHDA/OBcTCHkPNqWL9eIwbPN6dDg==" }, + "fs-extra": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-1.0.0.tgz", + "integrity": "sha1-zTzl9+fLYUWIP8rjGR6Yd/hYeVA=", + "optional": true, + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^2.1.0", + "klaw": "^1.0.0" + } + }, "fs-minipass": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz", @@ -3854,7 +3886,6 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -3951,8 +3982,7 @@ "graceful-fs": { "version": "4.1.15", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", - "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", - "dev": true + "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==" }, "graphql": { "version": "14.2.1", @@ -4035,14 +4065,12 @@ "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", - "dev": true + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" }, "har-validator": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", - "dev": true, "requires": { "ajv": "^6.5.5", "har-schema": "^2.0.0" @@ -4113,6 +4141,16 @@ } } }, + "hasha": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-2.2.0.tgz", + "integrity": "sha1-eNfL/B5tZjA/55g3NlmEUXsvbuE=", + "optional": true, + "requires": { + "is-stream": "^1.0.1", + "pinkie-promise": "^2.0.0" + } + }, "hosted-git-info": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz", @@ -4128,6 +4166,14 @@ "whatwg-encoding": "^1.0.1" } }, + "html-pdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/html-pdf/-/html-pdf-2.2.0.tgz", + "integrity": "sha1-S8+Rwky1YOR6o/rP0DPg4b8kG5E=", + "requires": { + "phantomjs-prebuilt": "^2.1.4" + } + }, "http-errors": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", @@ -4144,7 +4190,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -4754,8 +4799,7 @@ "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", - "dev": true + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" }, "is-symbol": { "version": "1.0.2", @@ -4768,8 +4812,7 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, "is-windows": { "version": "1.0.2", @@ -4791,8 +4834,7 @@ "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, "isobject": { "version": "3.0.1", @@ -4803,8 +4845,7 @@ "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", - "dev": true + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, "istanbul-api": { "version": "2.1.6", @@ -5521,8 +5562,7 @@ "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "dev": true + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, "jsdom": { "version": "11.12.0", @@ -5590,14 +5630,12 @@ "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", - "dev": true + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -5608,8 +5646,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, "json5": { "version": "2.1.0", @@ -5628,6 +5665,15 @@ } } }, + "jsonfile": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", + "optional": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, "jsonwebtoken": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", @@ -5656,7 +5702,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "dev": true, "requires": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -5697,12 +5742,27 @@ "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.0.tgz", "integrity": "sha512-6hHxsp9e6zQU8nXsP+02HGWXwTkOEw6IROhF2ZA28cYbUk4eJ6QbtZvdqZOdD9YPKghG3apk5eOCvs+tLl3lRg==" }, + "kew": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/kew/-/kew-0.7.0.tgz", + "integrity": "sha1-edk9LTM2PW/dKXCzNdkUGtWR15s=", + "optional": true + }, "kind-of": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", "dev": true }, + "klaw": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-1.3.1.tgz", + "integrity": "sha1-QIhDO0azsbolnXh4XY6W9zugJDk=", + "optional": true, + "requires": { + "graceful-fs": "^4.1.9" + } + }, "kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6623,8 +6683,7 @@ "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "dev": true + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" }, "object-assign": { "version": "4.1.1", @@ -6981,11 +7040,41 @@ "pify": "^2.0.0" } }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=", + "optional": true + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", - "dev": true + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "phantomjs-prebuilt": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/phantomjs-prebuilt/-/phantomjs-prebuilt-2.1.16.tgz", + "integrity": "sha1-79ISpKOWbTZHaE6ouniFSb4q7+8=", + "optional": true, + "requires": { + "es6-promise": "^4.0.3", + "extract-zip": "^1.6.5", + "fs-extra": "^1.0.0", + "hasha": "^2.2.0", + "kew": "^0.7.0", + "progress": "^1.1.8", + "request": "^2.81.0", + "request-progress": "^2.0.1", + "which": "^1.2.10" + }, + "dependencies": { + "progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz", + "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=", + "optional": true + } + } }, "pify": { "version": "2.3.0", @@ -6996,14 +7085,12 @@ "pinkie": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", - "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", - "dev": true + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=" }, "pinkie-promise": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", - "dev": true, "requires": { "pinkie": "^2.0.0" } @@ -7177,8 +7264,7 @@ "psl": { "version": "1.1.31", "resolved": "https://registry.npmjs.org/psl/-/psl-1.1.31.tgz", - "integrity": "sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw==", - "dev": true + "integrity": "sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw==" }, "pstree.remy": { "version": "1.1.6", @@ -7199,8 +7285,7 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "q": { "version": "1.5.1", @@ -7393,7 +7478,6 @@ "version": "2.88.0", "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", - "dev": true, "requires": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -7420,14 +7504,12 @@ "punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", - "dev": true + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" }, "tough-cookie": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", - "dev": true, "requires": { "psl": "^1.1.24", "punycode": "^1.4.1" @@ -7435,6 +7517,15 @@ } } }, + "request-progress": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-2.0.1.tgz", + "integrity": "sha1-XTa7V5YcZzqlt4jbyBQf3yO0Tgg=", + "optional": true, + "requires": { + "throttleit": "^1.0.0" + } + }, "request-promise-core": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.2.tgz", @@ -8049,7 +8140,6 @@ "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", - "dev": true, "requires": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -8492,6 +8582,12 @@ "integrity": "sha1-iQN8vJLFarGJJua6TLsgDhVnKmo=", "dev": true }, + "throttleit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz", + "integrity": "sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=", + "optional": true + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -8640,7 +8736,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "dev": true, "requires": { "safe-buffer": "^5.0.1" } @@ -8648,8 +8743,7 @@ "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", - "dev": true + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" }, "type-check": { "version": "0.3.2", @@ -8669,6 +8763,12 @@ "mime-types": "~2.1.18" } }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "optional": true + }, "uglify-js": { "version": "3.5.8", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.5.8.tgz", @@ -8838,7 +8938,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", - "dev": true, "requires": { "punycode": "^2.1.0" } @@ -8907,7 +9006,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -8979,7 +9077,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, "requires": { "isexe": "^2.0.0" } @@ -9224,6 +9321,15 @@ "decamelize": "^1.2.0" } }, + "yauzl": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.4.1.tgz", + "integrity": "sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU=", + "optional": true, + "requires": { + "fd-slicer": "~1.0.1" + } + }, "yup": { "version": "0.26.10", "resolved": "https://registry.npmjs.org/yup/-/yup-0.26.10.tgz", diff --git a/backend/package.json b/backend/package.json index 011646f..792355c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "dotenv": "^7.0.0", "express": "^4.16.4", "graphql": "^14.1.1", + "html-pdf": "^2.2.0", "indicative": "^5.0.8", "jsonwebtoken": "^8.5.1", "mongoose": "^5.4.19" diff --git a/backend/src/constants.js b/backend/src/constants.js new file mode 100644 index 0000000..3a61baa --- /dev/null +++ b/backend/src/constants.js @@ -0,0 +1,18 @@ +exports.TR_FLOW = { + IN: 'IN', + OUT: 'OUT' +}; + +exports.TR_TYPE = { + INVOICE: 'INVOICE', + EXPENSE: 'EXPENSE' +}; + +exports.STORAGE_PATH = { + INVOICE_PENDING: '/invoices/pending', + INVOICE_PAID: '/invoices/paid', + INVOICE_REJECTED: '/invoices/rejected', + EXPENSE_PENDING: '/expenses/pending', + EXPENSE_PAID: '/expenses/paid', + EXPENSE_REJECTED: '/expenses/rejected' +}; diff --git a/backend/src/db.js b/backend/src/db.js index bda52a2..dec81a7 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -12,6 +12,8 @@ module.exports.connect = async () => { await mongoose.connection.createCollection('users'); await mongoose.connection.createCollection('transactions'); await mongoose.connection.createCollection('categories'); + await mongoose.connection.createCollection('companies'); + await mongoose.connection.createCollection('counters'); console.log('Collections created successfully!'); }) .catch(err => { diff --git a/backend/src/graphql/resolvers/mutations.js b/backend/src/graphql/resolvers/mutations.js index 79da88c..edf5505 100644 --- a/backend/src/graphql/resolvers/mutations.js +++ b/backend/src/graphql/resolvers/mutations.js @@ -1,11 +1,28 @@ -const { - validate, - registerValidation, - updateProfileValidation, - expenseValidation -} = require('../../lib/validation'); - -const store = (file, tags, folder, cloudinary) => +const saveOrRetrieveCompany = async (company, Company, upsert) => { + if (!company) return null; + const { name, id } = company; + if (!name && !id) return null; + if (company.id) { + return Company.findById(id); + } + if (company) { + return Company.findOneAndUpdate({ name }, company, { new: true, upsert }); + } + return null; +}; + +const getInvoiceRef = async (id, Counter) => { + const counter = await Counter.findByIdAndUpdate( + id, + { $inc: { sequence: 1 } }, + { new: true, upsert: true } + ); + const INVOICE_REF_SIZE = process.env.INVOICE_REF_SIZE || 4; + const z = '0'; + return `${id}/${`${z.repeat(INVOICE_REF_SIZE)}${counter.sequence}`.slice(-INVOICE_REF_SIZE)}`; +}; + +const store = (file, tags, folder, cloudinary, generated) => new Promise((resolve, reject) => { const uploadStream = cloudinary.uploader.upload_stream({ tags, folder }, (err, image) => { if (image) { @@ -13,11 +30,16 @@ const store = (file, tags, folder, cloudinary) => } return reject(err); }); - file.createReadStream().pipe(uploadStream); + if (!generated) file.createReadStream().pipe(uploadStream); + else file.pipe(uploadStream); }); module.exports = { - register: async (_, { user }, { models: { User } }) => { + register: async ( + _, + { user }, + { models: { User }, validation: { registerValidation, validate } } + ) => { const { formatData, rules, messages } = registerValidation; await validate(formatData({ ...user }), rules, messages); @@ -32,7 +54,14 @@ module.exports = { expenseClaim: async ( unused, { expense }, - { user, models: { Transaction, User }, cloudinary, db } + { + user, + models: { Transaction, User }, + cloudinary, + db, + constants: { TR_TYPE, TR_FLOW, STORAGE_PATH }, + validation: { expenseValidation, validate } + } ) => { expense.date = expense.date || Date.now(); @@ -40,15 +69,20 @@ module.exports = { await validate(formatData({ ...expense }), rules, messages); expense.user = user.id; - expense.flow = 'IN'; - expense.type = 'EXPENSE'; + expense.flow = TR_FLOW.IN; + expense.type = TR_TYPE.EXPENSE; const receipt = await expense.receipt; const session = await db.startSession(); let tr; try { // upload the file to cloudinary - const file = await store(receipt, 'expense receipt', '/expenses/pending/', cloudinary); + const file = await store( + receipt, + 'expense receipt', + STORAGE_PATH.EXPENSE_PENDING, + cloudinary + ); expense.file = file.secure_url; const opts = { session }; @@ -76,7 +110,11 @@ module.exports = { } return tr; }, - updateProfile: async (_, args, { user, models: { User } }) => { + updateProfile: async ( + _, + args, + { user, models: { User }, validation: { updateProfileValidation, validate } } + ) => { const { formatData, rules, messages } = updateProfileValidation; await validate(formatData({ ...args.user }), rules, messages); const { email } = args.user; @@ -87,15 +125,81 @@ module.exports = { } } return User.findOneAndUpdate({ _id: user.id }, args.user, { new: true }); + }, + uploadInvoice: async ( + root, + { invoice }, + { + models: { Transaction, Company, Category }, + cloudinary, + constants: { TR_TYPE, TR_FLOW, STORAGE_PATH }, + validation: { uploadInvoiceValidation, validate } + } + ) => { + const { formatData, rules, messages } = uploadInvoiceValidation; + await validate(formatData({ ...invoice }), rules, messages); + + // look for a company (by id or name), if name is provided: update it or save it if doesn't exist + const company = await saveOrRetrieveCompany(invoice.company, Company, true); + invoice.company = company; + if (invoice.category && (invoice.category.name || invoice.category.id)) { + const category = Category.findOne({ + $or: [{ _id: company.category.id }, { name: company.category.id }] + }); + if (!category) { + throw new Error('Category not found.'); + } + invoice.category = category; + } + invoice.flow = TR_FLOW.IN; + invoice.type = TR_TYPE.INVOICE; + invoice.date = invoice.date || Date.now(); + invoice.invoice = await invoice.invoice; + + const file = await store(invoice.invoice, 'invoice', STORAGE_PATH.INVOICE_PENDING, cloudinary); + invoice.file = file.secure_url; + + return new Transaction(invoice).save(); + }, + generateInvoice: async ( + root, + { invoice }, + { + models: { Transaction, Company, Counter }, + cloudinary, + constants: { TR_TYPE, TR_FLOW, STORAGE_PATH }, + validation: { generateInvoiceValidation, validate }, + generateInvoicePDF + } + ) => { + const { formatData, rules, messages } = generateInvoiceValidation; + + // look for a company (by id or name), if name is provided: update it or save it if doesn't exist + const company = await saveOrRetrieveCompany(invoice.company, Company, true); + invoice.company = company; + + // validate the inputs with the retrieved company, will throw if any required element is missing + await validate(formatData(invoice), rules, messages); + invoice.type = TR_TYPE.INVOICE; + invoice.flow = TR_FLOW.OUT; + invoice.date = Date.now(); + + const noInvoice = await getInvoiceRef(new Date(invoice.date).getFullYear(), Counter); + invoice.ref = noInvoice; + + let file = await generateInvoicePDF( + invoice.details, + { + date: invoice.date, + VAT: invoice.VAT, + noInvoice + }, + invoice.company + ); + + file = await store(file, 'invoice', STORAGE_PATH.INVOICE_PENDING, cloudinary, true); + invoice.file = file.secure_url; + + return new Transaction(invoice).save(); } }; - -// TODO REMOVE EXAMPLE -// { -// "query": "mutation ($amount: Float!, $description: String!, $receipt: Upload!) {expenseClaim(expense: {amount: $amount, description: $description, receipt: $receipt}) {id}}", -// "variables": { -// "amount": 10, -// "description": "Hello World!", -// "receipt": null -// } -// } diff --git a/backend/src/graphql/types.js b/backend/src/graphql/types.js index a7a31e1..8e95b8c 100644 --- a/backend/src/graphql/types.js +++ b/backend/src/graphql/types.js @@ -18,6 +18,8 @@ module.exports = gql` login(email: String!, password: String!): User! @guest expenseClaim(expense: Expense!): Transaction! @auth updateProfile(user: UserUpdateInput!): User! @auth + uploadInvoice(invoice: InvoiceUpload!): Transaction! @auth + generateInvoice(invoice: GenerateInvoiceInput!): Transaction! @auth } type Success { status: Boolean! @@ -45,6 +47,8 @@ module.exports = gql` type Transaction { id: ID! + category: Category + company: Company flow: Flow! state: State! user: User! @@ -55,6 +59,21 @@ module.exports = gql` file: String VAT: Int type: TransactionType! + ref: String + } + + type Category { + id: ID! + name: String! + } + + type Company { + name: String! + email: String + phone: String + VAT: String + bankDetails: BankDetails + address: Address } #inputs @@ -89,12 +108,42 @@ module.exports = gql` input Expense { amount: Float! date: String - expDate: String description: String! receipt: Upload! VAT: Int } + input InvoiceUpload { + amount: Float! + date: String + category: String + company: CompanyInput + expDate: String + invoice: Upload! + VAT: Int + } + + input CompanyInput { + id: ID + name: String + email: String + phone: String + VAT: String + bankDetails: BankDetailsInput + address: AdressInput + } + + input GenerateInvoiceInput { + company: CompanyInput! + details: [GenerateInvoiceDetailsInput!]! + VAT: Int! + } + + input GenerateInvoiceDetailsInput { + description: String! + amount: Float! + } + #enums enum Flow { IN diff --git a/backend/src/index.js b/backend/src/index.js index 705b532..bede14d 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -15,6 +15,10 @@ const models = require('./models'); const db = require('./db'); const auth = require('./auth'); +const validation = require('./lib/validation'); +const constants = require('./constants'); +const generateInvoicePDF = require('./invoiceFactory'); + const PORT = process.env.PORT || 4000; const app = express(); @@ -29,7 +33,18 @@ app.use( const context = async ({ req, res }) => { const user = await auth.loggedUser(req.cookies, models); // adopting injection pattern to ease mocking - return { req, res, user, auth, models, cloudinary, db }; + return { + req, + res, + user, + auth, + models, + cloudinary, + db, + validation, + constants, + generateInvoicePDF + }; }; const server = new ApolloServer({ @@ -69,5 +84,8 @@ module.exports = { auth, server, app, - cloudinary + cloudinary, + validation, + constants, + generateInvoicePDF }; diff --git a/backend/src/invoiceFactory.js b/backend/src/invoiceFactory.js new file mode 100644 index 0000000..a736234 --- /dev/null +++ b/backend/src/invoiceFactory.js @@ -0,0 +1,150 @@ +const pdf = require('html-pdf'); + +const options = { format: 'Letter' }; + +const organization = { + name: 'Open Knowledge Belgium vzw', + address: { + street: 'Cantersteen 12', + city: 'Brussel', + country: 'Belgium', + zipCode: 1000 + }, + VAT: 'BE 0845.419.930', + iban: 'BE45 0688 9551 0289', + logo: 'https://res.cloudinary.com/dwyxk1pns/image/upload/v1557228337/assets/organization-logo.png' +}; + +const renderContactDetails = contact => { + return `${contact.name}
+ ${contact.address.street}
+ ${contact.address.zipCode} ${contact.address.city} ${contact.address.country}
VAT: ${ + contact.VAT + }`; +}; + +module.exports = /* sender */ (details, metadata, receiver) => { + const total = details.reduce((acc, current) => acc + current.amount, 0); + const amountVAT = (total / 100) * metadata.VAT; + const totalInclVAT = total + amountVAT; + const html = ` + + + + + +
+

Invoice

+
${renderContactDetails(receiver)}
+
+ Invoice: ${metadata.noInvoice}
+ Date: ${new Date(metadata.date).toLocaleDateString('be-BE')} +
+ + + + + + + + + ${details + .map( + detail => ` + + + ` + ) + .join('')} + + + + + + + + + + + + + + + + +
DescriptionAmount
${detail.description}€ ${detail.amount}
Total excl. VAT€ ${total}
VAT ${metadata.VAT}%€ ${amountVAT}
Total€ ${totalInclVAT}
+
Please pay this invoice by bank transfer, clearly stating the invoice number, to our account ${ + organization.iban + } within 30 days after date of invoice clearly. +
+
+ + + `; + return new Promise((resolve, reject) => { + pdf.create(html, options).toStream((err, res) => { + if (err) return reject(err); + return resolve(res); + }); + }); +}; diff --git a/backend/src/lib/validation.js b/backend/src/lib/validation.js index de59635..ea2fe16 100644 --- a/backend/src/lib/validation.js +++ b/backend/src/lib/validation.js @@ -14,7 +14,7 @@ configure({ EXISTY_STRICT: true }); -const MAX_LENGTH = 500; +const MAX_LENGTH = process.env.STRING_MAX_CHAR || 500; exports.validate = (data, rules, messages) => { return new Promise((resolve, reject) => { @@ -41,54 +41,102 @@ const minMessage = (field, validation, args) => { const maxMessage = (field, validation, args) => TOO_LONG(field, args[0]); const aboveMessage = (field, validation, args) => MUST_BE_ABOVE(field, args[0]); +const addressValidation = { + rules: { + 'address.street': `required_with_any:address.city,address.country,address.zipCode|max:${MAX_LENGTH}`, + 'address.city': `required_with_any:address.street,address.country,address.zipCode|max:${MAX_LENGTH}`, + 'address.country': `required_with_any:address.street,address.city,address.zipCode|max:${MAX_LENGTH}` + }, + messages: { required_with_any: requiredMessage, max: maxMessage } +}; + +const bankDetailsValidation = { + rules: { + 'bankDetails.bic': `requiredIf:bankDetails.iban|max:${MAX_LENGTH}`, + 'bankDetails.iban': `requiredIf:bankDetails.bic|max:${MAX_LENGTH}` // TODO change to IBAN format + }, + messages: { requiredIf: requiredMessage, max: maxMessage } +}; + +const companyValidation = { + rules: { + 'company.name': `required|max:${MAX_LENGTH}`, + 'company.email': 'email', + 'company.phone': `max:${MAX_LENGTH}`, // TODO change to regex + 'company.VAT': `max:12`, // TODO change to regex ? + ...addressValidation.rules, + ...bankDetailsValidation.rules + }, + messages: { + required: requiredMessage, + max: maxMessage, + email: WRONG_EMAIL_FORMAT, + ...bankDetailsValidation.messages, + ...addressValidation.messages + }, + formatData: data => ({ + ...data, + email: data.email === null ? undefined : data.email, + address: { ...data.address }, + bankDetails: { ...data.bankDetails } + }) +}; + +// module.exports = companyValidation; + +// const categoryValidation = { +// rules: { +// name: `required|max:${MAX_LENGTH}` +// }, +// messages: { +// required: requiredMessage +// } +// }; + +// module.exports = categoryValidation; + exports.registerValidation = { rules: { email: 'email|required', name: `required|max:${MAX_LENGTH}`, password: `required|min:8|max:1000`, - street: `required_with_any:city,country,zipCode|max:${MAX_LENGTH}`, - city: `required_with_any:street,country,zipCode|max:${MAX_LENGTH}`, - country: `required_with_any:street,city,zipCode|max:${MAX_LENGTH}`, - bic: `requiredIf:iban|max:${MAX_LENGTH}`, - iban: `requiredIf:bic|max:${MAX_LENGTH}` // TODO change to IBAN format + ...addressValidation.rules, + ...bankDetailsValidation.rules }, messages: { required: requiredMessage, - requiredIf: requiredMessage, - required_with_any: requiredMessage, + ...addressValidation.messages, + ...bankDetailsValidation.messages, min: minMessage, max: maxMessage, - 'email.email': WRONG_EMAIL_FORMAT + email: WRONG_EMAIL_FORMAT }, formatData: data => { return { email: data.email, name: data.name, password: data.password, - ...data.address, - ...data.bankDetails + bankDetails: { ...data.bankDetails }, + address: { ...data.address } }; } }; exports.updateProfileValidation = { rules: { - email: 'min:1|email', + email: 'email', name: `min:1|max:${MAX_LENGTH}`, password: `min:8|max:1000`, - street: `required_with_any:city,country,zipCode|max:${MAX_LENGTH}`, - city: `required_with_any:street,country,zipCode|max:${MAX_LENGTH}`, - country: `required_with_any:street,city,zipCode|max:${MAX_LENGTH}`, - bic: `requiredIf:iban|max:${MAX_LENGTH}`, - iban: `requiredIf:bic|max:${MAX_LENGTH}` // TODO change to IBAN format + ...addressValidation.rules, + ...bankDetailsValidation.rules }, messages: { required: requiredMessage, - required_with_any: requiredMessage, - requiredIf: requiredMessage, min: minMessage, max: maxMessage, - 'email.email': WRONG_EMAIL_FORMAT + email: WRONG_EMAIL_FORMAT, + ...addressValidation.messages, + ...bankDetailsValidation.messages }, formatData: data => { const formattedData = {}; @@ -99,8 +147,7 @@ exports.updateProfileValidation = { else formattedData.name = ''; if (data.password !== null) formattedData.password = data.password; else formattedData.password = ''; - - return { ...formattedData, ...data.bankDetails, ...data.address }; + return { ...formattedData, bankDetails: { ...data.bankDetails }, address: { ...data.address } }; } }; @@ -121,3 +168,44 @@ exports.expenseValidation = { return { VAT: data.VAT, amount: data.amount, date: data.date, description: data.description }; } }; + +exports.uploadInvoiceValidation = { + rules: { + VAT: 'above:-1', + amount: 'above:-1', + date: 'date', + expDate: 'date', + ...companyValidation.rules, + 'company.name': `min:1|max:${MAX_LENGTH}` + }, + messages: { + above: aboveMessage, + date: INVALID_DATE_FORMAT, + min: minMessage, + max: maxMessage, + ...companyValidation.messages + }, + formatData: data => ({ ...data, company: companyValidation.formatData({ ...data.company }) }) +}; + +exports.generateInvoiceValidation = { + rules: { + 'details.*.description': `required|max:${MAX_LENGTH}`, + 'details.*.amount': `above:-1|max:${MAX_LENGTH}`, + VAT: 'above:-1', + 'company.name': `required|max:${MAX_LENGTH}`, + 'company.address.street': `required|max:${MAX_LENGTH}`, + 'company.address.city': `required|max:${MAX_LENGTH}`, + 'company.address.zipCode': `required`, + 'company.address.country': `required|max:${MAX_LENGTH}` + }, + messages: { + above: aboveMessage, + max: maxMessage, + required: requiredMessage + }, + formatData: data => { + data.details = data.details.map(detail => ({ ...detail })); + return { ...data, details: [...data.details], company: data.company.toJSON() }; + } +}; diff --git a/backend/src/models/Category.js b/backend/src/models/Category.js new file mode 100644 index 0000000..12b0c2e --- /dev/null +++ b/backend/src/models/Category.js @@ -0,0 +1,12 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +const categorySchema = new Schema({ + name: { + type: String, + trim: true + } +}); + +module.exports = mongoose.model('Category', categorySchema); diff --git a/backend/src/models/Company.js b/backend/src/models/Company.js new file mode 100644 index 0000000..451a014 --- /dev/null +++ b/backend/src/models/Company.js @@ -0,0 +1,28 @@ +const mongoose = require('mongoose'); +const bankDetails = require('./bankDetails'); +const address = require('./address'); + +const { Schema } = mongoose; + +const companySchema = new Schema({ + name: { + type: String, + trim: true + }, + email: { + type: String, + trim: true + }, + phone: { + type: String, + trim: true + }, + VAT: { + type: String, + trim: true + }, + bankDetails, + address +}); + +module.exports = mongoose.model('Company', companySchema); diff --git a/backend/src/models/Counter.js b/backend/src/models/Counter.js new file mode 100644 index 0000000..2a335d3 --- /dev/null +++ b/backend/src/models/Counter.js @@ -0,0 +1,10 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +const CounterSchema = new Schema({ + _id: String, + sequence: Number +}); + +module.exports = mongoose.model('Counter', CounterSchema); diff --git a/backend/src/models/Transaction.js b/backend/src/models/Transaction.js index 03dcae9..4fe0353 100644 --- a/backend/src/models/Transaction.js +++ b/backend/src/models/Transaction.js @@ -11,10 +11,12 @@ const transactionSchema = new Schema({ type: String // should be ObjectId }, company: { - type: String // should be ObjectId + type: Schema.Types.ObjectId, + ref: 'Company' }, category: { - type: String // should be ObjectId + type: Schema.Types.ObjectId, + ref: 'Category' }, user: { type: Schema.Types.ObjectId, @@ -46,7 +48,8 @@ const transactionSchema = new Schema({ type: { type: String, enum: ['EXPENSE', 'INVOICE'] - } + }, + ref: String }); module.exports = mongoose.model('Transaction', transactionSchema); diff --git a/backend/src/models/user.js b/backend/src/models/User.js similarity index 91% rename from backend/src/models/user.js rename to backend/src/models/User.js index f9ce09e..dae711f 100644 --- a/backend/src/models/user.js +++ b/backend/src/models/User.js @@ -1,8 +1,8 @@ /* eslint-disable func-names */ const mongoose = require('mongoose'); const bcrypt = require('bcrypt'); -const Address = require('./address'); -const BankDetails = require('./bankDetails'); +const address = require('./address'); +const bankDetails = require('./bankDetails'); const { Schema } = mongoose; @@ -20,8 +20,8 @@ const userSchema = new Schema({ trim: true, lowercase: true }, - address: Address, - bankDetails: BankDetails, + address, + bankDetails, expenses: [ { type: Schema.Types.ObjectId, diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 83b8087..d8ccd86 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -1,4 +1,7 @@ -const User = require('./user'); +const User = require('./User'); const Transaction = require('./Transaction'); +const Company = require('./Company'); +const Category = require('./Category'); +const Counter = require('./Counter'); -module.exports = { User, Transaction }; +module.exports = { User, Transaction, Company, Category, Counter }; diff --git a/backend/src/tests/__snapshots__/e2e.test.js.snap b/backend/src/tests/__snapshots__/e2e.test.js.snap index ac4abd9..2bf1242 100644 --- a/backend/src/tests/__snapshots__/e2e.test.js.snap +++ b/backend/src/tests/__snapshots__/e2e.test.js.snap @@ -23,6 +23,52 @@ Object { } `; +exports[`Server - e2e Expense claim requires authenticated user 1`] = ` +Object { + "data": null, + "errors": Array [ + Object { + "extensions": Object { + "code": "UNAUTHENTICATED", + }, + "locations": Array [ + Object { + "column": 5, + "line": 3, + }, + ], + "message": "You must be logged in.", + "path": Array [ + "expenseClaim", + ], + }, + ], +} +`; + +exports[`Server - e2e Generate invoice requires authenticated user 1`] = ` +Object { + "data": null, + "errors": Array [ + Object { + "extensions": Object { + "code": "UNAUTHENTICATED", + }, + "locations": Array [ + Object { + "column": 3, + "line": 2, + }, + ], + "message": "You must be logged in.", + "path": Array [ + "generateInvoice", + ], + }, + ], +} +`; + exports[`Server - e2e Login fails on incorrect email 1`] = ` Object { "data": null, @@ -155,9 +201,78 @@ exports[`Server - e2e Register succeeds & returns lower case email 1`] = ` Object { "data": Object { "register": Object { + "address": null, + "bankDetails": null, "email": "test@email.com", "name": "Test Test", }, }, } `; + +exports[`Server - e2e Register succeeds & saves address and bankDetails 1`] = ` +Object { + "data": Object { + "register": Object { + "address": Object { + "city": "My city", + "country": "My country", + "street": "My street", + "zipCode": 1000, + }, + "bankDetails": Object { + "bic": "MY BIC", + "iban": "MY IBAN", + }, + "email": "test1@gmail.com", + "name": "Test Test", + }, + }, +} +`; + +exports[`Server - e2e Update profile requires authenticated user 1`] = ` +Object { + "data": null, + "errors": Array [ + Object { + "extensions": Object { + "code": "UNAUTHENTICATED", + }, + "locations": Array [ + Object { + "column": 3, + "line": 2, + }, + ], + "message": "You must be logged in.", + "path": Array [ + "updateProfile", + ], + }, + ], +} +`; + +exports[`Server - e2e Uplaod invoice requires authenticated user 1`] = ` +Object { + "data": null, + "errors": Array [ + Object { + "extensions": Object { + "code": "UNAUTHENTICATED", + }, + "locations": Array [ + Object { + "column": 5, + "line": 3, + }, + ], + "message": "You must be logged in.", + "path": Array [ + "uploadInvoice", + ], + }, + ], +} +`; diff --git a/backend/src/tests/__snapshots__/integration.test.js.snap b/backend/src/tests/__snapshots__/integration.test.js.snap index b3fc3bb..5edb6e8 100644 --- a/backend/src/tests/__snapshots__/integration.test.js.snap +++ b/backend/src/tests/__snapshots__/integration.test.js.snap @@ -1,5 +1,34 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Authenticated user Generate invoice succeeds 1`] = ` +Object { + "data": Object { + "generateInvoice": Object { + "flow": "OUT", + "type": "INVOICE", + }, + }, + "errors": undefined, + "extensions": undefined, + "http": Object { + "headers": Headers { + Symbol(map): Object {}, + }, + }, +} +`; + +exports[`Authenticated user Upload invoice succeeds 1`] = ` +Object { + "data": Object { + "uploadInvoice": Object { + "flow": "IN", + "type": "INVOICE", + }, + }, +} +`; + exports[`Authenticated user claims expenses succeeds 1`] = ` Object { "data": Object { diff --git a/backend/src/tests/__snapshots__/validations.test.js.snap b/backend/src/tests/__snapshots__/validations.test.js.snap new file mode 100644 index 0000000..a8b1dbb --- /dev/null +++ b/backend/src/tests/__snapshots__/validations.test.js.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GENERATE INVOICE VALIDATION fails max length exceeded 1`] = ` +Array [ + Object { + "details.0.description": "[details.*.description] must have a maximum of 50 characters.", + }, + Object { + "company.name": "[company.name] must have a maximum of 50 characters.", + }, + Object { + "company.address.street": "[company.address.street] must have a maximum of 50 characters.", + }, + Object { + "company.address.city": "[company.address.city] must have a maximum of 50 characters.", + }, + Object { + "company.address.country": "[company.address.country] must have a maximum of 50 characters.", + }, +] +`; + +exports[`GENERATE INVOICE VALIDATION fails on missing required fields 1`] = ` +Array [ + Object { + "details.0.description": "[details.*.description] is required (cannot be empty)", + }, + Object { + "company.name": "[company.name] is required (cannot be empty)", + }, + Object { + "company.address.street": "[company.address.street] is required (cannot be empty)", + }, + Object { + "company.address.city": "[company.address.city] is required (cannot be empty)", + }, + Object { + "company.address.zipCode": "[company.address.zipCode] is required (cannot be empty)", + }, + Object { + "company.address.country": "[company.address.country] is required (cannot be empty)", + }, +] +`; diff --git a/backend/src/tests/e2e.test.js b/backend/src/tests/e2e.test.js index 5472a28..daaf91f 100644 --- a/backend/src/tests/e2e.test.js +++ b/backend/src/tests/e2e.test.js @@ -1,15 +1,29 @@ +/* eslint-disable no-unused-vars */ +const FormData = require('form-data'); +const fetch = require('node-fetch'); // import our production apollo-server instance const { server, db } = require('../'); const { startTestServer, toPromise, populate, clean } = require('./utils'); -const { GET_ME, LOGIN_ME_IN, REGISTER, ALL_USERS } = require('./graphql/queryStrings'); +const { + GET_ME, + LOGIN_ME_IN, + REGISTER, + ALL_USERS, + UPDATE_PROFILE, + EXPENSE_CLAIM_WITHOUT_GQL, + INVOICE_UPLOAD_WITHOUT_GQL, + GENERATE_INVOICE +} = require('./graphql/queryStrings'); const testUser = { user: { name: 'Test Test', email: 'tesT@email.com', password: 'testing0189' } }; +const bankDetails = { iban: 'MY IBAN', bic: 'MY BIC' }; +const address = { street: 'My street', city: 'My city', zipCode: 1000, country: 'My country' }; describe('Server - e2e', () => { let stop; let graphql; - + let uri; beforeAll(async () => { await db.connect(); await populate(); @@ -26,6 +40,8 @@ describe('Server - e2e', () => { stop = testServer.stop; // eslint-disable-next-line prefer-destructuring graphql = testServer.graphql; + // eslint-disable-next-line prefer-destructuring + uri = testServer.uri; }); afterEach(async () => { @@ -120,5 +136,104 @@ describe('Server - e2e', () => { expect(res).toMatchSnapshot(); expect(res.data.register.email).toBe('test@email.com'); }); + + it('succeeds & saves address and bankDetails', async () => { + const res = await toPromise( + graphql({ + query: REGISTER, + variables: { + user: { + ...testUser.user, + bankDetails, + address, + email: 'test1@gmail.com' + } + } + }) + ); + expect(res).toMatchSnapshot(); + expect(res.data.register.bankDetails).toBeTruthy(); + expect(res.data.register.address).toBeTruthy(); + }); + }); + + describe('Update profile', () => { + it('requires authenticated user', async () => { + const res = await toPromise( + graphql({ + query: UPDATE_PROFILE, + variables: { user: {} } + }) + ); + expect(res).toMatchSnapshot(); + }); + }); + + describe('Expense claim', () => { + it('requires authenticated user', async () => { + // https://github.com/jaydenseric/graphql-upload/issues/125 + + const body = new FormData(); + + body.append( + 'operations', + JSON.stringify({ + query: EXPENSE_CLAIM_WITHOUT_GQL, + variables: { + amount: 10, + description: 'Hello World!', + receipt: null + } + }) + ); + body.append('map', JSON.stringify({ '0': ['variables.receipt'] })); + body.append('0', 'a', { filename: 'a.pdf' }); + let res = await fetch(uri, { method: 'POST', body }); + res = await res.json(); + expect(res).toMatchSnapshot(); + }); + }); + + describe('Uplaod invoice', () => { + it('requires authenticated user', async () => { + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: INVOICE_UPLOAD_WITHOUT_GQL, + variables: { + amount: 10, + invoice: null + } + }) + ); + body.append('map', JSON.stringify({ '0': ['variables.invoice'] })); + body.append('0', 'a', { filename: 'a.pdf' }); + let res = await fetch(uri, { method: 'POST', body }); + res = await res.json(); + expect(res).toMatchSnapshot(); + }); + }); + + describe('Generate invoice', () => { + it('requires authenticated user', async () => { + const res = await toPromise( + graphql({ + query: GENERATE_INVOICE, + variables: { + invoice: { + VAT: 21, + company: { + name: 'MY SUPER COMP', + VAT: 'MY VAT', + address: { street: '', country: '', city: '', zipCode: 0 } + }, + details: [{ description: '', amount: 200 }] + } + } + }) + ); + expect(res).toMatchSnapshot(); + }); }); }); diff --git a/backend/src/tests/graphql/queryStrings.js b/backend/src/tests/graphql/queryStrings.js index 6c5336d..5b36a9a 100644 --- a/backend/src/tests/graphql/queryStrings.js +++ b/backend/src/tests/graphql/queryStrings.js @@ -24,6 +24,35 @@ module.exports.REGISTER = gql` register(user: $user) { name email + bankDetails { + iban + bic + } + address { + street + city + zipCode + country + } + } + } +`; + +module.exports.UPDATE_PROFILE = gql` + mutation updateProfile($user: UserUpdateInput!) { + updateProfile(user: $user) { + name + email + bankDetails { + iban + bic + } + address { + street + city + zipCode + country + } } } `; @@ -66,3 +95,24 @@ module.exports.EXPENSE_CLAIM_WITHOUT_GQL = ` } } `; + +module.exports.INVOICE_UPLOAD_WITHOUT_GQL = ` + mutation($amount: Float!, $invoice: Upload!) { + uploadInvoice(invoice: { amount: $amount, invoice: $invoice }) { + id + type + flow + } + } +`; + +module.exports.GENERATE_INVOICE = gql` + mutation($invoice: GenerateInvoiceInput!) { + generateInvoice(invoice: $invoice) { + id + type + flow + ref + } + } +`; diff --git a/backend/src/tests/integration.test.js b/backend/src/tests/integration.test.js index 0e5da75..ac6346a 100644 --- a/backend/src/tests/integration.test.js +++ b/backend/src/tests/integration.test.js @@ -3,7 +3,7 @@ const FormData = require('form-data'); const fetch = require('node-fetch'); const fs = require('fs'); const { auth } = require('./mocks/index'); -const { db, models, cloudinary } = require('../'); +const { db, models, cloudinary, validation, constants, generateInvoicePDF } = require('../'); const { constructTestServer, startTestServer, populate, clean } = require('./utils'); const { @@ -11,10 +11,15 @@ const { GET_ME, REGISTER, ALL_USERS, - EXPENSE_CLAIM_WITHOUT_GQL + EXPENSE_CLAIM_WITHOUT_GQL, + UPDATE_PROFILE, + INVOICE_UPLOAD_WITHOUT_GQL, + GENERATE_INVOICE } = require('./graphql/queryStrings'); const testUser = { user: { name: 'Test Test', email: 'test@email.com', password: 'testing0189' } }; +const bankDetails = { iban: 'MY IBAN', bic: 'MY BIC' }; +const address = { street: 'My street', city: 'My city', zipCode: 1000, country: 'My country' }; describe('Authenticated user', () => { let loggedUser; @@ -90,6 +95,150 @@ describe('Authenticated user', () => { expect(res.data.users.length).toBeGreaterThanOrEqual(3); }); + describe('update profile', () => { + let server; + let user; + beforeAll(async () => { + // authenticate another user to avoid crash in other tests + user = await models.User.findByEmail('mitchell@gmail.com'); + server = constructTestServer({ + context: () => { + return { models, user, auth, db, validation }; + } + }); + }); + + it('updates address without overwriting others fields', async () => { + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: UPDATE_PROFILE, + variables: { user: { address } } + }); + expect(res.data.updateProfile.address).toEqual(address); + expect(res.data.updateProfile.email).toBeTruthy(); + }); + + it('updates bank details without overwriting others fields', async () => { + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: UPDATE_PROFILE, + variables: { user: { bankDetails } } + }); + expect(res.data.updateProfile.bankDetails).toEqual(bankDetails); + expect(res.data.updateProfile.address).toEqual(address); + }); + + it('updates email without overwritting others fields', async () => { + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: UPDATE_PROFILE, + variables: { user: { email: 'johnny@john.com' } } + }); + expect(res.data.updateProfile.email).toEqual('johnny@john.com'); + expect(res.data.updateProfile.address).toEqual(address); + }); + + it('fails if email already exists', async () => { + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: UPDATE_PROFILE, + variables: { user: { email: 'johnny@john.com' } } + }); + expect(res.errors).toBeTruthy(); + }); + + it('updates name without overwritting others fields', async () => { + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: UPDATE_PROFILE, + variables: { user: { name: 'Johnny John' } } + }); + expect(res.data.updateProfile.name).toEqual('Johnny John'); + expect(res.data.updateProfile.address).toEqual(address); + }); + + it('can update all the fields at once', async () => { + const addr = { + street: 'Next street', + city: 'Next city', + zipCode: 1001, + country: 'Next Country' + }; + const bd = { iban: 'BE098483992', bic: 'BPTOZE223' }; + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: UPDATE_PROFILE, + variables: { + user: { + email: user.email, + name: user.name, + password: 'testing343', + address: addr, + bankDetails: bd + } + } + }); + expect(res.data.updateProfile.name).toEqual(user.name); + expect(res.data.updateProfile.email).toEqual(user.email); + expect(res.data.updateProfile.address).toEqual(addr); + expect(res.data.updateProfile.bankDetails).toEqual(bd); + }); + }); + + describe('Generate invoice', () => { + let server; + let stop; + beforeAll(() => { + server = constructTestServer({ + context: () => { + return { + models, + user: loggedUser, + auth, + cloudinary, + db, + validation, + constants, + generateInvoicePDF + }; + } + }); + }); + + beforeEach(async () => { + const testServer = await startTestServer(server); + // eslint-disable-next-line prefer-destructuring + stop = testServer.stop; + }); + + afterEach(async () => { + stop(); + }); + + it('succeeds', async () => { + const { mutate } = createTestClient(server); + const res = await mutate({ + mutation: GENERATE_INVOICE, + variables: { + invoice: { + VAT: 21, + company: { + name: 'MY SUPER COMP', + VAT: 'MY VAT', + address + }, + details: [{ description: 'My description', amount: 200 }] + } + } + }); + expect(res.data.generateInvoice.id).toBeTruthy(); + expect(res.data.generateInvoice.ref).toBeTruthy(); + delete res.data.generateInvoice.id; + delete res.data.generateInvoice.ref; + expect(res).toMatchSnapshot(); + }); + }); + describe('claims expenses', () => { let uri; let server; @@ -97,7 +246,7 @@ describe('Authenticated user', () => { beforeAll(() => { server = constructTestServer({ context: () => { - return { models, user: loggedUser, auth, cloudinary, db }; + return { models, user: loggedUser, auth, cloudinary, db, validation, constants }; } }); }); @@ -141,9 +290,60 @@ describe('Authenticated user', () => { let res = await fetch(uri, { method: 'POST', body }); res = await res.json(); expect(res.data.expenseClaim.user.expenses.includes(res.data.expenseClaim.id)).toBeTruthy(); + expect(res.data.expenseClaim.type).toBe(constants.TR_TYPE.EXPENSE); + expect(res.data.expenseClaim.flow).toBe(constants.TR_FLOW.IN); delete res.data.expenseClaim.id; delete res.data.expenseClaim.user.expenses; expect(res).toMatchSnapshot(); }); }); + + describe('Upload invoice', () => { + let uri; + let server; + let stop; + beforeAll(() => { + server = constructTestServer({ + context: () => { + return { models, user: loggedUser, auth, cloudinary, db, validation, constants }; + } + }); + }); + + beforeEach(async () => { + const testServer = await startTestServer(server); + // eslint-disable-next-line prefer-destructuring + stop = testServer.stop; + // eslint-disable-next-line prefer-destructuring + uri = testServer.uri; + }); + + afterEach(async () => { + stop(); + }); + + it('succeeds', async () => { + const body = new FormData(); + body.append( + 'operations', + JSON.stringify({ + query: INVOICE_UPLOAD_WITHOUT_GQL, + variables: { + amount: 10, + invoice: null + } + }) + ); + body.append('map', JSON.stringify({ '0': ['variables.invoice'] })); + const file = fs.createReadStream(`${__dirname}/a.pdf`); + body.append('0', file); + + let res = await fetch(uri, { method: 'POST', body }); + res = await res.json(); + expect(res.data.uploadInvoice.type).toBe(constants.TR_TYPE.INVOICE); + expect(res.data.uploadInvoice.flow).toBe(constants.TR_FLOW.IN); + delete res.data.uploadInvoice.id; + expect(res).toMatchSnapshot(); + }); + }); }); diff --git a/backend/src/tests/utils.js b/backend/src/tests/utils.js index bb3b5c3..a21d16c 100644 --- a/backend/src/tests/utils.js +++ b/backend/src/tests/utils.js @@ -85,9 +85,11 @@ module.exports.startTestServer = startTestServer; // clean database const clean = async () => { - const { User, Transaction } = models; + const { User, Transaction, Company, Counter } = models; await User.deleteMany({}); await Transaction.deleteMany({}); + await Company.deleteMany({}); + await Counter.deleteMany({}); }; // populate database diff --git a/backend/src/tests/validations.test.js b/backend/src/tests/validations.test.js new file mode 100644 index 0000000..42f0d4b --- /dev/null +++ b/backend/src/tests/validations.test.js @@ -0,0 +1,462 @@ +const { UserInputError } = require('apollo-server-express'); +const { + validation: { + registerValidation, + updateProfileValidation, + expenseValidation, + uploadInvoiceValidation, + generateInvoiceValidation, + validate + } +} = require('../'); + +/** + * Test of input validation module + * + * Notes: + * 1. GraphQL will detect undefined values and reject them + * 2. GraphQL won't allow null for Numbers (Int/Float) + * 3. GraphQL allows null value for strings + * 4. GraphQL will make sure required fields have values (but allows empty string) + */ +const address = { + street: 'My Street 31', + city: 'My City', + country: 'My Country', + zipCode: 1000 +}; +const bankDetails = { + iban: 'MY IBAN', + bic: 'MY BIC' +}; +const FIFTYONECHARSSTR = 'QYE5TOXWrDbi0bSQDbM1KmKOljjR5SihgUJO7aDwkkjUJVJOzk6'; + +const addressExceedingMaxLength = { + street: FIFTYONECHARSSTR, + city: FIFTYONECHARSSTR, + country: FIFTYONECHARSSTR, + zipCode: 1000 +}; + +describe('EXPENSE CLAIM VALIDATION', () => { + const { messages, rules } = expenseValidation; + const expense = { + VAT: 21, + amount: 10, + date: Date.now(), + description: 'Hello World!' + }; + + it('will pass validation (minimum required fields)', async () => { + await validate({ description: expense.description, amount: expense.amount }, rules, messages); + }); + + it('will pass validation (all required fields)', async () => { + await validate(expense, rules, messages); + }); + + it('fails if description not set', async () => { + try { + // {} is equivalent to description = null + await validate({}, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].description).toBeTruthy(); + } else throw error; + } + try { + await validate({ description: undefined }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].description).toBeTruthy(); + } else throw error; + } + try { + await validate({ description: '' }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].description).toBeTruthy(); + } else throw error; + } + }); + + it('fails on max length exceeded', async () => { + try { + await validate({ description: FIFTYONECHARSSTR }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].description).toBeTruthy(); + } else throw error; + } + }); + + it('fails on max length exceeded', async () => { + try { + await validate({ description: FIFTYONECHARSSTR }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].description).toBeTruthy(); + } else throw error; + } + }); + + it('fails on invalid date', async () => { + try { + await validate({ description: expense.description, date: 'fefle' }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].date).toBeTruthy(); + } else throw error; + } + }); +}); +describe('REGISTER VALIDATION', () => { + const { rules, messages } = registerValidation; + const user = { + email: 'test@mail.com', + name: 'Test Test', + password: 'azerty1234', + address, + bankDetails + }; + it("doesn't throw (minimal config)", async () => { + const data = { + email: user.email, + name: user.name, + password: user.password + }; + + await validate(data, rules, messages); + }); + + it("doesn't throw (with address)", async () => { + const data = { + ...user, + bankDetails: {} + }; + await validate(data, rules, messages); + }); + + it("doesn't throw (with bankDetails)", async () => { + const data = { + ...user, + address: {} + }; + await validate(data, rules, messages); + }); + + it("doesn't throw (full config)", async () => { + const data = user; + await validate(data, rules, messages); + }); + + it('throws on missing required field', async () => { + const data = {}; + try { + await validate(data, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0].email).toBeTruthy(); + expect(err[1].email).toBeTruthy(); + expect(err[2].name).toBeTruthy(); + expect(err[3].password).toBeTruthy(); + } else throw error; + } + }); + + describe('throws if password is not matching requirements', () => { + it('throws if password less than minimal characters', async () => { + try { + await validate({ ...user, password: '1234567' }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) expect(true).toBe(true); + else throw error; + } + }); + }); + + it('throws if one of bankDetails field is missing while the other is present', async () => { + const data = { + ...user, + bankDetails: { + iban: 'MY IBAN' + } + }; + try { + await validate(data, rules, messages); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0]['bankDetails.bic']).toBeTruthy(); + } else throw error; + } + try { + await validate({ ...data, bankDetails: { bic: 'MY BIC' } }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err[0]['bankDetails.iban']).toBeTruthy(); + } else throw error; + } + }); + + it('throws if any of address fields are missing while one of them is present', async () => { + // street is present + try { + await validate({ ...user, address: { street: 'My street' } }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(2); + } else throw error; + } + // city is present + try { + await validate({ ...user, address: { city: 'My city' } }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(2); + } else throw error; + } + // zipCode is present + try { + await validate({ ...user, address: { zipCode: 1000 } }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(3); + } else throw error; + } + // country is present + try { + await validate({ ...user, address: { country: 'My country' } }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(2); + } else throw error; + } + }); + + it('throws when length over max', async () => { + const data = { + ...user, + name: FIFTYONECHARSSTR, + address: { + city: FIFTYONECHARSSTR, + country: FIFTYONECHARSSTR, + street: FIFTYONECHARSSTR + }, + bankDetails: { + bic: FIFTYONECHARSSTR, + iban: FIFTYONECHARSSTR + } + }; + try { + await validate(data, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(6); + } else throw error; + } + }); +}); +describe('UPDATE PROFILE VALIDATION', () => { + // most cases have been tested in REGISTER VALIDATION + const { formatData, messages, rules } = updateProfileValidation; + it('does not require any field', async () => { + await validate(formatData({}), rules, messages); + }); + + it('throws on empty string', async () => { + try { + await validate(formatData({ email: '', name: '', password: '' }), rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(3); + } else throw error; + } + }); + + it('throws on null value', async () => { + try { + await validate(formatData({ email: null, name: null, password: null }), rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(3); + } else throw error; + } + }); +}); +describe('UPLOAD INVOICE VALIDATION', () => { + const { formatData, messages, rules } = uploadInvoiceValidation; + const invoice = { + company: { + address, + bankDetails, + name: 'OKBE', + VAT: 'BE0000000000', + phone: '0483473742' + }, + phone: '0483473741', + VAT: 21, + amount: 10, + date: Date.now(), + expDate: Date.now() + }; + + it('passes validation with minial required fields', async () => { + await validate(formatData({}), rules, messages); + }); + + it('passes validation with full fields', async () => { + await validate(formatData(invoice), rules, messages); + }); + + it('fails on invalid date', async () => { + try { + await validate(formatData({ date: 'eeee', expDate: 'eeee' }), rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(2); + } else throw error; + } + }); + + it('fails on negative value', async () => { + try { + await validate(formatData({ amount: -1, VAT: -1 }), rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(2); + } else throw error; + } + }); + + // TODO make this describe a root + describe('COMPANY VALIDATION', () => { + // Address & BankDetails are tested in REGISTER VALIDATION + it('passes with minimal required fields', async () => { + await validate(formatData({ company: { name: 'OKBE' } }), rules, messages); + }); + it('passes with full fields', async () => { + await validate(formatData({ company: invoice.company }), rules, messages); + }); + it('fails on max length exceeded', async () => { + try { + await validate( + formatData({ + company: { + name: FIFTYONECHARSSTR, + VAT: `${invoice.company.VAT}1`, + phone: FIFTYONECHARSSTR + } + }), + rules, + messages + ); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const err = JSON.parse(error.message); + expect(err).toHaveLength(3); + } else throw error; + } + }); + }); +}); + +describe('GENERATE INVOICE VALIDATION', () => { + const { messages, rules } = generateInvoiceValidation; + const invoice = { + VAT: 21, + company: { + name: 'MY SUPER COMP', + VAT: 'MY VAT', + address + }, + details: [{ description: 'My description', amount: 200 }] + }; + it('passes validation', async () => { + await validate(invoice, rules, messages); + }); + it('fails on missing required fields', async () => { + try { + await validate({ details: [{}] }, rules, messages); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + expect(JSON.parse(error.message)).toMatchSnapshot(); + } else throw error; + } + }); + it('fails on negative value', async () => { + try { + await validate( + { ...invoice, VAT: -1, details: [{ description: 'My description', amount: -1 }] }, + rules, + messages + ); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + const message = JSON.parse(error.message); + expect(message[1].VAT).toBeTruthy(); + expect(message[0]['details.0.amount']).toBeTruthy(); + } else throw error; + } + }); + it('fails max length exceeded', async () => { + try { + await validate( + { + VAT: 10, + details: [{ description: FIFTYONECHARSSTR, amount: 300 }], + company: { + name: FIFTYONECHARSSTR, + address: addressExceedingMaxLength, + VAT: FIFTYONECHARSSTR + } + }, + rules, + messages + ); + expect(false).toBe(true); + } catch (error) { + if (error instanceof UserInputError) { + expect(JSON.parse(error.message)).toMatchSnapshot(); + } else throw error; + } + }); +});