From 396a996030dc65dffc327f41f48abe9012573aff Mon Sep 17 00:00:00 2001 From: Lewis Daly Date: Tue, 17 Dec 2019 20:50:39 +0800 Subject: [PATCH] Feature/1047 improve test coverage (#108) * Add unit tests to bring coverage up to 90%+ remove redundant nyc config Set up dir structure for tests Set up dir structure for tests Add inspect util for ease of testing working on quotes error test Add istanbul ignore comments for mockgen only files working on health check mocks Working on config mocks Add bulk quotes not implemented tests Working on health check tests Working on health check tests remove unused comments working on quotes test working on config default tests working on utils tests working on utils tests find and replace all stack inspection find and replace all stack inspection Working on quote tests Move http into its own library for ease of mocking Move http into its own library for ease of mocking fix existing tests once mocking out http add tests for handleException add tests for handleException add tests for handleException finish getting model testing up to scratch fix missing conditions on rule engine tests Add tests for http refactor start script to improve tests work on server testing working on database mocking working on knex mocks working on knex mocks working on knex mocks working on knex mocks working on knex mocks replace err.stack || util.inspect(err) with getStackOrInspect work on quite tests work on quite tests finish work on cachedDatabase update dependencies, bump package version to 8.7.0 * remove unneeded test files * run standard --fix * bump package version to 8.7.1-snapshot * bump package version to 8.7.2-snapshot --- .nycrc.yml | 10 + jest.config.js | 13 +- package-lock.json | 329 ++- package.json | 68 +- src/data/bulkQuotes.js | 2 + src/data/bulkQuotes/{id}.js | 2 + src/data/bulkQuotes/{id}/error.js | 2 + src/data/cachedDatabase.js | 4 +- src/data/database.js | 72 +- src/data/quotes.js | 4 + src/data/quotes/{id}.js | 2 + src/data/quotes/{id}/error.js | 2 + src/handlers/health.js | 76 +- src/handlers/quotes.js | 2 +- src/handlers/quotes/{id}.js | 4 +- src/handlers/quotes/{id}/error.js | 2 +- src/index.js | 39 + src/lib/http.js | 87 + src/lib/util.js | 53 +- src/model/quotes.js | 85 +- src/model/rules.js | 9 +- src/server.js | 149 +- test/unit/data/cachedDatabase.test.js | 191 ++ test/unit/data/database.test.js | 2124 +++++++++++++++++ test/unit/handlers/bulkQuotes.test.js | 45 + test/unit/handlers/bulkQuotes/{id}.test.js | 56 + .../handlers/bulkQuotes/{id}/error.test.js | 46 + test/unit/handlers/health.test.js | 202 ++ test/unit/handlers/quotes.test.js | 108 + test/unit/handlers/quotes/{id}.test.js | 141 ++ test/unit/handlers/quotes/{id}/error.test.js | 111 + test/unit/lib/config.test.js | 83 + test/unit/lib/http.test.js | 80 + test/unit/lib/util.test.js | 133 ++ test/unit/model/quotes.test.js | 644 +++++ test/unit/model/rules.test.js | 25 + test/unit/server.test.js | 127 + test/util/helper.js | 29 + 38 files changed, 4916 insertions(+), 245 deletions(-) create mode 100644 .nycrc.yml create mode 100644 src/index.js create mode 100644 src/lib/http.js create mode 100644 test/unit/data/cachedDatabase.test.js create mode 100644 test/unit/data/database.test.js create mode 100644 test/unit/handlers/bulkQuotes.test.js create mode 100644 test/unit/handlers/bulkQuotes/{id}.test.js create mode 100644 test/unit/handlers/bulkQuotes/{id}/error.test.js create mode 100644 test/unit/handlers/health.test.js create mode 100644 test/unit/handlers/quotes.test.js create mode 100644 test/unit/handlers/quotes/{id}.test.js create mode 100644 test/unit/handlers/quotes/{id}/error.test.js create mode 100644 test/unit/lib/config.test.js create mode 100644 test/unit/lib/http.test.js create mode 100644 test/unit/lib/util.test.js create mode 100644 test/unit/server.test.js diff --git a/.nycrc.yml b/.nycrc.yml new file mode 100644 index 00000000..9d64f588 --- /dev/null +++ b/.nycrc.yml @@ -0,0 +1,10 @@ +temp-directory: "./.nyc_output" +reporter: [ + "lcov", + "text-summary" +] +exclude: [ + "**/node_modules/**", + '**/migrations/**', + '**/docs/**' +] diff --git a/jest.config.js b/jest.config.js index cd8f1a7f..924d0a63 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,18 @@ - const path = require('path') module.exports = { + verbose: true, + collectCoverageFrom: [ + '**/src/**/**/*.js' + ], + coverageThreshold: { + global: { + statements: 90, + functions: 90, + branches: 90, + lines: 90 + } + }, globals: { __SRC__: path.resolve(__dirname, 'src'), __ROOT__: path.resolve(__dirname) diff --git a/package-lock.json b/package-lock.json index db2e2a5e..bb73be29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "quoting-service", - "version": "8.7.1-snapshot", + "version": "8.7.2-snapshot", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -742,9 +742,9 @@ } }, "@mojaloop/central-services-shared": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@mojaloop/central-services-shared/-/central-services-shared-8.7.0.tgz", - "integrity": "sha512-AVVzHLJdpif0X+D8Y4K4tkk/TzGmArpi2gDlKftwkqD6vYjGcYCew4dUe6wDuneQautnTg9GbZ8TEYqPtKNqoA==", + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/@mojaloop/central-services-shared/-/central-services-shared-8.7.1.tgz", + "integrity": "sha512-kA3jq0HwTfcZV6tWYKJpvVvlD9U+lxyOa9ZDsK7uXmxXJc5DekU8TySRbL6bPMDSeiqYHJaCO3lvhFvK6dD/ng==", "requires": { "@hapi/catbox": "10.2.3", "@hapi/catbox-memory": "4.1.1", @@ -1047,6 +1047,15 @@ "@types/istanbul-lib-report": "*" } }, + "@types/jest": { + "version": "24.0.23", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-24.0.23.tgz", + "integrity": "sha512-L7MBvwfNpe7yVPTXLn32df/EK+AMBFAFvZrRuArGs7npEWnlziUXK+5GMIUTI4NIuwok3XibsjXCs5HxviYXjg==", + "dev": true, + "requires": { + "jest-diff": "^24.3.0" + } + }, "@types/long": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.0.tgz", @@ -1249,12 +1258,27 @@ "normalize-path": "^2.1.1" } }, + "append-transform": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-1.0.0.tgz", + "integrity": "sha512-P009oYkeHyU742iSZJzZZywj4QRJdnTWffaKuJQLablCZ1uz6/cW4yaRgcDaoQ+uwOxxnt0gRUcwfsNP2ri0gw==", + "dev": true, + "requires": { + "default-require-extensions": "^2.0.0" + } + }, "aproba": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "dev": true }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -1836,6 +1860,31 @@ } } }, + "caching-transform": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-3.0.2.tgz", + "integrity": "sha512-Mtgcv3lh3U0zRii/6qVgQODdPA4G3zhG+jtbCWj39RXuUFTMzH0vcdMtaJS1jPowd+It2Pqr6y3NJMQqOqCE2w==", + "dev": true, + "requires": { + "hasha": "^3.0.0", + "make-dir": "^2.0.0", + "package-hash": "^3.0.0", + "write-file-atomic": "^2.4.2" + }, + "dependencies": { + "write-file-atomic": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz", + "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + } + } + }, "call-me-maybe": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.1.tgz", @@ -2089,6 +2138,12 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "optional": true }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, "component-emitter": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", @@ -2180,6 +2235,27 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, + "cp-file": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cp-file/-/cp-file-6.2.0.tgz", + "integrity": "sha512-fmvV4caBnofhPe8kOcitBwSn2f39QLjnAnGq3gO9dfd75mUytzKNZB1hde6QHunW2Rt+OwuBOMc3i1tNElbszA==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "make-dir": "^2.0.0", + "nested-error-stacks": "^2.0.0", + "pify": "^4.0.1", + "safe-buffer": "^5.0.1" + }, + "dependencies": { + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + } + } + }, "cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", @@ -2281,6 +2357,15 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, + "default-require-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-2.0.0.tgz", + "integrity": "sha1-9fj7sYp9bVCyH2QfZJ67Uiz+JPc=", + "dev": true, + "requires": { + "strip-bom": "^3.0.0" + } + }, "default-shell": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/default-shell/-/default-shell-1.0.1.tgz", @@ -2620,6 +2705,12 @@ "is-symbol": "^1.0.2" } }, + "es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, "es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", @@ -3602,6 +3693,17 @@ } } }, + "find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + } + }, "find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -3698,6 +3800,44 @@ "for-in": "^1.0.1" } }, + "foreground-child": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-1.5.6.tgz", + "integrity": "sha1-T9ca0t/elnibmApcCilZN8svXOk=", + "dev": true, + "requires": { + "cross-spawn": "^4", + "signal-exit": "^3.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz", + "integrity": "sha1-e5JHYhwjrf3ThWAEqCPL45dCTUE=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "which": "^1.2.9" + } + }, + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "dev": true + } + } + }, "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -5045,6 +5185,15 @@ "json-prune": "^1.1.0" } }, + "hasha": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-3.0.0.tgz", + "integrity": "sha1-UqMvq4Vp1BymmmH/GiFPjrfIvTk=", + "dev": true, + "requires": { + "is-stream": "^1.0.1" + } + }, "hoek": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/hoek/-/hoek-6.1.3.tgz", @@ -5600,6 +5749,15 @@ "integrity": "sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==", "dev": true }, + "istanbul-lib-hook": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-2.0.7.tgz", + "integrity": "sha512-vrRztU9VRRFDyC+aklfLoeXyNdTfga2EI3udDGn4cZ6fpSXpHLV9X6CHvfoMCPtggg8zvDDmC4b9xfu0z6/llA==", + "dev": true, + "requires": { + "append-transform": "^1.0.0" + } + }, "istanbul-lib-instrument": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-3.3.0.tgz", @@ -6737,6 +6895,12 @@ "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz", "integrity": "sha1-GVhwRQ9aExkkeN9Lw9I9LeoZB7Y=" }, + "lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", + "dev": true + }, "lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -6941,6 +7105,15 @@ "is-plain-obj": "^1.1" } }, + "merge-source-map": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.1.0.tgz", + "integrity": "sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==", + "dev": true, + "requires": { + "source-map": "^0.6.1" + } + }, "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -7584,6 +7757,119 @@ "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==", "dev": true }, + "nyc": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-14.1.1.tgz", + "integrity": "sha512-OI0vm6ZGUnoGZv/tLdZ2esSVzDwUC88SNs+6JoSOMVxA+gKMB8Tk7jBwgemLx4O40lhhvZCVw1C+OYLOBOPXWw==", + "dev": true, + "requires": { + "archy": "^1.0.0", + "caching-transform": "^3.0.2", + "convert-source-map": "^1.6.0", + "cp-file": "^6.2.0", + "find-cache-dir": "^2.1.0", + "find-up": "^3.0.0", + "foreground-child": "^1.5.6", + "glob": "^7.1.3", + "istanbul-lib-coverage": "^2.0.5", + "istanbul-lib-hook": "^2.0.7", + "istanbul-lib-instrument": "^3.3.0", + "istanbul-lib-report": "^2.0.8", + "istanbul-lib-source-maps": "^3.0.6", + "istanbul-reports": "^2.2.4", + "js-yaml": "^3.13.1", + "make-dir": "^2.1.0", + "merge-source-map": "^1.1.0", + "resolve-from": "^4.0.0", + "rimraf": "^2.6.3", + "signal-exit": "^3.0.2", + "spawn-wrap": "^1.4.2", + "test-exclude": "^5.2.3", + "uuid": "^3.3.2", + "yargs": "^13.2.2", + "yargs-parser": "^13.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yargs": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.0.tgz", + "integrity": "sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.1" + } + } + } + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -7902,6 +8188,18 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true }, + "package-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-3.0.0.tgz", + "integrity": "sha512-lOtmukMDVvtkL84rJHI7dpTYq+0rli8N2wlnqUcBuDWCfVhRUfOmnR9SsoHFMLpACvEV60dX7rd0rFaYDZI+FA==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.15", + "hasha": "^3.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + } + }, "package-json": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", @@ -8543,6 +8841,15 @@ "rc": "^1.2.8" } }, + "release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=", + "dev": true, + "requires": { + "es6-error": "^4.0.1" + } + }, "remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -9164,6 +9471,20 @@ "os-shim": "^0.1.2" } }, + "spawn-wrap": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-1.4.3.tgz", + "integrity": "sha512-IgB8md0QW/+tWqcavuFgKYR/qIRvJkRLPJDFaoXtLLUaVcCDK0+HeFTkmQHj3eprcYhc+gOl0aEA1w7qZlYezw==", + "dev": true, + "requires": { + "foreground-child": "^1.5.6", + "mkdirp": "^0.5.0", + "os-homedir": "^1.0.1", + "rimraf": "^2.6.2", + "signal-exit": "^3.0.2", + "which": "^1.3.0" + } + }, "spdx-correct": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", diff --git a/package.json b/package.json index 78d68ea5..ef7ed2b2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "quoting-service", "description": "Quoting Service hosted by a scheme", "license": "Apache-2.0", - "version": "8.7.1-snapshot", + "version": "8.7.2-snapshot", "author": "Modusbox", "contributors": [ "James Bush ", @@ -29,28 +29,36 @@ "jest" ] }, - "jest": { - "collectCoverageFrom": [ - "**/src/handlers/**/*.js", - "src/data/database.js", - "**/src/model/**/*.js" - ], - "coverageThreshold": { - "global": { - "statements": 90, - "functions": 90, - "branches": 90, - "lines": 90 - } - }, - "testEnvironment": "node" + "pre-commit": [ + "standard", + "dep:check", + "test:unit" + ], + "scripts": { + "start": "node src/index.js", + "test:unit": "jest --testMatch '**/test/unit/**/*.test.js'", + "test:coverage": "jest --coverage --coverageThreshold='{}' --testMatch '**/test/unit/**/*.test.js'", + "test:coverage-check": "jest --coverage --testMatch '**/test/unit/**/*.test.js'", + "test:junit": "jest --reporters=default --reporters=jest-junit --testMatch '**/test/unit/**/*.test.js'", + "lint": "eslint .", + "standard": "standard", + "regenerate": "yo swaggerize:test --framework hapi --apiPath './src/interface/swagger.json'", + "build": "docker build -t quoting-service:local -f ./Dockerfile ../", + "run": "docker run -p 3002:3002 --rm --link db:mysql quoting-service:local", + "package-lock": "docker run --rm -it quoting-service:local cat package-lock.json > package-lock.json", + "docker:up": "docker-compose -f docker-compose.yml -f docker-compose.base.yml up", + "docker:stop": "docker-compose -f docker-compose.yml -f docker-compose.base.yml stop", + "audit:resolve": "SHELL=sh resolve-audit", + "audit:check": "SHELL=sh check-audit", + "dep:check": "npx ncu -e 2", + "dep:update": "npx ncu -u" }, "dependencies": { "@hapi/good": "8.2.4", "@hapi/hapi": "18.4.0", "@mojaloop/central-services-error-handling": "8.6.2", "@mojaloop/central-services-logger": "8.6.0", - "@mojaloop/central-services-shared": "8.7.0", + "@mojaloop/central-services-shared": "8.7.1", "@mojaloop/event-sdk": "8.6.2", "@mojaloop/ml-number": "8.2.0", "axios": "0.19.0", @@ -68,41 +76,19 @@ "rc": "1.2.8" }, "devDependencies": { + "@types/jest": "^24.0.23", "eslint": "6.7.2", "jest": "24.9.0", "jest-junit": "10.0.0", "npm-audit-resolver": "2.1.0", "npm-check-updates": "4.0.1", + "nyc": "14.1.1", "pre-commit": "1.2.2", "proxyquire": "2.1.3", "sinon": "7.5.0", "standard": "14.3.1", "swagmock": "1.0.0" }, - "pre-commit": [ - "standard", - "dep:check", - "test" - ], - "scripts": { - "start": "node src/server.js", - "test": "jest --testMatch '**/test/unit/**/*.test.js'", - "test:coverage": "jest --coverage --coverageThreshold='{}' --testMatch '**/test/unit/**/*.test.js'", - "test:coverage-check": "jest --coverage --testMatch '**/test/unit/**/*.test.js'", - "test:junit": "jest --reporters=default --reporters=jest-junit --testMatch '**/test/unit/**/*.test.js'", - "lint": "eslint .", - "standard": "standard", - "regenerate": "yo swaggerize:test --framework hapi --apiPath './src/interface/swagger.json'", - "build": "docker build -t quoting-service:local -f ./Dockerfile ../", - "run": "docker run -p 3002:3002 --rm --link db:mysql quoting-service:local", - "package-lock": "docker run --rm -it quoting-service:local cat package-lock.json > package-lock.json", - "docker:up": "docker-compose -f docker-compose.yml -f docker-compose.base.yml up", - "docker:stop": "docker-compose -f docker-compose.yml -f docker-compose.base.yml stop", - "audit:resolve": "SHELL=sh resolve-audit", - "audit:check": "SHELL=sh check-audit", - "dep:check": "npx ncu -e 2", - "dep:update": "npx ncu -u" - }, "generator-swaggerize": { "version": "4.11.0" }, diff --git a/src/data/bulkQuotes.js b/src/data/bulkQuotes.js index 330195b5..5f04f38a 100644 --- a/src/data/bulkQuotes.js +++ b/src/data/bulkQuotes.js @@ -29,6 +29,8 @@ * Georgi Georgiev -------------- ******/ +// Ignore coverage for this file as it is only a mock implementation for now +/* istanbul ignore file */ 'use strict' var Mockgen = require('../../test/util/mockgen.js') diff --git a/src/data/bulkQuotes/{id}.js b/src/data/bulkQuotes/{id}.js index 27a90c06..3e593f74 100644 --- a/src/data/bulkQuotes/{id}.js +++ b/src/data/bulkQuotes/{id}.js @@ -29,6 +29,8 @@ * Georgi Georgiev -------------- ******/ +// Ignore coverage for this file as it is only a mock implementation for now +/* istanbul ignore file */ 'use strict' var Mockgen = require('../../../test/util/mockgen.js') diff --git a/src/data/bulkQuotes/{id}/error.js b/src/data/bulkQuotes/{id}/error.js index 76ea0447..e473bb1e 100644 --- a/src/data/bulkQuotes/{id}/error.js +++ b/src/data/bulkQuotes/{id}/error.js @@ -29,6 +29,8 @@ * Georgi Georgiev -------------- ******/ +// Ignore coverage for this file as it is only a mock implementation for now +/* istanbul ignore file */ 'use strict' var Mockgen = require('../../../../test/util/mockgen.js') diff --git a/src/data/cachedDatabase.js b/src/data/cachedDatabase.js index f7c99be8..d9f235db 100644 --- a/src/data/cachedDatabase.js +++ b/src/data/cachedDatabase.js @@ -35,6 +35,8 @@ const Database = require('./database.js') const Cache = require('memory-cache').Cache const ErrorHandler = require('@mojaloop/central-services-error-handling') +const { getStackOrInspect } = require('../lib/util') + const DEFAULT_TTL_SECONDS = 60 /** @@ -112,7 +114,7 @@ class CachedDatabase extends Database { return value } catch (err) { - this.writeLog(`Error in getCacheValue: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getCacheValue: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } diff --git a/src/data/database.js b/src/data/database.js index e665290b..451def6b 100644 --- a/src/data/database.js +++ b/src/data/database.js @@ -34,13 +34,15 @@ 'use strict' -const util = require('util') -const LOCAL_ENUM = require('../lib/enum') const Knex = require('knex') +const util = require('util') const Logger = require('@mojaloop/central-services-logger') const ErrorHandler = require('@mojaloop/central-services-error-handling') const MLNumber = require('@mojaloop/ml-number') +const LOCAL_ENUM = require('../lib/enum') +const { getStackOrInspect } = require('../lib/util') + /** * Abstracts operations against the database */ @@ -55,7 +57,7 @@ class Database { * @returns {promise} */ async connect () { - this.queryBuilder = Knex(this.config.database) + this.queryBuilder = new Knex(this.config.database) return this } @@ -106,7 +108,7 @@ class Database { .select() return rows.map(r => JSON.parse(r.rule)) } catch (err) { - this.writeLog(`Error in getTransferRules: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getTransferRules: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -128,7 +130,7 @@ class Database { } return rows[0].transactionInitiatorTypeId } catch (err) { - this.writeLog(`Error in getInitiatorType: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getInitiatorType: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -150,7 +152,7 @@ class Database { } return rows[0].transactionInitiatorId } catch (err) { - this.writeLog(`Error in getInitiator: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getInitiator: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -172,7 +174,7 @@ class Database { } return rows[0].transactionScenarioId } catch (err) { - this.writeLog(`Error in getScenario: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getScenario: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -194,7 +196,7 @@ class Database { } return rows[0].transactionSubScenarioId } catch (err) { - this.writeLog(`Error in getSubScenario: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getSubScenario: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -216,7 +218,7 @@ class Database { } return rows[0].amountTypeId } catch (err) { - this.writeLog(`Error in getAmountType: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getAmountType: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -238,7 +240,7 @@ class Database { this.writeLog(`inserted new transactionReference in db: ${transactionReferenceId}`) return transactionReferenceId } catch (err) { - this.writeLog(`Error in createTransactionReference: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in createTransactionReference: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -260,7 +262,7 @@ class Database { this.writeLog(`inserted new duplicate check in db for quoteId: ${quoteId}`) return quoteId } catch (err) { - this.writeLog(`Error in createQuoteDuplicateCheck: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in createQuoteDuplicateCheck: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -283,7 +285,7 @@ class Database { this.writeLog(`inserted new response duplicate check in db for quote ${quoteId}, quoteResponseId: ${quoteResponseId}`) return quoteId } catch (err) { - this.writeLog(`Error in createQuoteUpdateDuplicateCheck: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in createQuoteUpdateDuplicateCheck: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -306,7 +308,7 @@ class Database { return rows[0].partyTypeId } catch (err) { - this.writeLog(`Error in getPartyType: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getPartyType: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -329,7 +331,7 @@ class Database { return rows[0].partyIdentifierTypeId } catch (err) { - this.writeLog(`Error in getPartyIdentifierType: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getPartyIdentifierType: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -361,7 +363,7 @@ class Database { return rows[0].participantId } catch (err) { - this.writeLog(`Error in getPartyIdentifierType: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getPartyIdentifierType: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -387,7 +389,7 @@ class Database { return rows[0].transferParticipantRoleTypeId } catch (err) { - this.writeLog(`Error in getTransferParticipantRoleType: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getTransferParticipantRoleType: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -413,7 +415,7 @@ class Database { return rows[0].ledgerEntryTypeId } catch (err) { - this.writeLog(`Error in getLedgerEntryType: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getLedgerEntryType: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -512,7 +514,7 @@ class Database { return quotePartyId } catch (err) { - this.writeLog(`Error in createQuoteParty: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in createQuoteParty: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -533,7 +535,7 @@ class Database { return rows } catch (err) { - this.writeLog(`Error in getQuotePartyView: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getQuotePartyView: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -561,7 +563,7 @@ class Database { return rows[0] } catch (err) { - this.writeLog(`Error in getQuoteView: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getQuoteView: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -575,7 +577,7 @@ class Database { try { const rows = await this.queryBuilder('quoteResponseView') .where({ - quoteId: quoteId + quoteId }) .select() @@ -589,7 +591,7 @@ class Database { return rows[0] } catch (err) { - this.writeLog(`Error in getQuoteResponseView: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getQuoteResponseView: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -603,7 +605,7 @@ class Database { try { const newParty = { ...party, - quotePartyId: quotePartyId + quotePartyId } const res = await this.queryBuilder('party') @@ -613,7 +615,7 @@ class Database { newParty.partyId = res[0] return newParty } catch (err) { - this.writeLog(`Error in createParty: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in createParty: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -646,7 +648,7 @@ class Database { this.writeLog(`inserted new quote in db: ${util.inspect(quote)}`) return quote.quoteId } catch (err) { - this.writeLog(`Error in createQuote: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in createQuote: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -674,7 +676,7 @@ class Database { return rows[0] } catch (err) { - this.writeLog(`Error in getQuoteParty: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getQuoteParty: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -703,7 +705,7 @@ class Database { return rows[0].value } catch (err) { - this.writeLog(`Error in getQuotePartyEndpoint: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getQuotePartyEndpoint: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -729,7 +731,7 @@ class Database { return rows[0].value } catch (err) { - this.writeLog(`Error in getParticipantEndpoint: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getParticipantEndpoint: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -753,7 +755,7 @@ class Database { return rows[0] } catch (err) { - this.writeLog(`Error in getQuoteDuplicateCheck: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getQuoteDuplicateCheck: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -777,7 +779,7 @@ class Database { return rows[0] } catch (err) { - this.writeLog(`Error in getQuoteResponseDuplicateCheck: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getQuoteResponseDuplicateCheck: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -801,7 +803,7 @@ class Database { return rows[0] } catch (err) { - this.writeLog(`Error in getTransactionReference: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in getTransactionReference: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -837,7 +839,7 @@ class Database { this.writeLog(`inserted new quoteResponse in db: ${util.inspect(newQuoteResponse)}`) return newQuoteResponse } catch (err) { - this.writeLog(`Error in createQuoteResponse: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in createQuoteResponse: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -861,7 +863,7 @@ class Database { this.writeLog(`inserted new quoteResponseIlpPacket in db: ${util.inspect(res)}`) return res } catch (err) { - this.writeLog(`Error in createIlpPacket: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in createIlpPacket: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -888,7 +890,7 @@ class Database { this.writeLog(`inserted new geoCode in db: ${util.inspect(newGeoCode)}`) return res } catch (err) { - this.writeLog(`Error in createGeoCode: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in createGeoCode: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -915,7 +917,7 @@ class Database { this.writeLog(`inserted new quoteError in db: ${util.inspect(newError)}`) return res } catch (err) { - this.writeLog(`Error in createQuoteError: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in createQuoteError: ${getStackOrInspect(err)}`) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } diff --git a/src/data/quotes.js b/src/data/quotes.js index f1d39877..cb7f0704 100644 --- a/src/data/quotes.js +++ b/src/data/quotes.js @@ -30,7 +30,11 @@ -------------- ******/ +// Ignore coverage for this file as it is only a mock implementation for now +/* istanbul ignore file */ + 'use strict' + var Mockgen = require('../../test/util/mockgen.js') /** * Operations on /quotes diff --git a/src/data/quotes/{id}.js b/src/data/quotes/{id}.js index 26a457f7..c942c0a4 100644 --- a/src/data/quotes/{id}.js +++ b/src/data/quotes/{id}.js @@ -29,6 +29,8 @@ * Georgi Georgiev -------------- ******/ +// Ignore coverage for this file as it is only a mock implementation for now +/* istanbul ignore file */ 'use strict' var Mockgen = require('../../../test/util/mockgen.js') diff --git a/src/data/quotes/{id}/error.js b/src/data/quotes/{id}/error.js index 3e42b4d7..fc204124 100644 --- a/src/data/quotes/{id}/error.js +++ b/src/data/quotes/{id}/error.js @@ -29,6 +29,8 @@ * Georgi Georgiev -------------- ******/ +// Ignore coverage for this file as it is only a mock implementation for now +/* istanbul ignore file */ 'use strict' var Mockgen = require('../../../../test/util/mockgen.js') diff --git a/src/handlers/health.js b/src/handlers/health.js index 1e450b9e..c20b07f9 100644 --- a/src/handlers/health.js +++ b/src/handlers/health.js @@ -34,6 +34,34 @@ const packageJson = require('../../package.json') const envConfig = new Config() +/** + * @function getSubServiceHealthDatastore + * + * @description + * Gets the health of the Datastore by ensuring the table is currently locked + * in a migration state. This implicity checks the connection with the database. + * + * @returns Promise The SubService health object for the broker + */ +const getSubServiceHealthDatastore = async (db) => { + let status = statusEnum.OK + + try { + const isLocked = await db.getIsMigrationLocked() + if (isLocked) { + status = statusEnum.DOWN + } + } catch (err) { + Logger.debug(`getSubServiceHealthDatastore failed with error ${err.message}.`) + status = statusEnum.DOWN + } + + return { + name: serviceName.datastore, + status + } +} + /** * Operations on /health */ @@ -46,47 +74,16 @@ module.exports = { * responses: 200, 400, 401, 403, 404, 405, 406, 501, 503 */ get: async (request, h) => { - let db - // lets check to see if we are NOT in simpleRoutingMode - if (!envConfig.simpleRoutingMode) { - // assign the db object - db = request.server.app.database - } - - // Create function to query DB health - /** - * @function getSubServiceHealthDatastore - * - * @description - * Gets the health of the Datastore by ensuring the table is currently locked - * in a migration state. This implicity checks the connection with the database. - * - * @returns Promise The SubService health object for the broker - */ - const getSubServiceHealthDatastore = async () => { - let status = statusEnum.OK - - try { - const isLocked = await db.getIsMigrationLocked() - if (isLocked) { - status = statusEnum.DOWN - } - } catch (err) { - Logger.debug(`getSubServiceHealthDatastore failed with error ${err.message}.`) - status = statusEnum.DOWN - } - - return { - name: serviceName.datastore, - status - } - } - - // lets check to see if we are running in simpleRoutingMode + // Check to see if we are NOT in simpleRoutingMode let serviceHealthList = [] + // console.log('envConfig', envConfig) if (!envConfig.simpleRoutingMode) { + // assign the db object + /* istanbul ignore next */ + // ignoring coverage, since we can't test this anonymous function and its tests are covered + // elsewhere serviceHealthList = [ - getSubServiceHealthDatastore + async () => getSubServiceHealthDatastore(request.server.app.database) ] } @@ -107,5 +104,6 @@ module.exports = { // return response return h.response(healthCheckResponse).code(code) - } + }, + getSubServiceHealthDatastore } diff --git a/src/handlers/quotes.js b/src/handlers/quotes.js index 639020b9..b66eebcd 100644 --- a/src/handlers/quotes.js +++ b/src/handlers/quotes.js @@ -80,7 +80,7 @@ module.exports = { request.server.log(['info'], `POST quote request succeeded and returned: ${util.inspect(result)}`) } catch (err) { // something went wrong, use the model to handle the error in a sensible way - request.server.log(['error'], `ERROR - POST /quotes: ${err.stack || util.inspect(err)}`) + request.server.log(['error'], `ERROR - POST /quotes: ${LibUtil.getStackOrInspect(err)}`) const fspiopError = ErrorHandler.ReformatFSPIOPError(err) await model.handleException(fspiopSource, quoteId, fspiopError, request.headers, span) } finally { diff --git a/src/handlers/quotes/{id}.js b/src/handlers/quotes/{id}.js index 0135509a..cf601783 100644 --- a/src/handlers/quotes/{id}.js +++ b/src/handlers/quotes/{id}.js @@ -80,7 +80,7 @@ module.exports = { request.server.log(['info'], `GET quotes/{id} request succeeded and returned: ${util.inspect(result)}`) } catch (err) { // something went wrong, use the model to handle the error in a sensible way - request.server.log(['error'], `ERROR - GET /quotes/{id}: ${err.stack || util.inspect(err)}`) + request.server.log(['error'], `ERROR - GET /quotes/{id}: ${LibUtil.getStackOrInspect(err)}`) await model.handleException(fspiopSource, quoteId, err, request.headers, span) } finally { // eslint-disable-next-line no-unsafe-finally @@ -123,7 +123,7 @@ module.exports = { request.server.log(['info'], `PUT quote request succeeded and returned: ${util.inspect(result)}`) } catch (err) { // something went wrong, use the model to handle the error in a sensible way - request.server.log(['error'], `ERROR - PUT /quotes/{id}: ${err.stack || util.inspect(err)}`) + request.server.log(['error'], `ERROR - PUT /quotes/{id}: ${LibUtil.getStackOrInspect(err)}`) await model.handleException(fspiopSource, quoteId, err, request.headers, span) } finally { // eslint-disable-next-line no-unsafe-finally diff --git a/src/handlers/quotes/{id}/error.js b/src/handlers/quotes/{id}/error.js index 55a0a28a..52536f72 100644 --- a/src/handlers/quotes/{id}/error.js +++ b/src/handlers/quotes/{id}/error.js @@ -78,7 +78,7 @@ module.exports = { request.server.log(['info'], `PUT quote error request succeeded and returned: ${util.inspect(result)}`) } catch (err) { // something went wrong, use the model to handle the error in a sensible way - request.server.log(['error'], `ERROR - PUT /quotes/{id}/error: ${err.stack || util.inspect(err)}`) + request.server.log(['error'], `ERROR - PUT /quotes/{id}/error: ${LibUtil.getStackOrInspect(err)}`) await model.handleException(fspiopSource, quoteId, err, request.headers) } finally { // eslint-disable-next-line no-unsafe-finally diff --git a/src/index.js b/src/index.js new file mode 100644 index 00000000..7ddaad89 --- /dev/null +++ b/src/index.js @@ -0,0 +1,39 @@ +// (C)2018 ModusBox Inc. +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Initial contribution + -------------------- + The initial functionality and code base was donated by the Mowali project working in conjunction with MTN and Orange as service provides. + * Project: Mowali + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * ModusBox + - Georgi Georgiev + - Henk Kodde + - Matt Kingston + - Vassilis Barzokas + -------------- + ******/ +/* istanbul ignore file */ + +const server = require('./server') + +module.exports = server() diff --git a/src/lib/http.js b/src/lib/http.js new file mode 100644 index 00000000..0736c0d3 --- /dev/null +++ b/src/lib/http.js @@ -0,0 +1,87 @@ +// (C)2018 ModusBox Inc. +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Initial contribution + -------------------- + The initial functionality and code base was donated by the Mowali project working in conjunction with MTN and Orange as service provides. + * Project: Mowali + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * ModusBox + - Georgi Georgiev + - Henk Kodde + - Matt Kingston + - Vassilis Barzokas + -------------- + ******/ + +const axios = require('axios') +const util = require('util') +const ErrorHandler = require('@mojaloop/central-services-error-handling') + +const { getStackOrInspect } = require('../lib/util') + +// TODO: where httpRequest is called, there's a pretty common pattern of obtaining an endpoint from +// the database, specialising a template string with that endpoint, then calling httpRequest. Is +// there common functionality in these places than can reasonably be factored out? +/** + * Encapsulates making an HTTP request and translating any error response into a domain-specific + * error type. + * + * @param {Object} opts + * @param {String} fspiopSource + * @returns {Promise} + */ +async function httpRequest (opts, fspiopSource) { + // Network errors lob an exception. Bear in mind 3xx 4xx and 5xx are not network errors so we + // need to wrap the request below in a `try catch` to handle network errors + let res + let body + + try { + res = await axios.request(opts) + body = await res.data + } catch (e) { + throw ErrorHandler.CreateFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.DESTINATION_COMMUNICATION_ERROR, + 'Network error', + `${getStackOrInspect(e)}. Opts: ${util.inspect(opts)}`, + fspiopSource) + } + + // handle non network related errors below + if (res.status < 200 || res.status >= 300) { + const errObj = util.inspect({ + opts, + status: res.status, + statusText: res.statusText, + body + }) + + throw ErrorHandler.CreateFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.DESTINATION_COMMUNICATION_ERROR, + 'Non-success response in HTTP request', + `${errObj}`, + fspiopSource) + } +} + +module.exports = { + httpRequest +} diff --git a/src/lib/util.js b/src/lib/util.js index e43dcaa6..21442bc9 100644 --- a/src/lib/util.js +++ b/src/lib/util.js @@ -32,7 +32,14 @@ 'use strict' +const util = require('util') const Enum = require('@mojaloop/central-services-shared').Enum +const Logger = require('@mojaloop/central-services-logger') + +const failActionHandler = async (request, h, err) => { + Logger.error(`validation failure: ${getStackOrInspect}`) + throw err +} const getSpanTags = ({ payload, headers, params }, transactionType, transactionAction) => { const tags = { @@ -43,15 +50,51 @@ const getSpanTags = ({ payload, headers, params }, transactionType, transactionA source: headers[Enum.Http.Headers.FSPIOP.SOURCE], destination: headers[Enum.Http.Headers.FSPIOP.DESTINATION] } - if (payload && payload.payee && payload.payee.partyIdInfo && payload.payee.partyIdInfo.fspId) { - tags.payeeFsp = payload.payee.partyIdInfo.fspId + + const payeeFsp = getSafe(['payee', 'partyIdInfo', 'fspId'], payload) + const payerFsp = getSafe(['payer', 'partyIdInfo', 'fspId'], payload) + + if (payeeFsp) { + tags.payeeFsp = payeeFsp } - if (payload && payload.payer && payload.payer.partyIdInfo && payload.payer.partyIdInfo.fspId) { - tags.payerFsp = payload.payer.partyIdInfo.fspId + if (payerFsp) { + tags.payerFsp = payerFsp } + return tags } +/** + * @function getStackOrInspect + * @description Gets the error stack, or uses util.inspect to inspect the error + * @param {*} err - An error object + */ +function getStackOrInspect (err) { + return err.stack || util.inspect(err) +} + +/** + * @function getSafe + * @description Saftely get a nested value + * @param {Array} path - the path to the required variable + * @param {*} obj - The object with which to get the value from + * @returns {any | undefined} - The object at the path, or undefined + * + * @example + * Instead of the following: + * const fspId = payload && payload.payee && payload.payee.partyIdInfo && payload.payee.partyIdInfo.fspId + * + * You can use `getSafe()`: + * const fspId = getSafe(['payee', 'partyIdInfo', 'fspId'], payload) + * + */ +function getSafe (path, obj) { + return path.reduce((xs, x) => (xs && xs[x]) ? xs[x] : undefined, obj) +} + module.exports = { - getSpanTags + failActionHandler, + getSafe, + getSpanTags, + getStackOrInspect } diff --git a/src/model/quotes.js b/src/model/quotes.js index b8b71049..98fe7ecc 100644 --- a/src/model/quotes.js +++ b/src/model/quotes.js @@ -45,6 +45,8 @@ const Logger = require('@mojaloop/central-services-logger') const MLNumber = require('@mojaloop/ml-number') const Config = require('../lib/config') +const { httpRequest } = require('../lib/http') +const { getStackOrInspect } = require('../lib/util') const LOCAL_ENUM = require('../lib/enum') const rules = require('../../config/rules.json') const RulesEngine = require('./rules.js') @@ -52,49 +54,6 @@ const RulesEngine = require('./rules.js') delete axios.defaults.headers.common.Accept delete axios.defaults.headers.common['Content-Type'] -// TODO: where httpRequest is called, there's a pretty common pattern of obtaining an endpoint from -// the database, specialising a template string with that endpoint, then calling httpRequest. Is -// there common functionality in these places than can reasonably be factored out? -/** - * Encapsulates making an HTTP request and translating any error response into a domain-specific - * error type. - * - * @param {Object} opts - * @param {String} fspiopSource - * @returns {Promise} - */ -const httpRequest = async (opts, fspiopSource) => { - // Network errors lob an exception. Bear in mind 3xx 4xx and 5xx are not network errors so we - // need to wrap the request below in a `try catch` to handle network errors - let res - let body - - try { - res = await axios.request(opts) - body = await res.data - } catch (e) { - throw ErrorHandler.CreateFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.DESTINATION_COMMUNICATION_ERROR, - 'Network error', - `${e.stack || util.inspect(e)}. Opts: ${util.inspect(opts)}`, - fspiopSource) - } - - // handle non network related errors below - if (res.status < 200 || res.status >= 300) { - const errObj = util.inspect({ - opts, - status: res.status, - statusText: res.statusText, - body - }) - - throw ErrorHandler.CreateFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.DESTINATION_COMMUNICATION_ERROR, - 'Non-success response in HTTP request', - `${errObj}`, - fspiopSource) - } -} - /** * Encapsulates operations on the quotes domain model * @@ -344,7 +303,7 @@ class QuotesModel { childSpan = span.getChild('qs_quote_forwardQuoteRequest') } catch (err) { // internal-error - this.writeLog(`Error in handleQuoteRequest for quoteId ${quoteRequest.quoteId}: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in handleQuoteRequest for quoteId ${quoteRequest.quoteId}: ${getStackOrInspect(err)}`) if (txn) { txn.rollback(err) } @@ -371,7 +330,7 @@ class QuotesModel { // any-error // as we are on our own in this context, dont just rethrow the error, instead... // get the model to handle it - this.writeLog(`Error forwarding quote request: ${err.stack || util.inspect(err)}. Attempting to send error callback to ${fspiopSource}`) + this.writeLog(`Error forwarding quote request: ${getStackOrInspect(err)}. Attempting to send error callback to ${fspiopSource}`) if (envConfig.simpleRoutingMode) { await this.handleException(fspiopSource, quoteRequest.quoteId, err, headers, childSpan) } else { @@ -445,7 +404,7 @@ class QuotesModel { await httpRequest(opts, fspiopSource) } catch (err) { // any-error - this.writeLog(`Error forwarding quote request to endpoint ${endpoint}: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error forwarding quote request to endpoint ${endpoint}: ${getStackOrInspect(err)}`) throw ErrorHandler.ReformatFSPIOPError(err) } } @@ -472,7 +431,7 @@ class QuotesModel { // any-error // as we are on our own in this context, dont just rethrow the error, instead... // get the model to handle it - this.writeLog(`Error forwarding quote request: ${err.stack || util.inspect(err)}. Attempting to send error callback to ${fspiopSource}`) + this.writeLog(`Error forwarding quote request: ${getStackOrInspect(err)}. Attempting to send error callback to ${fspiopSource}`) const fspiopError = ErrorHandler.ReformatFSPIOPError(err) await this.handleException(fspiopSource, quoteRequest.quoteId, fspiopError, headers, childSpan) } finally { @@ -482,7 +441,7 @@ class QuotesModel { } } catch (err) { // internal-error - this.writeLog(`Error in handleQuoteRequestResend: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in handleQuoteRequestResend: ${getStackOrInspect(err)}`) throw ErrorHandler.ReformatFSPIOPError(err) } } @@ -593,7 +552,7 @@ class QuotesModel { // as we are on our own in this context, dont just rethrow the error, instead... // get the model to handle it const fspiopSource = headers[ENUM.Http.Headers.FSPIOP.SOURCE] - this.writeLog(`Error forwarding quote update: ${err.stack || util.inspect(err)}. Attempting to send error callback to ${fspiopSource}`) + this.writeLog(`Error forwarding quote update: ${getStackOrInspect(err)}. Attempting to send error callback to ${fspiopSource}`) await this.handleException(fspiopSource, quoteId, err, headers, childSpan) } finally { if (!childSpan.isFinished) { @@ -605,7 +564,7 @@ class QuotesModel { return refs } catch (err) { // internal-error - this.writeLog(`Error in handleQuoteUpdate: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in handleQuoteUpdate: ${getStackOrInspect(err)}`) if (txn) { txn.rollback(err) } @@ -679,7 +638,7 @@ class QuotesModel { await httpRequest(opts, fspiopSource) } catch (err) { // any-error - this.writeLog(`Error forwarding quote response to endpoint ${endpoint}: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error forwarding quote response to endpoint ${endpoint}: ${getStackOrInspect(err)}`) throw ErrorHandler.ReformatFSPIOPError(err) } } @@ -707,7 +666,7 @@ class QuotesModel { // any-error // as we are on our own in this context, dont just rethrow the error, instead... // get the model to handle it - this.writeLog(`Error forwarding quote response: ${err.stack || util.inspect(err)}. Attempting to send error callback to ${fspiopSource}`) + this.writeLog(`Error forwarding quote response: ${getStackOrInspect(err)}. Attempting to send error callback to ${fspiopSource}`) await this.handleException(fspiopSource, quoteId, err, headers, childSpan) } finally { if (!childSpan.isFinished) { @@ -716,7 +675,7 @@ class QuotesModel { } } catch (err) { // internal-error - this.writeLog(`Error in handleQuoteUpdateResend: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in handleQuoteUpdateResend: ${getStackOrInspect(err)}`) throw ErrorHandler.ReformatFSPIOPError(err) } } @@ -753,8 +712,10 @@ class QuotesModel { return newError } catch (err) { // internal-error - this.writeLog(`Error in handleQuoteError: ${err.stack || util.inspect(err)}`) - txn.rollback(err) + this.writeLog(`Error in handleQuoteError: ${getStackOrInspect(err)}`) + if (txn) { + txn.rollback(err) + } const fspiopError = ErrorHandler.ReformatFSPIOPError(err) const state = new EventSdk.EventStateMetadata(EventSdk.EventStatusType.failed, fspiopError.apiErrorCode.code, fspiopError.apiErrorCode.message) if (span) { @@ -781,7 +742,7 @@ class QuotesModel { // any-error // as we are on our own in this context, dont just rethrow the error, instead... // get the model to handle it - this.writeLog(`Error forwarding quote get: ${err.stack || util.inspect(err)}. Attempting to send error callback to ${fspiopSource}`) + this.writeLog(`Error forwarding quote get: ${getStackOrInspect(err)}. Attempting to send error callback to ${fspiopSource}`) await this.handleException(fspiopSource, quoteId, err, headers, childSpan) } finally { if (!childSpan.isFinished) { @@ -790,7 +751,7 @@ class QuotesModel { } } catch (err) { // internal-error - this.writeLog(`Error in handleQuoteGet: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in handleQuoteGet: ${getStackOrInspect(err)}`) const fspiopError = ErrorHandler.ReformatFSPIOPError(err) const state = new EventSdk.EventStateMetadata(EventSdk.EventStatusType.failed, fspiopError.apiErrorCode.code, fspiopError.apiErrorCode.message) if (span) { @@ -847,7 +808,7 @@ class QuotesModel { await httpRequest(opts, fspiopSource) } catch (err) { // any-error - this.writeLog(`Error forwarding quote get request: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error forwarding quote get request: ${getStackOrInspect(err)}`) throw ErrorHandler.ReformatFSPIOPError(err) } } @@ -867,7 +828,7 @@ class QuotesModel { } catch (err) { // any-error // not much we can do other than log the error - this.writeLog(`Error occurred while handling error. Check service logs as this error may not have been propagated successfully to any other party: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error occurred while handling error. Check service logs as this error may not have been propagated successfully to any other party: ${getStackOrInspect(err)}`) } finally { if (!childSpan.isFinished) { await childSpan.finish() @@ -953,7 +914,7 @@ class QuotesModel { } } catch (err) { // any-error - this.writeLog(`Error in sendErrorCallback: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in sendErrorCallback: ${getStackOrInspect(err)}`) const fspiopError = ErrorHandler.ReformatFSPIOPError(err) const state = new EventSdk.EventStateMetadata(EventSdk.EventStatusType.failed, fspiopError.apiErrorCode.code, fspiopError.apiErrorCode.message) if (span) { @@ -1003,7 +964,7 @@ class QuotesModel { } } catch (err) { // internal-error - this.writeLog(`Error in checkDuplicateQuoteRequest: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in checkDuplicateQuoteRequest: ${getStackOrInspect(err)}`) throw ErrorHandler.ReformatFSPIOPError(err) } } @@ -1047,7 +1008,7 @@ class QuotesModel { } } catch (err) { // internal-error - this.writeLog(`Error in checkDuplicateQuoteResponse: ${err.stack || util.inspect(err)}`) + this.writeLog(`Error in checkDuplicateQuoteResponse: ${getStackOrInspect(err)}`) throw ErrorHandler.ReformatFSPIOPError(err) } } diff --git a/src/model/rules.js b/src/model/rules.js index 34571c56..aac84e23 100644 --- a/src/model/rules.js +++ b/src/model/rules.js @@ -39,7 +39,7 @@ const jre = require('json-rules-engine') const assert = require('assert').strict -module.exports.events = { +const events = { INTERCEPT_QUOTE: 'INTERCEPT_QUOTE', INVALID_QUOTE_REQUEST: 'INVALID_QUOTE_REQUEST' } @@ -74,9 +74,14 @@ const createEngine = () => { * * @returns {promise} - array of failure cases, may be empty */ -module.exports.run = (rules, runtimeFacts) => { +const run = (rules, runtimeFacts) => { const engine = createEngine() rules.map(r => new jre.Rule(r)).forEach(r => engine.addRule(r)) return engine.run(runtimeFacts) } + +module.exports = { + events, + run +} diff --git a/src/server.js b/src/server.js index b96af020..a7fea9ab 100644 --- a/src/server.js +++ b/src/server.js @@ -1,3 +1,38 @@ +// (C)2018 ModusBox Inc. +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Initial contribution + -------------------- + The initial functionality and code base was donated by the Mowali project working in conjunction with MTN and Orange as service provides. + * Project: Mowali + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * ModusBox + - Georgi Georgiev + - Henk Kodde + - Matt Kingston + - Vassilis Barzokas + -------------- + ******/ + 'use strict' const Hapi = require('@hapi/hapi') @@ -9,10 +44,10 @@ const ErrorHandler = require('@mojaloop/central-services-error-handling') const CentralServices = require('@mojaloop/central-services-shared') const HeaderValidation = require('@mojaloop/central-services-shared').Util.Hapi.FSPIOPHeaderValidation const Logger = require('@mojaloop/central-services-logger') -const util = require('util') +const { getStackOrInspect, failActionHandler } = require('../src/lib/util') const Config = require('./lib/config.js') -const Database = require('./data/cachedDatabase.js') +const Database = require('./data/cachedDatabase') /** * Initializes a database connection pool @@ -37,10 +72,7 @@ const initServer = async function (db, config) { port: config.listenPort, routes: { validate: { - failAction: async (request, h, err) => { - Logger.error(`validation failure: ${err.stack || util.inspect(err)}`) - throw err - } + failAction: failActionHandler } } }) @@ -49,36 +81,39 @@ const initServer = async function (db, config) { server.app.database = db // add plugins to the server - await server.register([{ - plugin: HapiOpenAPI, - options: { - api: Path.resolve('./src/interface/swagger.json'), - handlers: Path.resolve('./src/handlers') - } - }, { - plugin: Good, - options: { - ops: { - interval: 1000 - }, - reporters: { - console: [{ - module: 'good-squeeze', - name: 'Squeeze', - args: [{ log: '*', response: '*' }] - }, { - module: 'good-console', - args: [{ format: '' }] - }, 'stdout'] + await server.register([ + { + plugin: HapiOpenAPI, + options: { + api: Path.resolve('./src/interface/swagger.json'), + handlers: Path.resolve('./src/handlers') } - } - }, - { - plugin: HeaderValidation - }, - Blipp, - ErrorHandler, - CentralServices.Util.Hapi.HapiEventPlugin]) + }, + { + plugin: Good, + options: { + ops: { + interval: 1000 + }, + reporters: { + console: [{ + module: 'good-squeeze', + name: 'Squeeze', + args: [{ log: '*', response: '*' }] + }, { + module: 'good-console', + args: [{ format: '' }] + }, 'stdout'] + } + } + }, + { + plugin: HeaderValidation + }, + Blipp, + ErrorHandler, + CentralServices.Util.Hapi.HapiEventPlugin + ]) // start the server await server.start() @@ -89,21 +124,33 @@ const initServer = async function (db, config) { // load config const config = new Config() -// initialise database connection pool and start the api server -initDb(config).then(db => { - return initServer(db, config) -}).then(server => { - process.on('SIGTERM', () => { - server.log(['info'], 'Received SIGTERM, closing server...') - server.stop({ timeout: 10000 }).then(err => { - Logger.warn(`server stopped. ${err ? (err.stack || util.inspect(err)) : ''}`) - process.exit((err) ? 1 : 0) +/** + * @function start + * @description Starts the web server + */ +async function start () { + // initialise database connection pool and start the api server + return initDb(config) + .then(db => initServer(db, config)) + .then(server => { + // Ignore coverage here as simulating `process.on('SIGTERM'...)` kills jest + /* istanbul ignore next */ + process.on('SIGTERM', () => { + console.log('sigterm???') + server.log(['info'], 'Received SIGTERM, closing server...') + server.stop({ timeout: 10000 }) + .then(err => { + Logger.warn(`server stopped. ${err ? (getStackOrInspect(err)) : ''}`) + process.exit((err) ? 1 : 0) + }) + }) + + server.plugins.openapi.setHost(server.info.host + ':' + server.info.port) + server.log(['info'], `Server running on ${server.info.uri}`) + // eslint-disable-next-line no-unused-vars + }).catch(err => { + Logger.error(`Error initializing server: ${getStackOrInspect(err)}`) }) - }) +} - server.plugins.openapi.setHost(server.info.host + ':' + server.info.port) - server.log(['info'], `Server running on ${server.info.uri}`) -// eslint-disable-next-line no-unused-vars -}).catch(err => { - Logger.error(`Error initializing server: ${err.stack || util.inspect(err)}`) -}) +module.exports = start diff --git a/test/unit/data/cachedDatabase.test.js b/test/unit/data/cachedDatabase.test.js new file mode 100644 index 00000000..6da37237 --- /dev/null +++ b/test/unit/data/cachedDatabase.test.js @@ -0,0 +1,191 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the 'License') and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Initial contribution + -------------------- + The initial functionality and code base was donated by the Mowali project working in conjunction with MTN and Orange as service provides. + * Project: Mowali + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Crosslake + - Lewis Daly + -------------- + ******/ + +const Config = require('../../../src/lib/config') +const CachedDatabase = require('../../../src/data/cachedDatabase') + +describe('cachedDatabase', () => { + describe('getCacheMethods', () => { + let cachedDb + + beforeEach(() => { + const config = new Config() + cachedDb = new CachedDatabase(config) + }) + + it('getInitiatorType', async () => { + // Arrange + cachedDb.cachePut('getInitiatorType', ['paramA'], 'testInitiatorTypeValue') + + // Act + const result = await cachedDb.getInitiatorType('paramA') + + // Assert + expect(result).toBe('testInitiatorTypeValue') + }) + + it('getInitiator', async () => { + // Arrange + cachedDb.cachePut('getInitiator', ['paramA'], 'getInitiatorValue') + + // Act + const result = await cachedDb.getInitiator('paramA') + + // Assert + expect(result).toBe('getInitiatorValue') + }) + + it('getScenario', async () => { + // Arrange + cachedDb.cachePut('getScenario', ['paramA'], 'getScenarioValue') + + // Act + const result = await cachedDb.getScenario('paramA') + + // Assert + expect(result).toBe('getScenarioValue') + }) + + it('getSubScenario', async () => { + // Arrange + cachedDb.cachePut('getSubScenario', ['paramA'], 'getSubScenarioValue') + + // Act + const result = await cachedDb.getSubScenario('paramA') + + // Assert + expect(result).toBe('getSubScenarioValue') + }) + + it('getAmountType', async () => { + // Arrange + cachedDb.cachePut('getAmountType', ['paramA'], 'getAmountTypeValue') + + // Act + const result = await cachedDb.getAmountType('paramA') + + // Assert + expect(result).toBe('getAmountTypeValue') + }) + + it('getPartyType', async () => { + // Arrange + cachedDb.cachePut('getPartyType', ['paramA'], 'getPartyTypeValue') + + // Act + const result = await cachedDb.getPartyType('paramA') + + // Assert + expect(result).toBe('getPartyTypeValue') + }) + + it('getPartyIdentifierType', async () => { + // Arrange + cachedDb.cachePut('getPartyIdentifierType', ['paramA'], 'getPartyIdentifierTypeValue') + + // Act + const result = await cachedDb.getPartyIdentifierType('paramA') + + // Assert + expect(result).toBe('getPartyIdentifierTypeValue') + }) + + it('getTransferParticipantRoleType', async () => { + // Arrange + cachedDb.cachePut('getTransferParticipantRoleType', ['paramA'], 'getTransferParticipantRoleTypeValue') + + // Act + const result = await cachedDb.getTransferParticipantRoleType('paramA') + + // Assert + expect(result).toBe('getTransferParticipantRoleTypeValue') + }) + + it('getLedgerEntryType', async () => { + // Arrange + cachedDb.cachePut('getLedgerEntryType', ['paramA'], 'getLedgerEntryTypeValue') + + // Act + const result = await cachedDb.getLedgerEntryType('paramA') + + // Assert + expect(result).toBe('getLedgerEntryTypeValue') + }) + }) + + describe('Cache Handling', () => { + let cachedDb + let Database + let MockCachedDatabase + + beforeEach(() => { + jest.resetModules() + + const config = new Config() + Database = require('../../../src/data/database') + MockCachedDatabase = require('../../../src/data/cachedDatabase') + + cachedDb = new MockCachedDatabase(config) + // Override the config since mocking out the superclass causes this to break + cachedDb.config = config + }) + + it('tries to get a value where none is cached', async () => { + // Arrange + // Mocking superclasses is a little tricky -- so we directly override the prototype here + Database.prototype.getLedgerEntryType = jest.fn().mockReturnValueOnce({ ledgerEntryType: true }) + const expected = { ledgerEntryType: true } + + // Act + const result = await cachedDb.getCacheValue('getLedgerEntryType', ['paramA']) + // Result should now be cached + const result2 = await cachedDb.getCacheValue('getLedgerEntryType', ['paramA']) + + // Assert + // Check that we only called the super method once, the 2nd time should be cached + expect(Database.prototype.getLedgerEntryType).toBeCalledTimes(1) + expect(result).toStrictEqual(expected) + expect(result2).toStrictEqual(expected) + }) + + it('handles an exception', async () => { + // Arrange + // Mocking superclasses is a little tricky -- so we directly override the prototype here + Database.prototype.getLedgerEntryType = jest.fn().mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => cachedDb.getCacheValue('getLedgerEntryType', ['paramA']) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) +}) diff --git a/test/unit/data/database.test.js b/test/unit/data/database.test.js new file mode 100644 index 00000000..5abb611c --- /dev/null +++ b/test/unit/data/database.test.js @@ -0,0 +1,2124 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the 'License') and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Initial contribution + -------------------- + The initial functionality and code base was donated by the Mowali project working in conjunction with MTN and Orange as service provides. + * Project: Mowali + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Crosslake + - Lewis Daly + -------------- +******/ + +jest.mock('knex') + +const Knex = require('knex') +const crypto = require('crypto') + +const Database = require('../../../src/data/database') +const Config = require('../../../src/lib/config') +const LibEnum = require('../../../src/lib/enum') + +let database + +/** + * @function mockKnexBuilder + * @description Stubs out a set of Knex calls in order + * @param {Jest.Mock} rootMock - the root jest mock object to apply the mocks to + * @param {*} returnValue - the final object to be returned + * @param {*} methodList - the list of querybuilder methods that will be called + */ +const mockKnexBuilder = (rootMock, returnValue, methodList) => { + const jestMocks = [] + + const firstMock = methodList.reduceRight((acc, curr, idx) => { + jestMocks.push(acc) + const thisReturnValue = {} + thisReturnValue[curr] = acc + + if (idx === 0) { + return rootMock.mockReturnValueOnce(thisReturnValue) + } + return jest.fn().mockReturnValueOnce(thisReturnValue) + }, jest.fn().mockReturnValueOnce(returnValue)) + + // Make sure we catch the last one + jestMocks.push(firstMock) + + // Ensure the mock order matches the called order + return jestMocks.reverse() +} + +describe('/database', () => { + // Mock knex object for raw queries + const mockKnex = { + transaction: jest.fn(), + raw: jest.fn() + } + + describe('raw queries', () => { + const config = {} + + beforeEach(async () => { + jest.clearAllMocks() + + // Return the mockKnex we defined above. + // For individual tests, simply call mockKnex..mockImplementation + Knex.mockImplementation(() => mockKnex) + database = new Database(config) + await database.connect() + }) + + it('connects to knex', async () => { + expect(database.config).toStrictEqual(config) + expect(database.queryBuilder).not.toBeUndefined() + }) + + // describe('initializes a transaction', () => { + // it('returns a transaction in a promise', async () => { + // // Arrange + // mockKnex.transaction.mockReturnValueOnce('testTx') + + // // Act + // const result = await database.newTransaction() + + // // Assert + // expect(result).toBe('testTx') + // }) + // }) + + describe('isConnected', () => { + it('returns true when connected', async () => { + // Arrange + mockKnex.raw.mockReturnValueOnce(true) + + // Act + const result = await database.isConnected() + + // Assert + expect(result).toBe(true) + expect(mockKnex.raw).toHaveBeenCalledWith('SELECT 1 + 1 AS result') + }) + + it('returns false on invalid or missing result', async () => { + // Arrange + mockKnex.raw.mockReturnValueOnce(undefined) + + // Act + const result = await database.isConnected() + + // Assert + expect(result).toBe(false) + }) + + it('returns false when queryBuilder throws an error', async () => { + // Arrange + mockKnex.raw.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const result = await database.isConnected() + + // Assert + expect(result).toBe(false) + }) + }) + }) + + describe('queryBuilder queries', () => { + // Mock knex object for queryBuilder queries + const mockKnex = jest.fn() + + beforeEach(async () => { + jest.clearAllMocks() + const defaultConfig = new Config() + + // Return the mockKnex we defined above. + // For individual tests, simply call mockKnex..mockImplementation + Knex.mockImplementation(() => mockKnex) + + database = new Database(defaultConfig) + await database.connect() + }) + + describe('getTransferRules', () => { + it('gets the initiator', async () => { + // Arrange + const mockList = mockKnexBuilder( + mockKnex, + [ + { rule: '{"testRule1": true}' }, + { rule: '{"testRule2": true}' } + ], + ['where', 'select'] + ) + const expected = [ + { testRule1: true }, + { testRule2: true } + ] + + // Act + const result = await database.getTransferRules() + + // Assert + expect(result).toStrictEqual(expected) + expect(mockList[0]).toHaveBeenCalledWith('transferRules') + expect(mockList[1]).toHaveBeenCalledWith('enabled', true) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles a JSON.parse error', async () => { + // Arrange + mockKnexBuilder( + mockKnex, + [ + { rule: '{"invalidJSON: true}' } + ], + ['where', 'select'] + ) + + // Act + const action = async () => database.getTransferRules() + + // Assert + await expect(action()).rejects.toThrowError('Unexpected end of JSON input') + }) + }) + + describe('getInitiatorType', () => { + it('gets the initiator', async () => { + // Arrange + const initiatorType = 'testInitiatorType' + const mockList = mockKnexBuilder( + mockKnex, + [{ transactionInitiatorTypeId: 123 }], + ['where', 'select'] + ) + + // Act + const result = await database.getInitiatorType(initiatorType) + + // Assert + expect(result).toStrictEqual(123) + expect(mockList[0]).toHaveBeenCalledWith('transactionInitiatorType') + expect(mockList[1]).toHaveBeenCalledWith('name', initiatorType) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where rows is undefined', async () => { + // Arrange + const initiatorType = 'testInitiatorType' + mockKnexBuilder( + mockKnex, + undefined, + ['where', 'select'] + ) + + // Act + const action = async () => database.getInitiatorType(initiatorType) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported initiatorType \'testInitiatorType\'') + }) + + it('handles the case where rows is empty', async () => { + // Arrange + const initiatorType = 'testInitiatorType' + mockKnexBuilder( + mockKnex, + [], + ['where', 'select'] + ) + + // Act + const action = async () => database.getInitiatorType(initiatorType) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported initiatorType \'testInitiatorType\'') + }) + + it('handles an exception', async () => { + // Arrange + const initiatorType = 'testInitiatorType' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getInitiatorType(initiatorType) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getInitiator', () => { + it('gets the initiator', async () => { + // Arrange + const initiator = 'testInitiator' + const mockList = mockKnexBuilder( + mockKnex, + [{ transactionInitiatorId: 123 }], + ['where', 'select'] + ) + + // Act + const result = await database.getInitiator(initiator) + + // Assert + expect(result).toStrictEqual(123) + expect(mockList[0]).toHaveBeenCalledWith('transactionInitiator') + expect(mockList[1]).toHaveBeenCalledWith('name', initiator) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where rows is undefined', async () => { + // Arrange + const initiator = 'testInitiator' + mockKnexBuilder( + mockKnex, + undefined, + ['where', 'select'] + ) + + // Act + const action = async () => database.getInitiator(initiator) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported initiator \'testInitiator\'') + }) + + it('handles the case where rows is empty', async () => { + // Arrange + const initiator = 'testInitiator' + mockKnexBuilder( + mockKnex, + [], + ['where', 'select'] + ) + + // Act + const action = async () => database.getInitiator(initiator) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported initiator \'testInitiator\'') + }) + + it('handles an exception', async () => { + // Arrange + const initiator = 'testInitiator' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getInitiator(initiator) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getScenario', () => { + it('gets the scenario', async () => { + // Arrange + const scenario = 'testScenario' + const mockList = mockKnexBuilder( + mockKnex, + [{ transactionScenarioId: 123 }], + ['where', 'select'] + ) + + // Act + const result = await database.getScenario(scenario) + + // Assert + expect(result).toStrictEqual(123) + expect(mockList[0]).toHaveBeenCalledWith('transactionScenario') + expect(mockList[1]).toHaveBeenCalledWith('name', scenario) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where rows is undefined', async () => { + // Arrange + const scenario = 'testScenario' + mockKnexBuilder( + mockKnex, + undefined, + ['where', 'select'] + ) + + // Act + const action = async () => database.getScenario(scenario) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported transaction scenario \'testScenario\'') + }) + + it('handles the case where rows is empty', async () => { + // Arrange + const scenario = 'testScenario' + mockKnexBuilder( + mockKnex, + [], + ['where', 'select'] + ) + + // Act + const action = async () => database.getScenario(scenario) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported transaction scenario \'testScenario\'') + }) + + it('handles an exception', async () => { + // Arrange + const scenario = 'testScenario' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getScenario(scenario) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getSubScenario', () => { + it('gets the subScenario', async () => { + // Arrange + const subScenario = 'testSubScenario' + const mockList = mockKnexBuilder( + mockKnex, + [{ transactionSubScenarioId: 123 }], + ['where', 'select'] + ) + + // Act + const result = await database.getSubScenario(subScenario) + + // Assert + expect(result).toStrictEqual(123) + expect(mockList[0]).toHaveBeenCalledWith('transactionSubScenario') + expect(mockList[1]).toHaveBeenCalledWith('name', subScenario) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where rows is undefined', async () => { + // Arrange + const subScenario = 'testSubScenario' + mockKnexBuilder( + mockKnex, + undefined, + ['where', 'select'] + ) + + // Act + const action = async () => database.getSubScenario(subScenario) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported transaction sub-scenario \'testSubScenario\'') + }) + + it('handles the case where rows is empty', async () => { + // Arrange + const subScenario = 'testSubScenario' + mockKnexBuilder( + mockKnex, + [], + ['where', 'select'] + ) + + // Act + const action = async () => database.getSubScenario(subScenario) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported transaction sub-scenario \'testSubScenario\'') + }) + + it('handles an exception', async () => { + // Arrange + const subScenario = 'testSubScenario' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getSubScenario(subScenario) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getAmountType', () => { + it('gets the amountType', async () => { + // Arrange + const amountType = 'testAmountType' + const mockList = mockKnexBuilder( + mockKnex, + [{ amountTypeId: 123 }], + ['where', 'select'] + ) + + // Act + const result = await database.getAmountType(amountType) + + // Assert + expect(result).toStrictEqual(123) + expect(mockList[0]).toHaveBeenCalledWith('amountType') + expect(mockList[1]).toHaveBeenCalledWith('name', amountType) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where rows is undefined', async () => { + // Arrange + const amountType = 'testAmountType' + mockKnexBuilder( + mockKnex, + undefined, + ['where', 'select'] + ) + + // Act + const action = async () => database.getAmountType(amountType) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported amount type \'testAmountType\'') + }) + + it('handles the case where rows is empty', async () => { + // Arrange + const amountType = 'testAmountType' + mockKnexBuilder( + mockKnex, + [], + ['where', 'select'] + ) + + // Act + const action = async () => database.getAmountType(amountType) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported amount type \'testAmountType\'') + }) + + it('handles an exception', async () => { + // Arrange + const amountType = 'testAmountType' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getAmountType(amountType) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('createTransactionReference', () => { + it('creates a transactionReference', async () => { + // Arrange + const txn = jest.fn() + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const transactionReferenceId = '12345' + const mockList = mockKnexBuilder( + mockKnex, + null, + ['transacting', 'insert'] + ) + + // Act + const result = await database.createTransactionReference(txn, quoteId, transactionReferenceId) + + // Assert + expect(result).toBe(transactionReferenceId) + expect(mockList[0]).toHaveBeenCalledWith('transactionReference') + expect(mockList[1]).toHaveBeenCalledWith(txn) + expect(mockList[2]).toHaveBeenCalledWith({ + quoteId, + transactionReferenceId + }) + }) + + it('handles an exception', async () => { + // Arrange + const txn = jest.fn() + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const transactionReferenceId = '12345' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.createTransactionReference(txn, quoteId, transactionReferenceId) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('createQuoteDuplicateCheck', () => { + it('creates a quoteDuplicateCheck', async () => { + // Arrange + const txn = jest.fn() + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const hash = crypto.createHash('sha256').update(quoteId).digest('hex') + const mockList = mockKnexBuilder( + mockKnex, + null, + ['transacting', 'insert'] + ) + + // Act + const result = await database.createQuoteDuplicateCheck(txn, quoteId, hash) + + // Assert + expect(result).toBe(quoteId) + expect(mockList[0]).toHaveBeenCalledWith('quoteDuplicateCheck') + expect(mockList[1]).toHaveBeenCalledWith(txn) + expect(mockList[2]).toHaveBeenCalledWith({ + quoteId, + hash + }) + }) + + it('handles an exception', async () => { + // Arrange + const txn = jest.fn() + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const hash = crypto.createHash('sha256').update(quoteId).digest('hex') + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.createQuoteDuplicateCheck(txn, quoteId, hash) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('createQuoteUpdateDuplicateCheck', () => { + it('creates a quoteUpdateDuplicateCheck', async () => { + // Arrange + const txn = jest.fn() + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const quoteResponseId = '12345' + const hash = crypto.createHash('sha256').update(quoteId).digest('hex') + const mockList = mockKnexBuilder( + mockKnex, + null, + ['transacting', 'insert'] + ) + + // Act + const result = await database.createQuoteUpdateDuplicateCheck(txn, quoteId, quoteResponseId, hash) + + // Assert + expect(result).toBe(quoteId) + expect(mockList[0]).toHaveBeenCalledWith('quoteResponseDuplicateCheck') + expect(mockList[1]).toHaveBeenCalledWith(txn) + expect(mockList[2]).toHaveBeenCalledWith({ + quoteId, + quoteResponseId, + hash + }) + }) + + it('handles an exception', async () => { + // Arrange + const txn = jest.fn() + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const quoteResponseId = '12345' + const hash = crypto.createHash('sha256').update(quoteId).digest('hex') + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.createQuoteUpdateDuplicateCheck(txn, quoteId, quoteResponseId, hash) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getPartyType', () => { + it('gets the partyType', async () => { + // Arrange + const partyType = 'testPartyType' + const mockList = mockKnexBuilder( + mockKnex, + [{ partyTypeId: 123 }], + ['where', 'select'] + ) + + // Act + const result = await database.getPartyType(partyType) + + // Assert + expect(result).toStrictEqual(123) + expect(mockList[0]).toHaveBeenCalledWith('partyType') + expect(mockList[1]).toHaveBeenCalledWith('name', partyType) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where rows is undefined', async () => { + // Arrange + const partyType = 'testPartyType' + mockKnexBuilder( + mockKnex, + undefined, + ['where', 'select'] + ) + + // Act + const action = async () => database.getPartyType(partyType) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported party type \'testPartyType\'') + }) + + it('handles the case where rows is empty', async () => { + // Arrange + const partyType = 'testPartyType' + mockKnexBuilder( + mockKnex, + [], + ['where', 'select'] + ) + + // Act + const action = async () => database.getPartyType(partyType) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported party type \'testPartyType\'') + }) + + it('handles an exception', async () => { + // Arrange + const partyType = 'testPartyType' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getPartyType(partyType) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getPartyIdentifierType', () => { + it('gets the partyIdentifierType', async () => { + // Arrange + const partyIdentifierType = 'testPartyIdentifierType' + const mockList = mockKnexBuilder( + mockKnex, + [{ partyIdentifierTypeId: 123 }], + ['where', 'select'] + ) + + // Act + const result = await database.getPartyIdentifierType(partyIdentifierType) + + // Assert + expect(result).toStrictEqual(123) + expect(mockList[0]).toHaveBeenCalledWith('partyIdentifierType') + expect(mockList[1]).toHaveBeenCalledWith('name', partyIdentifierType) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where rows is undefined', async () => { + // Arrange + const partyIdentifierType = 'testPartyIdentifierType' + mockKnexBuilder( + mockKnex, + undefined, + ['where', 'select'] + ) + + // Act + const action = async () => database.getPartyIdentifierType(partyIdentifierType) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported party identifier type \'testPartyIdentifierType\'') + }) + + it('handles the case where rows is empty', async () => { + // Arrange + const partyIdentifierType = 'testPartyIdentifierType' + mockKnexBuilder( + mockKnex, + [], + ['where', 'select'] + ) + + // Act + const action = async () => database.getPartyIdentifierType(partyIdentifierType) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported party identifier type \'testPartyIdentifierType\'') + }) + + it('handles an exception', async () => { + // Arrange + const partyIdentifierType = 'testPartyIdentifierType' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getPartyIdentifierType(partyIdentifierType) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getParticipant', () => { + it('gets the participant for PAYEE_DFSP', async () => { + // Arrange + const participantName = 'dfsp1' + const participantType = LibEnum.PAYEE_DFSP + const mockList = mockKnexBuilder( + mockKnex, + [{ participantId: 123 }], + ['where', 'select'] + ) + + // Act + const result = await database.getParticipant(participantName, participantType) + + // Assert + expect(result).toBe(123) + expect(mockList[0]).toHaveBeenCalledWith('participant') + expect(mockList[1]).toHaveBeenCalledWith({ name: participantName, isActive: 1 }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles an undefined response with a participantType of PAYEE_DFSP', async () => { + // Arrange + const participantName = 'dfsp1' + const participantType = LibEnum.PAYEE_DFSP + mockKnexBuilder( + mockKnex, + undefined, + ['where', 'select'] + ) + + // Act + const action = async () => database.getParticipant(participantName, participantType) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported participant') + }) + + it('handles an undefined response with a participantType of PAYER_DFSP', async () => { + // Arrange + const participantName = 'dfsp1' + const participantType = LibEnum.PAYER_DFSP + mockKnexBuilder( + mockKnex, + undefined, + ['where', 'select'] + ) + + // Act + const action = async () => database.getParticipant(participantName, participantType) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported participant') + }) + + it('handles an empty response with no participantType', async () => { + // Arrange + const participantName = 'dfsp1' + mockKnexBuilder( + mockKnex, + [], + ['where', 'select'] + ) + + // Act + const action = async () => database.getParticipant(participantName) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported participant') + }) + }) + + describe('getTransferParticipantRoleType', () => { + it('gets the transferParticipantRoleType', async () => { + // Arrange + const name = 'testName' + const mockList = mockKnexBuilder( + mockKnex, + [{ transferParticipantRoleTypeId: 123 }], + ['where', 'select'] + ) + + // Act + const result = await database.getTransferParticipantRoleType(name) + + // Assert + expect(result).toStrictEqual(123) + expect(mockList[0]).toHaveBeenCalledWith('transferParticipantRoleType') + expect(mockList[1]).toHaveBeenCalledWith({ name, isActive: 1 }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where rows is undefined', async () => { + // Arrange + const name = 'testName' + mockKnexBuilder( + mockKnex, + undefined, + ['where', 'select'] + ) + + // Act + const action = async () => database.getTransferParticipantRoleType(name) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported transfer participant role type \'testName\'') + }) + + it('handles the case where rows is empty', async () => { + // Arrange + const name = 'testName' + mockKnexBuilder( + mockKnex, + [], + ['where', 'select'] + ) + + // Act + const action = async () => database.getTransferParticipantRoleType(name) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported transfer participant role type \'testName\'') + }) + + it('handles an exception', async () => { + // Arrange + const name = 'name' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getTransferParticipantRoleType(name) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getLedgerEntryType', () => { + it('gets the ledgerEntityType', async () => { + // Arrange + const name = 'ledgerName' + const mockList = mockKnexBuilder( + mockKnex, + [{ ledgerEntryTypeId: 123 }], + ['where', 'select'] + ) + + // Act + const result = await database.getLedgerEntryType(name) + + // Assert + expect(result).toStrictEqual(123) + expect(mockList[0]).toHaveBeenCalledWith('ledgerEntryType') + expect(mockList[1]).toHaveBeenCalledWith({ name, isActive: 1 }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where rows is undefined', async () => { + // Arrange + const name = 'ledgerName' + mockKnexBuilder( + mockKnex, + undefined, + ['where', 'select'] + ) + + // Act + const action = async () => database.getLedgerEntryType(name) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported ledger entry type \'ledgerName\'') + }) + + it('handles the case where rows is empty', async () => { + // Arrange + const name = 'ledgerName' + mockKnexBuilder( + mockKnex, + [], + ['where', 'select'] + ) + + // Act + const action = async () => database.getLedgerEntryType(name) + + // Assert + await expect(action()).rejects.toThrowError('Unsupported ledger entry type \'ledgerName\'') + }) + + it('handles an exception', async () => { + // Arrange + const name = 'ledgerName' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getLedgerEntryType(name) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('createPayerQuoteParty', () => { + it('creates a payer quote for a party', () => { + // Arrange + const txn = jest.fn() + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const party = {} + const amount = 100 + const currency = 'AUD' + database.createQuoteParty = jest.fn() + + // Act + database.createPayerQuoteParty(txn, quoteId, party, amount, currency) + + // Assert + expect(database.createQuoteParty).toHaveBeenCalledWith( + txn, + quoteId, + LibEnum.PAYER, + LibEnum.PAYER_DFSP, + LibEnum.PRINCIPLE_VALUE, + party, + 100, + 'AUD' + ) + }) + }) + + describe('createPayeeQuoteParty', () => { + it('creates a payee quote for a party', () => { + // Arrange + const txn = jest.fn() + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const party = {} + const amount = 100 + const currency = 'AUD' + database.createQuoteParty = jest.fn() + + // Act + database.createPayeeQuoteParty(txn, quoteId, party, amount, currency) + + // Assert + expect(database.createQuoteParty).toHaveBeenCalledWith( + txn, + quoteId, + LibEnum.PAYEE, + LibEnum.PAYEE_DFSP, + LibEnum.PRINCIPLE_VALUE, + party, + -100, + 'AUD' + ) + }) + }) + + describe('createQuoteParty', () => { + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const partyType = LibEnum.PAYEE + const participantType = LibEnum.PAYEE_DFSP + const ledgerEntryType = LibEnum.PRINCIPLE_VALUE + const amount = 100 + const currency = 'AUD' + + beforeEach(() => { + database.getPartyType = jest.fn().mockResolvedValueOnce('testPartyTypeId') + database.getPartyIdentifierType = jest.fn().mockResolvedValueOnce('testPartyIdentifierTypeId') + database.getParticipant = jest.fn().mockResolvedValueOnce('testParticipantId') + database.getTransferParticipantRoleType = jest.fn().mockResolvedValueOnce('testTransferParticipantRoleTypeId') + database.getLedgerEntryType = jest.fn().mockResolvedValueOnce('testLedgerEntryTypeId') + }) + + it('Creates a quote party', async () => { + // Arrange + const txn = jest.fn() + const party = { + partyName: 'testPartyName', + partyIdInfo: { + partyIdentifier: 'testPartyIdentifier', + partyIdType: 'MSISDN', + fspId: 'payeeFsp' + }, + merchantClassificationCode: '0' + } + const mockList = mockKnexBuilder( + mockKnex, + ['12345'], + ['transacting', 'insert'] + ) + const expectedNewQuoteParty = { + quoteId, + partyTypeId: 'testPartyTypeId', + partyIdentifierTypeId: 'testPartyIdentifierTypeId', + partyIdentifierValue: 'testPartyIdentifier', + partySubIdOrTypeId: undefined, + fspId: 'payeeFsp', + participantId: 'testParticipantId', + merchantClassificationCode: '0', + partyName: 'testPartyName', + transferParticipantRoleTypeId: 'testTransferParticipantRoleTypeId', + ledgerEntryTypeId: 'testLedgerEntryTypeId', + amount: '100.0000', + currencyId: 'AUD' + } + + // Act + const result = await database.createQuoteParty(txn, quoteId, partyType, participantType, ledgerEntryType, party, amount, currency) + + // Assert + expect(result).toBe('12345') + expect(mockList[0]).toHaveBeenCalledWith('quoteParty') + expect(mockList[1]).toHaveBeenCalledWith(txn) + expect(mockList[2]).toHaveBeenCalledWith(expectedNewQuoteParty) + }) + + it('handles the partySubIdOrType', async () => { + // Arrange + const txn = jest.fn() + const party = { + partyName: 'testPartyName', + partyIdInfo: { + partySubIdOrType: 'testSubId', + partyIdentifier: 'testPartyIdentifier', + partyIdType: 'MSISDN', + fspId: 'payeeFsp' + }, + merchantClassificationCode: '0' + } + database.getPartyIdentifierType = jest.fn() + .mockResolvedValueOnce('testPartyIdentifierTypeId') + .mockResolvedValueOnce('testPartySubIdOrTypeId') + const mockList = mockKnexBuilder( + mockKnex, + ['12345'], + ['transacting', 'insert'] + ) + const expectedNewQuoteParty = { + quoteId, + partyTypeId: 'testPartyTypeId', + partyIdentifierTypeId: 'testPartyIdentifierTypeId', + partyIdentifierValue: 'testPartyIdentifier', + partySubIdOrTypeId: 'testPartySubIdOrTypeId', + fspId: 'payeeFsp', + participantId: 'testParticipantId', + merchantClassificationCode: '0', + partyName: 'testPartyName', + transferParticipantRoleTypeId: 'testTransferParticipantRoleTypeId', + ledgerEntryTypeId: 'testLedgerEntryTypeId', + amount: '100.0000', + currencyId: 'AUD' + } + + // Act + const result = await database.createQuoteParty(txn, quoteId, partyType, participantType, ledgerEntryType, party, amount, currency) + + // Assert + expect(result).toBe('12345') + expect(mockList[0]).toHaveBeenCalledWith('quoteParty') + expect(mockList[1]).toHaveBeenCalledWith(txn) + expect(mockList[2]).toHaveBeenCalledWith(expectedNewQuoteParty) + }) + + it('creates a new party if the party contains personal info', async () => { + // Arrange + const txn = jest.fn() + const party = { + partyName: 'testPartyName', + partyIdInfo: { + partyIdentifier: 'testPartyIdentifier', + partyIdType: 'MSISDN', + fspId: 'payeeFsp' + }, + merchantClassificationCode: '0', + personalInfo: { + complexName: { + firstName: 'Mats', + middleName: 'Middle', + lastName: 'Hagman' + }, + dateOfBirth: '1983-10-25' + } + } + const mockList = mockKnexBuilder( + mockKnex, + ['12345'], + ['transacting', 'insert'] + ) + database.createParty = jest.fn() + const expectedNewQuoteParty = { + quoteId, + partyTypeId: 'testPartyTypeId', + partyIdentifierTypeId: 'testPartyIdentifierTypeId', + partyIdentifierValue: 'testPartyIdentifier', + partySubIdOrTypeId: undefined, + fspId: 'payeeFsp', + participantId: 'testParticipantId', + merchantClassificationCode: '0', + partyName: 'testPartyName', + transferParticipantRoleTypeId: 'testTransferParticipantRoleTypeId', + ledgerEntryTypeId: 'testLedgerEntryTypeId', + amount: '100.0000', + currencyId: 'AUD' + } + const expectedNewParty = { + firstName: 'Mats', + middleName: 'Middle', + lastName: 'Hagman', + dateOfBirth: '1983-10-25' + } + + // Act + const result = await database.createQuoteParty(txn, quoteId, partyType, participantType, ledgerEntryType, party, amount, currency) + + // Assert + expect(result).toBe('12345') + expect(mockList[0]).toHaveBeenCalledWith('quoteParty') + expect(mockList[1]).toHaveBeenCalledWith(txn) + expect(mockList[2]).toHaveBeenCalledWith(expectedNewQuoteParty) + expect(database.createParty).toHaveBeenCalledWith(txn, '12345', expectedNewParty) + }) + + it('handles an exception when creating a quote', async () => { + // Arrange + const txn = jest.fn() + const party = { + partyName: 'testPartyName', + partyIdInfo: { + partyIdentifier: 'testPartyIdentifier', + partyIdType: 'MSISDN', + fspId: 'payeeFsp' + }, + merchantClassificationCode: '0' + } + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.createQuoteParty(txn, quoteId, partyType, participantType, ledgerEntryType, party, amount, currency) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getQuotePartyView', () => { + it('gets the quotePartyView', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder( + mockKnex, + ['12345'], + ['where', 'select'] + ) + + // Act + const result = await database.getQuotePartyView(quoteId) + + // Assert + expect(result).toStrictEqual(['12345']) + expect(mockList[0]).toHaveBeenCalledWith('quotePartyView') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles an exception', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getQuotePartyView(quoteId) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getQuoteView', () => { + it('gets the getQuoteView', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder( + mockKnex, + ['12345'], + ['where', 'select'] + ) + + // Act + const result = await database.getQuoteView(quoteId) + + // Assert + expect(result).toStrictEqual('12345') + expect(mockList[0]).toHaveBeenCalledWith('quoteView') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where the return rows are undefined', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder( + mockKnex, + undefined, + ['where', 'select'] + ) + + // Act + const result = await database.getQuoteView(quoteId) + + // Assert + expect(result).toStrictEqual(null) + expect(mockList[0]).toHaveBeenCalledWith('quoteView') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where the return rows are empty', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder( + mockKnex, + [], + ['where', 'select'] + ) + + // Act + const result = await database.getQuoteView(quoteId) + + // Assert + expect(result).toStrictEqual(null) + expect(mockList[0]).toHaveBeenCalledWith('quoteView') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where there is more than 1 row', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + mockKnexBuilder( + mockKnex, + ['12345', '67890'], + ['where', 'select'] + ) + + // Act + const action = async () => database.getQuoteView(quoteId) + + // Assert + await expect(action()).rejects.toThrowError(new RegExp('Expected 1 row for quoteId .*')) + }) + }) + + describe('getQuoteResponseView', () => { + it('gets the quoteResponseView', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder( + mockKnex, + ['12345'], + ['where', 'select'] + ) + + // Act + const result = await database.getQuoteResponseView(quoteId) + + // Assert + expect(result).toStrictEqual('12345') + expect(mockList[0]).toHaveBeenCalledWith('quoteResponseView') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where the return rows are undefined', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder( + mockKnex, + undefined, + ['where', 'select'] + ) + + // Act + const result = await database.getQuoteResponseView(quoteId) + + // Assert + expect(result).toStrictEqual(null) + expect(mockList[0]).toHaveBeenCalledWith('quoteResponseView') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where the return rows are empty', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder( + mockKnex, + [], + ['where', 'select'] + ) + + // Act + const result = await database.getQuoteResponseView(quoteId) + + // Assert + expect(result).toStrictEqual(null) + expect(mockList[0]).toHaveBeenCalledWith('quoteResponseView') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles the case where there is more than 1 row', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + mockKnexBuilder( + mockKnex, + ['12345', '67890'], + ['where', 'select'] + ) + + // Act + const action = async () => database.getQuoteResponseView(quoteId) + + // Assert + await expect(action()).rejects.toThrowError(new RegExp('Expected 1 row for quoteId .*')) + }) + }) + + describe('createParty', () => { + const quotePartyId = '12345' + const party = { + firstName: 'Mats', + middleName: 'Middle', + lastName: 'Hagman', + dateOfBirth: '1983-10-25' + } + + it('creates a party', async () => { + // Arrange + const txn = jest.fn() + const mockList = mockKnexBuilder( + mockKnex, + ['12345'], + ['transacting', 'insert'] + ) + const expected = { + partyId: '12345', + ...party, + quotePartyId + } + + // Act + const result = await database.createParty(txn, quotePartyId, party) + + // Assert + expect(result).toStrictEqual(expected) + expect(mockList[0]).toHaveBeenCalledWith('party') + expect(mockList[1]).toHaveBeenCalledWith(txn) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles an exception when creating a party', async () => { + // Arrange + const txn = jest.fn() + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.createParty(txn, quotePartyId, party) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('createQuote', () => { + const mockQuote = { + quoteId: 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37', + transactionReferenceId: 'referenceId', + transactionRequestId: 'abc123', + note: 'test quote', + expirationDate: '2019-10-30T10:30:19.899Z', + transactionInitiatorId: 'CONSUMER', + transactionInitiatorTypeId: 'payee', + transactionScenarioId: 'TRANSFER', + balanceOfPaymentsId: '1', + transactionSubScenarioId: 'testSubScenario', + amountTypeId: 'SEND', + amount: 100, + currencyId: 'USD' + } + + it('creates a quote', async () => { + // Arrange + const txn = jest.fn() + const mockList = mockKnexBuilder( + mockKnex, + null, + ['transacting', 'insert'] + ) + const expectedInsert = { + ...mockQuote, + amount: '100.0000' + } + + // Act + const result = await database.createQuote(txn, mockQuote) + + // Assert + expect(result).toEqual(mockQuote.quoteId) + expect(mockList[0]).toHaveBeenCalledWith('quote') + expect(mockList[1]).toHaveBeenCalledWith(txn) + expect(mockList[2]).toHaveBeenCalledWith(expectedInsert) + }) + + it('handles an error creating the quote', async () => { + // Arrange + const txn = jest.fn() + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.createQuote(txn, mockQuote) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getQuoteParty', () => { + it('gets the quote party', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const partyType = 'PAYEE' + const mockList = mockKnexBuilder( + mockKnex, + [{ value: 'mockQuoteParty' }], + ['innerJoin', 'where', 'andWhere', 'select'] + ) + + // Act + const result = await database.getQuoteParty(quoteId, partyType) + + // Assert + expect(result).toStrictEqual({ value: 'mockQuoteParty' }) + expect(mockList[0]).toHaveBeenCalledWith('quoteParty') + expect(mockList[1]).toHaveBeenCalledWith('partyType', 'partyType.partyTypeId', 'quoteParty.partyTypeId') + expect(mockList[2]).toHaveBeenCalledWith('quoteParty.quoteId', quoteId) + expect(mockList[3]).toHaveBeenCalledWith('partyType.name', partyType) + expect(mockList[4]).toHaveBeenCalledWith('quoteParty.*') + }) + + it('returns null when the query returns undefined', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const partyType = 'PAYEE' + mockKnexBuilder( + mockKnex, + undefined, + ['innerJoin', 'where', 'andWhere', 'select'] + ) + + // Act + const result = await database.getQuoteParty(quoteId, partyType) + + // Assert + expect(result).toStrictEqual(null) + }) + + it('returns null when the query returns no rows', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const partyType = 'PAYEE' + mockKnexBuilder( + mockKnex, + [], + ['innerJoin', 'where', 'andWhere', 'select'] + ) + + // Act + const result = await database.getQuoteParty(quoteId, partyType) + + // Assert + expect(result).toStrictEqual(null) + }) + + it('handles an exception', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const partyType = 'PAYEE' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getQuoteParty(quoteId, partyType) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + + it('throws an exception when more than one quoteParty is found', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const partyType = 'PAYEE' + mockKnexBuilder( + mockKnex, + [{ value: 'mockQuoteParty' }, { value: 'mockQuoteParty2' }], + ['innerJoin', 'where', 'andWhere', 'select'] + ) + + // Act + const action = async () => database.getQuoteParty(quoteId, partyType) + + // Assert + await expect(action()).rejects.toThrowError(new RegExp('Expected 1 quoteParty .*')) + }) + }) + + describe('getQuotePartyEndpoint', () => { + it('gets the quote party endpoint', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const endpointType = 'FSPIOP_CALLBACK_URL_QUOTES' + const partyType = 'PAYEE' + const mockList = mockKnexBuilder( + mockKnex, + [{ value: 'http://localhost:3000/testEndpoint' }], + ['innerJoin', 'innerJoin', 'innerJoin', 'innerJoin', 'where', 'andWhere', 'andWhere', 'andWhere', 'select'] + ) + + // Act + const result = await database.getQuotePartyEndpoint(quoteId, endpointType, partyType) + + // Assert + expect(result).toBe('http://localhost:3000/testEndpoint') + expect(mockList[0]).toHaveBeenCalledWith('participantEndpoint') + expect(mockList[1]).toHaveBeenCalledWith('endpointType', 'participantEndpoint.endpointTypeId', 'endpointType.endpointTypeId') + expect(mockList[2]).toHaveBeenCalledWith('quoteParty', 'quoteParty.participantId', 'participantEndpoint.participantId') + expect(mockList[3]).toHaveBeenCalledWith('partyType', 'partyType.partyTypeId', 'quoteParty.partyTypeId') + expect(mockList[4]).toHaveBeenCalledWith('quote', 'quote.quoteId', 'quoteParty.quoteId') + expect(mockList[5]).toHaveBeenCalledWith('endpointType.name', endpointType) + expect(mockList[6]).toHaveBeenCalledWith('partyType.name', partyType) + expect(mockList[7]).toHaveBeenCalledWith('quote.quoteId', quoteId) + expect(mockList[8]).toHaveBeenCalledWith('participantEndpoint.isActive', 1) + expect(mockList[9]).toHaveBeenCalledWith('participantEndpoint.value') + }) + + it('returns null when the query returns undefined', async () => { + // Arrange + const participantName = 'fsp1' + const endpointType = 'FSPIOP_CALLBACK_URL_QUOTES' + mockKnexBuilder( + mockKnex, + undefined, + ['innerJoin', 'innerJoin', 'innerJoin', 'innerJoin', 'where', 'andWhere', 'andWhere', 'andWhere', 'select'] + ) + + // Act + const result = await database.getQuotePartyEndpoint(participantName, endpointType) + + // Assert + expect(result).toBe(null) + }) + + it('returns null when there are no rows found', async () => { + // Arrange + const participantName = 'fsp1' + const endpointType = 'FSPIOP_CALLBACK_URL_QUOTES' + mockKnexBuilder( + mockKnex, + [], + ['innerJoin', 'innerJoin', 'innerJoin', 'innerJoin', 'where', 'andWhere', 'andWhere', 'andWhere', 'select'] + ) + + // Act + const result = await database.getQuotePartyEndpoint(participantName, endpointType) + + // Assert + expect(result).toBe(null) + }) + + it('handles an exception', async () => { + // Arrange + const participantName = 'fsp1' + const endpointType = 'FSPIOP_CALLBACK_URL_QUOTES' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getQuotePartyEndpoint(participantName, endpointType) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getParticipantEndpoint', () => { + it('gets the participant endpoint', async () => { + // Arrange + const participantName = 'fsp1' + const endpointType = 'FSPIOP_CALLBACK_URL_QUOTES' + const mockList = mockKnexBuilder( + mockKnex, + [{ value: 'http://localhost:3000/testEndpoint' }], + ['innerJoin', 'innerJoin', 'where', 'andWhere', 'andWhere', 'select'] + ) + + // Act + const result = await database.getParticipantEndpoint(participantName, endpointType) + + // Assert + expect(result).toBe('http://localhost:3000/testEndpoint') + expect(mockList[0]).toBeCalledWith('participantEndpoint') + expect(mockList[1]).toBeCalledWith('participant', 'participant.participantId', 'participantEndpoint.participantId') + expect(mockList[2]).toBeCalledWith('endpointType', 'endpointType.endpointTypeId', 'participantEndpoint.endpointTypeId') + expect(mockList[3]).toBeCalledWith('participant.name', participantName) + expect(mockList[4]).toBeCalledWith('endpointType.name', endpointType) + expect(mockList[5]).toBeCalledWith('participantEndpoint.isActive', 1) + expect(mockList[6]).toBeCalledWith('participantEndpoint.value') + }) + + it('returns null when the query returns undefined', async () => { + // Arrange + const participantName = 'fsp1' + const endpointType = 'FSPIOP_CALLBACK_URL_QUOTES' + mockKnexBuilder( + mockKnex, + undefined, + ['innerJoin', 'innerJoin', 'where', 'andWhere', 'andWhere', 'select'] + ) + + // Act + const result = await database.getParticipantEndpoint(participantName, endpointType) + + // Assert + expect(result).toBe(null) + }) + + it('returns null when there are no rows found', async () => { + // Arrange + const participantName = 'fsp1' + const endpointType = 'FSPIOP_CALLBACK_URL_QUOTES' + mockKnexBuilder( + mockKnex, + [], + ['innerJoin', 'innerJoin', 'where', 'andWhere', 'andWhere', 'select'] + ) + + // Act + const result = await database.getParticipantEndpoint(participantName, endpointType) + + // Assert + expect(result).toBe(null) + }) + + it('handles an exception', async () => { + // Arrange + const participantName = 'fsp1' + const endpointType = 'FSPIOP_CALLBACK_URL_QUOTES' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getParticipantEndpoint(participantName, endpointType) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getQuoteDuplicateCheck', () => { + it('gets the getQuoteDuplicateCheck', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder(mockKnex, ['1'], ['where', 'select']) + + // Act + const result = await database.getQuoteDuplicateCheck(quoteId) + + // Assert + expect(result).toBe('1') + expect(mockList[0]).toHaveBeenCalledWith('quoteDuplicateCheck') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('returns null when the query returns undefined', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder(mockKnex, null, ['where', 'select']) + + // Act + const result = await database.getQuoteDuplicateCheck(quoteId) + + // Assert + expect(result).toBe(null) + expect(mockList[0]).toHaveBeenCalledWith('quoteDuplicateCheck') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('returns null when there are no rows found', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder(mockKnex, [], ['where', 'select']) + + // Act + const result = await database.getQuoteDuplicateCheck(quoteId) + + // Assert + expect(result).toBe(null) + expect(mockList[0]).toHaveBeenCalledWith('quoteDuplicateCheck') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles an exception', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getQuoteDuplicateCheck(quoteId) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getQuoteResponseDuplicateCheck', () => { + it('gets the quoteResponseDuplicateCheck', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder(mockKnex, ['1'], ['where', 'select']) + + // Act + const result = await database.getQuoteResponseDuplicateCheck(quoteId) + + // Assert + expect(result).toBe('1') + expect(mockList[0]).toHaveBeenCalledWith('quoteResponseDuplicateCheck') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('returns null when the query returns undefined', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder(mockKnex, undefined, ['where', 'select']) + + // Act + const result = await database.getQuoteResponseDuplicateCheck(quoteId) + + // Assert + expect(result).toBe(null) + expect(mockList[0]).toHaveBeenCalledWith('quoteResponseDuplicateCheck') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('returns null when there are no rows found', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder(mockKnex, [], ['where', 'select']) + + // Act + const result = await database.getQuoteResponseDuplicateCheck(quoteId) + + // Assert + expect(result).toBe(null) + expect(mockList[0]).toHaveBeenCalledWith('quoteResponseDuplicateCheck') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles an exception', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getQuoteResponseDuplicateCheck(quoteId) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getTransactionReference', () => { + it('gets the transaction reference', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder(mockKnex, ['1'], ['where', 'select']) + + // Act + const result = await database.getTransactionReference(quoteId) + + // Assert + expect(result).toBe('1') + expect(mockList[0]).toHaveBeenCalledWith('transactionReference') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('returns null when the query returns undefined', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder(mockKnex, undefined, ['where', 'select']) + + // Act + const result = await database.getTransactionReference(quoteId) + + // Assert + expect(result).toBe(null) + expect(mockList[0]).toHaveBeenCalledWith('transactionReference') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('returns null when there are no rows found', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder(mockKnex, [], ['where', 'select']) + + // Act + const result = await database.getTransactionReference(quoteId) + + // Assert + expect(result).toBe(null) + expect(mockList[0]).toHaveBeenCalledWith('transactionReference') + expect(mockList[1]).toHaveBeenCalledWith({ quoteId }) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles an exception', async () => { + // Arrange + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.getTransactionReference(quoteId) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('createQuoteResponse', () => { + const completeQuoteResponse = { + transferAmount: { + amount: '100', + currency: 'USD' + }, + payeeReceiveAmount: { + amount: '99', + currency: 'USD' + }, + payeeFspFee: { + amount: '1', + currency: 'USD' + }, + payeeFspCommission: { + amount: '1', + currency: 'USD' + }, + condition: 'HOr22-H3AfTDHrSkPjJtVPRdKouuMkDXTR4ejlQa8Ks', + expiration: '2019-05-27T15:44:53.292Z', + isValid: true + } + + it('creates the quote response', async () => { + // Arrange + const txn = jest.fn() + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + const mockList = mockKnexBuilder(mockKnex, ['1'], ['transacting', 'insert']) + const expected = { + quoteId, + quoteResponseId: '1', + ilpCondition: completeQuoteResponse.condition, + isValid: completeQuoteResponse.isValid, + payeeFspCommissionAmount: '1.0000', + payeeFspCommissionCurrencyId: 'USD', + payeeFspFeeAmount: '1.0000', + payeeFspFeeCurrencyId: 'USD', + payeeReceiveAmount: '99.0000', + payeeReceiveAmountCurrencyId: 'USD', + responseExpirationDate: '2019-05-27T15:44:53.292Z', + transferAmount: '100.0000', + transferAmountCurrencyId: 'USD' + } + + // Act + const result = await database.createQuoteResponse(txn, quoteId, completeQuoteResponse) + + // Assert + expect(result).toStrictEqual(expected) + expect(mockList[0]).toHaveBeenCalledWith('quoteResponse') + expect(mockList[1]).toHaveBeenCalledWith(txn) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles an exception in createQuoteResponse', async () => { + // Arrange + const txn = jest.fn() + const quoteId = 'ddaa67b3-5bf8-45c1-bfcf-1e8781177c37' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.createQuoteResponse(txn, quoteId, completeQuoteResponse) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('createQuoteResponseIlpPacket', () => { + it('creates a new createQuoteResponseIlpPacket', async () => { + // Arrange + const txn = jest.fn() + const quoteResponseId = '12345' + const ilpPacket = 'mock_ilp_packet' + const mockList = mockKnexBuilder(mockKnex, ['12345'], ['transacting', 'insert']) + const expectedInsert = { + quoteResponseId, + value: ilpPacket + } + + // Act + const result = await database.createQuoteResponseIlpPacket(txn, quoteResponseId, ilpPacket) + + // Assert + expect(result).toStrictEqual(['12345']) + expect(mockList[0]).toHaveBeenCalledWith('quoteResponseIlpPacket') + expect(mockList[1]).toHaveBeenCalledWith(txn) + expect(mockList[2]).toHaveBeenCalledWith(expectedInsert) + }) + + it('handles an exception in creating the GeoCode', async () => { + // Arrange + const txn = jest.fn() + const quoteResponseId = '12345' + const ilpPacket = 'mock_ilp_packet' + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.createQuoteResponseIlpPacket(txn, quoteResponseId, ilpPacket) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('createGeoCode', () => { + it('creates a new GeoCode', async () => { + // Arrange + const txn = jest.fn() + const geoCode = { + quotePartyId: '12345', + latitude: '00.0000', + longitude: '00.0000' + } + const mockList = mockKnexBuilder(mockKnex, ['12345'], ['transacting', 'insert']) + + // Act + const result = await database.createGeoCode(txn, geoCode) + + // Assert + expect(result).toStrictEqual(['12345']) + expect(mockList[0]).toHaveBeenCalledWith('geoCode') + expect(mockList[1]).toHaveBeenCalledWith(txn) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles an exception in creating the GeoCode', async () => { + // Arrange + const txn = jest.fn() + const geoCode = { + quotePartyId: '12345', + latitude: '00.0000', + longitude: '00.0000' + } + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.createGeoCode(txn, geoCode) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('createQuoteError', () => { + it('creates a default quote error', async () => { + // Arrange + const txn = jest.fn() + const error = { + quoteId: '12345', + errorCode: '2201', + errorDescription: 'Test Error' + } + const mockList = mockKnexBuilder(mockKnex, ['12345'], ['transacting', 'insert']) + + // Act + const result = await database.createQuoteError(txn, error) + + // Assert + expect(result).toStrictEqual(['12345']) + expect(mockList[0]).toHaveBeenCalledWith('quoteError') + expect(mockList[1]).toHaveBeenCalledWith(txn) + expect(mockList[2]).toHaveBeenCalledTimes(1) + }) + + it('handles an exception in handling the quote error', async () => { + // Arrange + const txn = jest.fn() + const error = { + quoteId: '12345', + errorCode: '2201', + errorDescription: 'Test Error' + } + mockKnex.mockImplementationOnce(() => { throw new Error('Test Error') }) + + // Act + const action = async () => database.createQuoteError(txn, error) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + }) + + describe('getIsMigrationLocked', () => { + it('gets the migration lock status when the database is locked', async () => { + // Arrange + const mockList = mockKnexBuilder(mockKnex, { isLocked: true }, ['orderBy', 'first', 'select']) + + // Act + const result = await database.getIsMigrationLocked() + + // Assert + expect(result).toBe(true) + expect(mockList[0]).toHaveBeenCalledWith('migration_lock') + expect(mockList[1]).toHaveBeenCalledWith('index', 'desc') + expect(mockList[2]).toHaveBeenCalledTimes(1) + expect(mockList[3]).toHaveBeenCalledWith('is_locked AS isLocked') + }) + }) + }) +}) diff --git a/test/unit/handlers/bulkQuotes.test.js b/test/unit/handlers/bulkQuotes.test.js new file mode 100644 index 00000000..60c48270 --- /dev/null +++ b/test/unit/handlers/bulkQuotes.test.js @@ -0,0 +1,45 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the 'License') and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Initial contribution + -------------------- + The initial functionality and code base was donated by the Mowali project working in conjunction with MTN and Orange as service provides. + * Project: Mowali + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Crosslake + - Lewis Daly + -------------- + ******/ + +const BulkQuotesHandler = require('../../../src/handlers/bulkQuotes') + +describe('/bulkQuotes', () => { + describe('POST', () => { + it('throws NOT IMPLEMENTED error', async () => { + // Arrange + // Act + const action = () => BulkQuotesHandler.post() + + // Assert + expect(action).toThrowError('Bulk quotes not implemented') + }) + }) +}) diff --git a/test/unit/handlers/bulkQuotes/{id}.test.js b/test/unit/handlers/bulkQuotes/{id}.test.js new file mode 100644 index 00000000..85d4e3e4 --- /dev/null +++ b/test/unit/handlers/bulkQuotes/{id}.test.js @@ -0,0 +1,56 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the 'License') and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Initial contribution + -------------------- + The initial functionality and code base was donated by the Mowali project working in conjunction with MTN and Orange as service provides. + * Project: Mowali + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Crosslake + - Lewis Daly + -------------- + ******/ + +const BulkQuotesHandler = require('../../../../src/handlers/bulkQuotes/{id}') + +describe('/bulkQuotes/{id}', () => { + describe('GET', () => { + it('throws NOT IMPLEMENTED error', async () => { + // Arrange + // Act + const action = () => BulkQuotesHandler.get() + + // Assert + expect(action).toThrowError('Bulk quotes not implemented') + }) + }) + + describe('PUT', () => { + it('throws NOT IMPLEMENTED error', async () => { + // Arrange + // Act + const action = () => BulkQuotesHandler.put() + + // Assert + expect(action).toThrowError('Bulk quotes not implemented') + }) + }) +}) diff --git a/test/unit/handlers/bulkQuotes/{id}/error.test.js b/test/unit/handlers/bulkQuotes/{id}/error.test.js new file mode 100644 index 00000000..73037b8b --- /dev/null +++ b/test/unit/handlers/bulkQuotes/{id}/error.test.js @@ -0,0 +1,46 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the 'License') and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Initial contribution + -------------------- + The initial functionality and code base was donated by the Mowali project working in conjunction with MTN and Orange as service provides. + * Project: Mowali + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Crosslake + - Lewis Daly + -------------- + ******/ + +const BulkQuotesErrorHandler = require('../../../../../src/handlers/bulkQuotes/{id}/error') + +describe('/bulkQuotes/error/{id}', () => { + describe('PUT', () => { + it('throws NOT IMPLEMENTED error', async () => { + // Arrange + + // Act + const action = () => BulkQuotesErrorHandler.put() + + // Assert + expect(action).toThrowError('Bulk quotes not implemented') + }) + }) +}) diff --git a/test/unit/handlers/health.test.js b/test/unit/handlers/health.test.js new file mode 100644 index 00000000..f34ee573 --- /dev/null +++ b/test/unit/handlers/health.test.js @@ -0,0 +1,202 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the 'License') and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Initial contribution + -------------------- + The initial functionality and code base was donated by the Mowali project working in conjunction with MTN and Orange as service provides. + * Project: Mowali + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Crosslake + - Lewis Daly + -------------- + ******/ +jest.mock('../../../src/lib/config') + +const { responseCode, statusEnum } = require('@mojaloop/central-services-shared').HealthCheck.HealthCheckEnums +const HealthHandler = require('../../../src/handlers/health') +const { baseMockRequest } = require('../../util/helper') + +let Config = require('../../../src/lib/config') +let HealthCheck = require('@mojaloop/central-services-shared/src/healthCheck') + +describe('/health', () => { + describe('getSubServiceHealthDatastore', () => { + beforeAll(() => { + jest.mock('@mojaloop/central-services-shared/src/healthCheck') + }) + + it('is down when the database throws an error', async () => { + // Arrange + const mockDb = { + getIsMigrationLocked: jest.fn(() => { throw new Error('Test Error') }) + } + + // Act + const result = await HealthHandler.getSubServiceHealthDatastore(mockDb) + + // Assert + expect(result.status).toEqual(statusEnum.DOWN) + }) + + it('is down when the database is locked', async () => { + // Arrange + const mockDb = { + getIsMigrationLocked: jest.fn(() => true) + } + + // Act + const result = await HealthHandler.getSubServiceHealthDatastore(mockDb) + + // Assert + expect(result.status).toEqual(statusEnum.DOWN) + }) + + it('is up when the database is not locked', async () => { + // Arrange + const mockDb = { + getIsMigrationLocked: jest.fn(() => false) + } + + // Act + const result = await HealthHandler.getSubServiceHealthDatastore(mockDb) + + // Assert + expect(result.status).toEqual(statusEnum.OK) + }) + }) + + describe('GET success', () => { + let code + let handler + + beforeEach(() => { + // We need to reimport the modules here, since `new Config()` is called at import time + jest.resetModules() + jest.mock('@mojaloop/central-services-shared/src/healthCheck') + Config = require('../../../src/lib/config') + HealthCheck = require('@mojaloop/central-services-shared/src/healthCheck').HealthCheck + + handler = { + response: jest.fn(() => ({ + code + })) + } + HealthCheck.mockImplementationOnce(() => ({ + getHealth: () => ({ + status: statusEnum.OK + }) + })) + }) + + it('returns an UP response when simpleRoutingMode is on', async () => { + // Arrange + code = jest.fn() + Config.mockImplementation(() => ({ + simpleRoutingMode: true + })) + const HealthHandlerProxy = require('../../../src/handlers/health') + const expectedServiceHealthList = [] + + // Act + await HealthHandlerProxy.get({ ...baseMockRequest }, handler) + + // Assert + expect(code).toHaveBeenCalledWith(responseCode.success) + expect(HealthCheck.mock.calls.pop()[1]).toEqual(expectedServiceHealthList) + }) + + it('returns an UP response when simpleRoutingMode is off', async () => { + // Arrange + code = jest.fn() + Config.mockImplementation(() => ({ + simpleRoutingMode: false + })) + const HealthHandlerProxy = require('../../../src/handlers/health') + + // Act + await HealthHandlerProxy.get({ ...baseMockRequest }, handler) + + // Assert + expect(code).toHaveBeenCalledWith(responseCode.success) + // Ensure there was one item in the `serviceHealthList` + expect(HealthCheck.mock.calls.pop()[1].length).toEqual(1) + }) + }) + + describe('GET failure', () => { + let code + let handler + let HealthHandlerProxy + + beforeEach(() => { + // We need to reimport the modules here, since `new Config()` is called at import time + jest.resetModules() + Config = require('../../../src/lib/config') + Config.mockImplementation(() => ({ + simpleRoutingMode: false + })) + + handler = { + response: jest.fn(() => ({ + code + })) + } + }) + + it('returns an down response when getHealth returns DOWN', async () => { + // Arrange + HealthCheck = require('@mojaloop/central-services-shared/src/healthCheck').HealthCheck + HealthHandlerProxy = require('../../../src/handlers/health') + + code = jest.fn() + const mockRequest = { + ...baseMockRequest + } + mockRequest.server.app.database.getIsMigrationLocked = jest.fn().mockImplementation(() => { + throw new Error('Test Error') + }) + + // Act + await HealthHandlerProxy.get(mockRequest, handler) + + // Assert + expect(code).toHaveBeenCalledWith(responseCode.gatewayTimeout) + }) + + it('returns an down response when getHealth returns undefined', async () => { + // Arrange + jest.mock('@mojaloop/central-services-shared/src/healthCheck') + HealthCheck = require('@mojaloop/central-services-shared/src/healthCheck').HealthCheck + HealthHandlerProxy = require('../../../src/handlers/health') + + code = jest.fn() + HealthCheck.mockImplementationOnce(() => ({ + getHealth: () => undefined + })) + + // Act + await HealthHandlerProxy.get({ ...baseMockRequest }, handler) + + // Assert + expect(code).toHaveBeenCalledWith(responseCode.gatewayTimeout) + }) + }) +}) diff --git a/test/unit/handlers/quotes.test.js b/test/unit/handlers/quotes.test.js new file mode 100644 index 00000000..c1992792 --- /dev/null +++ b/test/unit/handlers/quotes.test.js @@ -0,0 +1,108 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the 'License') and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Initial contribution + -------------------- + The initial functionality and code base was donated by the Mowali project working in conjunction with MTN and Orange as service provides. + * Project: Mowali + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Crosslake + - Lewis Daly + -------------- + ******/ + +jest.mock('../../../src/model/quotes') + +const Enum = require('@mojaloop/central-services-shared').Enum + +const QuotesModel = require('../../../src/model/quotes') +const QuotesHandler = require('../../../src/handlers/quotes') +const { baseMockRequest } = require('../../util/helper') + +describe('/quotes', () => { + describe('POST', () => { + beforeEach(() => { + QuotesModel.mockClear() + }) + + it('creates a quote', async () => { + // Arrange + const code = jest.fn() + const handler = { + response: jest.fn(() => ({ + code + })) + } + const mockRequest = { + ...baseMockRequest, + payload: { + quoteId: '12345' + }, + span: { + audit: jest.fn(), + setTags: jest.fn() + } + } + + // Act + await QuotesHandler.post(mockRequest, handler) + + // Assert + expect(code).toHaveBeenCalledWith(Enum.Http.ReturnCodes.ACCEPTED.CODE) + const mockQuoteInstance = QuotesModel.mock.instances[0] + expect(mockQuoteInstance.handleQuoteRequest).toHaveBeenCalledTimes(1) + }) + + it('fails to create a quote', async () => { + // Arrange + const handleException = jest.fn() + QuotesModel.mockImplementationOnce(() => ({ + handleQuoteRequest: () => { + throw new Error('Create Quote Test Error') + }, + handleException + })) + const code = jest.fn() + const handler = { + response: jest.fn(() => ({ + code + })) + } + const mockRequest = { + ...baseMockRequest, + payload: { + quoteId: '12345' + }, + span: { + audit: jest.fn(), + setTags: jest.fn() + } + } + + // Act + await QuotesHandler.post(mockRequest, handler) + + // Assert + expect(code).toHaveBeenCalledWith(Enum.Http.ReturnCodes.ACCEPTED.CODE) + expect(handleException).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/test/unit/handlers/quotes/{id}.test.js b/test/unit/handlers/quotes/{id}.test.js new file mode 100644 index 00000000..cd3f4187 --- /dev/null +++ b/test/unit/handlers/quotes/{id}.test.js @@ -0,0 +1,141 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the 'License') and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Initial contribution + -------------------- + The initial functionality and code base was donated by the Mowali project working in conjunction with MTN and Orange as service provides. + * Project: Mowali + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Crosslake + - Lewis Daly + -------------- + ******/ + +jest.mock('@mojaloop/central-services-logger') +jest.mock('../../../../src/model/quotes') + +const QuotesHandler = require('../../../../src/handlers/quotes/{id}') +const QuotesModel = require('../../../../src/model/quotes') +const { baseMockRequest } = require('../../../util/helper') + +describe('/quotes/{id}', () => { + beforeEach(() => { + QuotesModel.mockClear() + }) + + describe('GET', () => { + it('gets a quote by id', async () => { + // Arrange + const code = jest.fn() + const handler = { + response: jest.fn(() => ({ + code + })) + } + + // Act + await QuotesHandler.get({ ...baseMockRequest }, handler) + + // Assert + expect(QuotesModel).toHaveBeenCalledTimes(1) + const mockQuoteInstance = QuotesModel.mock.instances[0] + expect(mockQuoteInstance.handleQuoteGet).toHaveBeenCalledTimes(1) + expect(code).toHaveBeenCalledWith(202) + }) + + it('handles an error with the model', async () => { + // Arrange + const handleException = jest.fn() + QuotesModel.mockImplementationOnce(() => { + return { + handleQuoteGet: () => { + throw new Error('Test error') + }, + handleException + } + }) + const code = jest.fn() + const handler = { + response: jest.fn(() => ({ + code + })) + } + + // Act + await QuotesHandler.get({ ...baseMockRequest }, handler) + + // Assert + expect(QuotesModel).toHaveBeenCalledTimes(1) + expect(handleException).toHaveBeenCalledTimes(1) + expect(code).toHaveBeenCalledWith(202) + }) + }) + + describe('PUT', () => { + it('puts a quote by id', async () => { + QuotesModel.mockClear() + + // Arrange + const code = jest.fn() + const handler = { + response: jest.fn(() => ({ + code + })) + } + + // Act + await QuotesHandler.put({ ...baseMockRequest }, handler) + + // Assert + expect(QuotesModel).toHaveBeenCalledTimes(1) + const mockQuoteInstance = QuotesModel.mock.instances[0] + expect(mockQuoteInstance.handleQuoteUpdate).toHaveBeenCalledTimes(1) + expect(code).toHaveBeenCalledWith(202) + }) + + it('handles an error with the model', async () => { + // Arrange + const handleException = jest.fn() + QuotesModel.mockImplementationOnce(() => { + return { + handleQuoteUpdate: () => { + throw new Error('Test error') + }, + handleException + } + }) + const code = jest.fn() + const handler = { + response: jest.fn(() => ({ + code + })) + } + + // Act + await QuotesHandler.put({ ...baseMockRequest }, handler) + + // Assert + expect(QuotesModel).toHaveBeenCalledTimes(1) + expect(handleException).toHaveBeenCalledTimes(1) + expect(code).toHaveBeenCalledWith(202) + }) + }) +}) diff --git a/test/unit/handlers/quotes/{id}/error.test.js b/test/unit/handlers/quotes/{id}/error.test.js new file mode 100644 index 00000000..7cee4758 --- /dev/null +++ b/test/unit/handlers/quotes/{id}/error.test.js @@ -0,0 +1,111 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the 'License') and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Initial contribution + -------------------- + The initial functionality and code base was donated by the Mowali project working in conjunction with MTN and Orange as service provides. + * Project: Mowali + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Crosslake + - Lewis Daly + -------------- + ******/ + +const Enum = require('@mojaloop/central-services-shared').Enum + +jest.mock('@mojaloop/central-services-logger') +jest.mock('../../../../../src/model/quotes') + +const QuotesErrorHandler = require('../../../../../src/handlers/quotes/{id}/error') +const QuotesModel = require('../../../../../src/model/quotes') +const { baseMockRequest } = require('../../../../util/helper') + +describe('/quotes/{id}', () => { + beforeEach(() => { + QuotesModel.mockClear() + }) + + describe('PUT', () => { + it('handles an error', async () => { + // Arrange + const request = { + ...baseMockRequest, + payload: { + errorInformation: { + errorCode: '2201', + errorDescription: 'Test Error' + } + } + } + const code = jest.fn() + const handler = { + response: jest.fn(() => ({ + code + })) + } + + // Act + await QuotesErrorHandler.put(request, handler) + + // Assert + expect(QuotesModel).toHaveBeenCalledTimes(1) + const mockQuoteInstance = QuotesModel.mock.instances[0] + expect(mockQuoteInstance.handleQuoteError).toHaveBeenCalledTimes(1) + expect(code).toHaveBeenCalledWith(Enum.Http.ReturnCodes.OK.CODE) + }) + + it('handles an error with the model', async () => { + // Arrange + const request = { + ...baseMockRequest, + payload: { + errorInformation: { + errorCode: '2201', + errorDescription: 'Test Error' + } + } + } + const handleException = jest.fn() + QuotesModel.mockImplementationOnce(() => { + return { + handleQuoteError: () => { + throw new Error('Test error') + }, + handleException + } + }) + const code = jest.fn() + const handler = { + response: jest.fn(() => ({ + code + })) + } + + // Act + await QuotesErrorHandler.put(request, handler) + + // Assert + expect(QuotesModel).toHaveBeenCalledTimes(1) + expect(handleException).toHaveBeenCalledTimes(1) + expect(code).toHaveBeenCalledWith(Enum.Http.ReturnCodes.OK.CODE) + }) + }) +}) diff --git a/test/unit/lib/config.test.js b/test/unit/lib/config.test.js new file mode 100644 index 00000000..7a855390 --- /dev/null +++ b/test/unit/lib/config.test.js @@ -0,0 +1,83 @@ + +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + + * Crosslake + - Lewis Daly + + -------------- + ******/ +'use strict' + +const mockDefaultFile = { + HOSTNAME: 'http://quoting-service', + LISTEN_ADDRESS: '0.0.0.0', + PORT: 3002, + AMOUNT: { + PRECISION: 18, + SCALE: 4 + }, + DATABASE: { + DIALECT: 'mysql', + HOST: 'localhost', + PORT: 3306, + USER: 'central_ledger', + PASSWORD: 'password', + SCHEMA: 'central_ledger', + POOL_MIN_SIZE: 10, + POOL_MAX_SIZE: 10, + ACQUIRE_TIMEOUT_MILLIS: 30000, + CREATE_TIMEOUT_MILLIS: 30000, + DESTROY_TIMEOUT_MILLIS: 5000, + IDLE_TIMEOUT_MILLIS: 30000, + REAP_INTERVAL_MILLIS: 1000, + CREATE_RETRY_INTERVAL_MILLIS: 200, + DEBUG: true + }, + SWITCH_ENDPOINT: 'http://localhost:3001', + ERROR_HANDLING: { + includeCauseExtension: false, + truncateExtensions: true + }, + SIMPLE_ROUTING_MODE: true +} + +describe('Config', () => { + beforeEach(() => { + jest.resetModules() + }) + + it('sets the default amounts', () => { + // Arrange + jest.mock('../../../config/default.json', () => ({ + ...mockDefaultFile, + AMOUNT: {} + }), { virtual: true }) + + const Config = require('../../../src/lib/config') + + // Act + const result = new Config() + + // Assert + expect(result.amount.precision).toBe(18) + expect(result.amount.scale).toBe(4) + expect(result.database.debug).toBe(true) + }) +}) diff --git a/test/unit/lib/http.test.js b/test/unit/lib/http.test.js new file mode 100644 index 00000000..4d611961 --- /dev/null +++ b/test/unit/lib/http.test.js @@ -0,0 +1,80 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + + * Crosslake + - Lewis Daly + + -------------- + ******/ +'use strict' + +jest.mock('axios') + +const axios = require('axios') +const { httpRequest } = require('../../../src/lib/http') + +describe('httpRequest', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('performs a successful http request', async () => { + // Arrange + axios.request.mockReturnValueOnce({ + status: 200, + data: Promise.resolve({}) + }) + const options = {} + + // Act + await httpRequest(options, 'payeefsp') + + // Assert + expect(axios.request).toHaveBeenCalledTimes(1) + }) + + it('handles a http exception', async () => { + // Arrange + axios.request.mockImplementationOnce(() => { throw new Error('Network error') }) + const options = {} + + // Act + const action = async () => httpRequest(options, 'payeefsp') + + // Assert + await expect(action()).rejects.toThrow('Network error') + expect(axios.request).toHaveBeenCalledTimes(1) + }) + + it('handles a bad response', async () => { + // Arrange + axios.request.mockReturnValueOnce({ + status: 400, + data: Promise.resolve({}) + }) + const options = {} + + // Act + const action = async () => httpRequest(options, 'payeefsp') + + // Assert + await expect(action()).rejects.toThrow('Non-success response in HTTP request') + expect(axios.request).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/unit/lib/util.test.js b/test/unit/lib/util.test.js new file mode 100644 index 00000000..e565c577 --- /dev/null +++ b/test/unit/lib/util.test.js @@ -0,0 +1,133 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + + * Crosslake + - Lewis Daly + + -------------- + ******/ +'use strict' + +const Enum = require('@mojaloop/central-services-shared').Enum + +const { failActionHandler, getStackOrInspect, getSpanTags } = require('../../../src/lib/util') + +describe('util', () => { + describe('failActionHandler', () => { + it('throws the reformatted error', async () => { + // Arrange + const input = new Error('Generic error') + + // Act + const action = async () => failActionHandler(null, null, input) + + // Assert + await expect(action()).rejects.toThrowError('Generic error') + }) + }) + + describe('getSpanTags', () => { + it('does not get the span tags for payeeFsp and payerFsp if they do not exist', () => { + // Arrange + const expected = { + transactionType: 'quote', + transactionAction: 'prepare', + transactionId: '12345', + quoteId: 'ABCDE', + source: 'fsp1', + destination: 'switch' + } + const mockRequest = { + params: { + id: 'ABCDE' + }, + payload: { + transactionId: '12345' + }, + headers: { + 'fspiop-source': 'fsp1', + 'fspiop-destination': 'switch' + } + } + + // Act + const result = getSpanTags(mockRequest, Enum.Events.Event.Type.QUOTE, Enum.Events.Event.Action.PREPARE) + + // Assert + expect(result).toStrictEqual(expected) + }) + + it('gets the span tags for payeeFsp and payerFsp if they do not exist', () => { + // Arrange + const expected = { + transactionType: 'quote', + transactionAction: 'prepare', + transactionId: '12345', + quoteId: 'ABCDE', + source: 'fsp1', + destination: 'switch', + payeeFsp: 'fsp1', + payerFsp: 'fsp2' + } + const mockRequest = { + params: { + id: 'ABCDE' + }, + payload: { + transactionId: '12345', + payee: { + partyIdInfo: { + fspId: 'fsp1' + } + }, + payer: { + partyIdInfo: { + fspId: 'fsp2' + } + } + }, + headers: { + 'fspiop-source': 'fsp1', + 'fspiop-destination': 'switch' + } + } + + // Act + const result = getSpanTags(mockRequest, Enum.Events.Event.Type.QUOTE, Enum.Events.Event.Action.PREPARE) + + // Assert + expect(result).toStrictEqual(expected) + }) + }) + + describe('getStackOrInspect', () => { + it('handles an error without a stack', () => { + // Arrange + const input = new Error('This is a normal error') + delete input.stack + const expected = '[Error: This is a normal error]' + + // Act + const output = getStackOrInspect(input) + + // Assert + expect(output).toBe(expected) + }) + }) +}) diff --git a/test/unit/model/quotes.test.js b/test/unit/model/quotes.test.js index 1bcbf98b..6969fad4 100644 --- a/test/unit/model/quotes.test.js +++ b/test/unit/model/quotes.test.js @@ -46,18 +46,23 @@ jest.mock('../../../src/model/rules') jest.mock('../../../src/lib/config', () => { return jest.fn().mockImplementation(() => mockConfig) }) +jest.mock('../../../src/lib/http') const axios = require('axios') const clone = require('@mojaloop/central-services-shared').Util.clone +const Enum = require('@mojaloop/central-services-shared').Enum +const LibUtil = require('@mojaloop/central-services-shared').Util const ErrorHandler = require('@mojaloop/central-services-error-handling') const EventSdk = require('@mojaloop/event-sdk') +const Logger = require('@mojaloop/central-services-logger') const Db = require('../../../src/data/database') const Config = jest.requireActual('../../../src/lib/config') const QuotesModel = require('../../../src/model/quotes') const rules = require('../../../config/rules') const RulesEngine = require('../../../src/model/rules') +const Http = require('../../../src/lib/http') describe('QuotesModel', () => { let mockData @@ -175,6 +180,9 @@ describe('QuotesModel', () => { }] } }, + quoteResponse: { + quoteId: 'test123' + }, rules: [ { conditions: { @@ -262,6 +270,7 @@ describe('QuotesModel', () => { quotesModel.db.getSubScenario.mockImplementation(() => mockData.subScenario) quotesModel.db.getAmountType.mockImplementation(() => mockData.amountTypeId) quotesModel.db.createQuote.mockImplementation(() => mockData.quoteRequest.quoteId) + quotesModel.db.createQuoteError.mockImplementation(() => mockData.quoteRequest.quoteId) quotesModel.db.createPayerQuoteParty.mockImplementation(() => mockData.quoteRequest.payer.partyIdInfo.fspId) quotesModel.db.createPayeeQuoteParty.mockImplementation(() => mockData.quoteRequest.payee.partyIdInfo.fspId) quotesModel.db.createGeoCode.mockImplementation(() => mockData.geoCode) @@ -1104,6 +1113,7 @@ describe('QuotesModel', () => { expect.assertions(3) mockConfig.simpleRoutingMode = false quotesModel.db.getQuotePartyEndpoint.mockReturnValueOnce(mockData.endpoints.invalid) + Http.httpRequest.mockImplementationOnce(() => { throw ErrorHandler.CreateFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.DESTINATION_COMMUNICATION_ERROR) }) await expect(quotesModel.forwardQuoteRequest(mockData.headers, mockData.quoteRequest.quoteId, mockData.quoteRequest)) .rejects @@ -1116,6 +1126,7 @@ describe('QuotesModel', () => { expect.assertions(3) mockConfig.simpleRoutingMode = false quotesModel.db.getQuotePartyEndpoint.mockReturnValueOnce(mockData.endpoints.invalidResponse) + Http.httpRequest.mockImplementationOnce(() => { throw ErrorHandler.CreateFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.DESTINATION_COMMUNICATION_ERROR) }) await expect(quotesModel.forwardQuoteRequest(mockData.headers, mockData.quoteRequest.quoteId, mockData.quoteRequest)) .rejects @@ -1485,6 +1496,7 @@ describe('QuotesModel', () => { mockConfig.simpleRoutingMode = false quotesModel.db.getQuotePartyEndpoint.mockReturnValueOnce(mockData.endpoints.invalid) + Http.httpRequest.mockImplementationOnce(() => { throw ErrorHandler.CreateFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.DESTINATION_COMMUNICATION_ERROR) }) await expect(quotesModel.forwardQuoteUpdate(mockData.headers, mockData.quoteId, mockData.quoteUpdate)) .rejects @@ -1498,6 +1510,7 @@ describe('QuotesModel', () => { mockConfig.simpleRoutingMode = false quotesModel.db.getQuotePartyEndpoint.mockReturnValueOnce(mockData.endpoints.invalidResponse) + Http.httpRequest.mockImplementationOnce(() => { throw ErrorHandler.CreateFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.DESTINATION_COMMUNICATION_ERROR) }) await expect(quotesModel.forwardQuoteUpdate(mockData.headers, mockData.quoteId, mockData.quoteUpdate)) .rejects @@ -1599,4 +1612,635 @@ describe('QuotesModel', () => { .toHaveProperty('apiErrorCode.code', ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR.code) }) }) + + describe('handleQuoteError', () => { + beforeEach(() => { + // restore the current method in test to its original implementation + quotesModel.handleQuoteError.mockRestore() + }) + + it('handles the quote error', async () => { + // Arrange + expect.assertions(2) + mockConfig.simpleRoutingMode = true + const error = { + errorCode: 2001, + errorDescription: 'Test Error' + } + + // Act + const result = await quotesModel.handleQuoteError(mockData.headers, mockData.quoteId, error, mockSpan) + + // Assert + // For `handleQuoteError` response is undefined + expect(result).toBe(undefined) + expect(quotesModel.sendErrorCallback).toHaveBeenCalledTimes(1) + }) + + it('handles the quote error with simpleRoutingMode: false', async () => { + // Arrange + expect.assertions(4) + mockConfig.simpleRoutingMode = false + const error = { + errorCode: 2001, + errorDescription: 'Test Error' + } + + // Act + const result = await quotesModel.handleQuoteError(mockData.headers, mockData.quoteId, error, mockSpan) + + // Assert + expect(result).toBe(mockData.quoteId) + expect(quotesModel.sendErrorCallback).toHaveBeenCalledTimes(1) + expect(quotesModel.db.newTransaction.mock.calls.length).toBe(1) + expect(quotesModel.db.createQuoteError.mock.calls.length).toBe(1) + }) + + it('handles bad error input', async () => { + // Arrange + expect.assertions(1) + mockConfig.simpleRoutingMode = false + const error = { + errorDescription: 'Test Error' + } + + // Act + const action = async () => quotesModel.handleQuoteError(mockData.headers, mockData.quoteId, error, mockSpan) + + // Assert + await expect(action()).rejects.toThrowError('Validation failed due to error code being invalid - undefined.') + }) + }) + + describe('handleQuoteGet', () => { + beforeEach(() => { + // restore the current method in test to its original implementation + quotesModel.handleQuoteGet.mockRestore() + }) + + it('handles the quote get with a child span', async () => { + // Arrange + expect.assertions(3) + + // Act + await quotesModel.handleQuoteGet(mockData.headers, mockData.quoteId, mockSpan) + + // Assert + expect(mockChildSpan.audit.mock.calls.length).toBe(1) + expect(mockChildSpan.finish.mock.calls.length).toBe(1) + expect(quotesModel.forwardQuoteGet.mock.calls.length).toBe(1) + }) + + it('handles an exception on `span.getChild`', async () => { + // Arrange + expect.assertions(1) + mockSpan.getChild = jest.fn(() => { throw new Error('Test Error') }) + + // Act + const action = async () => quotesModel.handleQuoteGet(mockData.headers, mockData.quoteId, mockSpan) + + // Assert + await expect(action()).rejects.toThrowError('Test Error') + }) + + it('handles an exception on `childSpan.audit`', async () => { + // Arrange + expect.assertions(2) + mockChildSpan.audit = jest.fn(() => { throw new Error('Test Error') }) + + // Act + await quotesModel.handleQuoteGet(mockData.headers, mockData.quoteId, mockSpan) + + // Assert + expect(mockChildSpan.finish.mock.calls.length).toBe(1) + expect(quotesModel.handleException.mock.calls.length).toBe(1) + }) + }) + + describe('forwardQuoteGet', () => { + beforeEach(() => { + // restore the current method in test to its original implementation + quotesModel.forwardQuoteGet.mockRestore() + }) + + it('fails to forward if the database has no endpoint for the dfsp', async () => { + // Arrange + expect.assertions(1) + quotesModel.db.getParticipantEndpoint.mockImplementation(() => null) + + // Act + const action = async () => quotesModel.forwardQuoteGet(mockData.headers, mockData.quoteId, mockSpan) + + // Assert + await expect(action()).rejects.toThrowError('No FSPIOP_CALLBACK_URL_QUOTES found for quote GET test123') + }) + + it('forwards the request to the payee dfsp without a span', async () => { + // Arrange + // expect.assertions(2) + quotesModel.db.getParticipantEndpoint.mockImplementation(() => 'http://localhost:3333') + const expectedOptions = { + headers: {}, + method: 'GET', + url: 'http://localhost:3333/quotes/test123' + } + + // Act + await quotesModel.forwardQuoteGet(mockData.headers, mockData.quoteId) + + // Assert + expect(Http.httpRequest).toBeCalledTimes(1) + expect(Http.httpRequest).toBeCalledWith(expectedOptions, mockData.headers[Enum.Http.Headers.FSPIOP.SOURCE]) + }) + + it('forwards the request to the payee dfsp', async () => { + // Arrange + expect.assertions(4) + quotesModel.db.getParticipantEndpoint.mockImplementation(() => 'http://localhost:3333') + mockSpan.injectContextToHttpRequest = jest.fn().mockImplementation(() => ({ + headers: { + spanHeaders: '12345' + } + })) + mockSpan.audit = jest.fn() + const expectedOptions = { + headers: { + spanHeaders: '12345' + } + } + + // Act + await quotesModel.forwardQuoteGet(mockData.headers, mockData.quoteId, mockSpan) + + // Assert + expect(mockSpan.injectContextToHttpRequest).toBeCalledTimes(1) + expect(mockSpan.audit).toBeCalledTimes(1) + expect(Http.httpRequest).toBeCalledTimes(1) + expect(Http.httpRequest).toBeCalledWith(expectedOptions, mockData.headers[Enum.Http.Headers.FSPIOP.SOURCE]) + }) + + it('handles a http error', async () => { + // Arrange + expect.assertions(1) + quotesModel.db.getParticipantEndpoint.mockImplementation(() => 'http://localhost:3333') + Http.httpRequest.mockImplementationOnce(() => { throw new Error('Test HTTP Error') }) + + // Act + const action = async () => quotesModel.forwardQuoteGet(mockData.headers, mockData.quoteId) + + // Assert + await expect(action()).rejects.toThrowError('Test HTTP Error') + }) + }) + + describe('handleException', () => { + beforeEach(() => { + // restore the current method in test to its original implementation + quotesModel.handleException.mockRestore() + }) + + it('handles the error and finishes the child span', async () => { + // Arrange + expect.assertions(3) + const error = new Error('Test Error') + const expectedError = ErrorHandler.ReformatFSPIOPError(error) + quotesModel.sendErrorCallback.mockImplementationOnce(() => true) + + // Act + const result = await quotesModel.handleException('payeefsp', mockData.quoteId, error, mockData.headers, mockSpan) + + // Assert + expect(quotesModel.sendErrorCallback).toHaveBeenCalledWith('payeefsp', expectedError, mockData.quoteId, mockData.headers, mockChildSpan) + expect(result).toBe(true) + expect(mockChildSpan.finish).toHaveBeenCalledTimes(1) + }) + + it('handles an error in sendErrorCallback', async () => { + // Arrange + expect.assertions(3) + const error = new Error('Test Error') + const expectedError = ErrorHandler.ReformatFSPIOPError(error) + quotesModel.sendErrorCallback.mockImplementationOnce(() => { throw new Error('Error sending callback.') }) + + // Act + await quotesModel.handleException('payeefsp', mockData.quoteId, error, mockData.headers, mockSpan) + + // Assert + expect(quotesModel.sendErrorCallback).toHaveBeenCalledWith('payeefsp', expectedError, mockData.quoteId, mockData.headers, mockChildSpan) + expect(quotesModel.writeLog).toHaveBeenCalledTimes(1) + expect(mockChildSpan.finish).toHaveBeenCalledTimes(1) + }) + }) + + describe('sendErrorCallback', () => { + beforeEach(() => { + // restore the current method in test to its original implementation + quotesModel.sendErrorCallback.mockRestore() + }) + + it('sends the error callback without a span', async () => { + // Arrange + expect.assertions(1) + quotesModel.db.getParticipantEndpoint.mockReturnValueOnce(mockData.endpoints.payeefsp) + quotesModel.generateRequestHeaders.mockReturnValueOnce({}) + const error = new Error('Test Error') + const fspiopError = ErrorHandler.ReformatFSPIOPError(error) + const expectedOptions = { + method: Enum.Http.RestMethods.PUT, + url: 'http://localhost:8444/payeefsp/quotes/test123/error', + data: JSON.stringify(fspiopError.toApiErrorObject(mockConfig.errorHandling), LibUtil.getCircularReplacer()), + headers: {} + } + + // Act + await quotesModel.sendErrorCallback('payeefsp', fspiopError, mockData.quoteId, mockData.headers) + + // Assert + expect(axios.request).toBeCalledWith(expectedOptions) + }) + + it('sends the error callback and handles the span', async () => { + // Arrange + expect.assertions(3) + quotesModel.db.getParticipantEndpoint.mockReturnValueOnce(mockData.endpoints.payeefsp) + quotesModel.generateRequestHeaders.mockReturnValueOnce({}) + const error = new Error('Test Error') + const fspiopError = ErrorHandler.ReformatFSPIOPError(error) + mockSpan.injectContextToHttpRequest = jest.fn().mockImplementation(() => ({ + headers: { + spanHeaders: '12345' + }, + method: Enum.Http.RestMethods.PUT, + url: 'http://localhost:8444/payeefsp/quotes/test123/error', + data: {} + })) + mockSpan.audit = jest.fn() + const expectedOptions = { + method: Enum.Http.RestMethods.PUT, + url: 'http://localhost:8444/payeefsp/quotes/test123/error', + data: {}, + headers: { + spanHeaders: '12345' + } + } + + // Act + await quotesModel.sendErrorCallback('payeefsp', fspiopError, mockData.quoteId, mockData.headers, mockSpan) + + // Assert + expect(mockSpan.injectContextToHttpRequest).toBeCalledTimes(1) + expect(mockSpan.audit).toBeCalledTimes(1) + expect(axios.request).toBeCalledWith(expectedOptions) + }) + + it('handles when the endpoint could not be found', async () => { + // Arrange + expect.assertions(2) + quotesModel.db.getParticipantEndpoint.mockReturnValueOnce(undefined) + quotesModel.generateRequestHeaders.mockReturnValueOnce({}) + const error = new Error('Test Error') + const fspiopError = ErrorHandler.ReformatFSPIOPError(error) + + // Act + const action = async () => quotesModel.sendErrorCallback('payeefsp', fspiopError, mockData.quoteId, mockData.headers, mockSpan) + + // Assert + await expect(action()).rejects.toThrow('No FSPIOP_CALLBACK_URL_QUOTES found for payeefsp unable to make error callback') + expect(axios.request).not.toHaveBeenCalled() + }) + + it('handles a http exception', async () => { + // Arrange + expect.assertions(2) + quotesModel.db.getParticipantEndpoint.mockReturnValueOnce(mockData.endpoints.payeefsp) + quotesModel.generateRequestHeaders.mockReturnValueOnce({}) + const error = new Error('Test Error') + const fspiopError = ErrorHandler.ReformatFSPIOPError(error) + axios.request.mockImplementationOnce(() => { throw new Error('HTTP test error') }) + + // Act + const action = async () => quotesModel.sendErrorCallback('payeefsp', fspiopError, mockData.quoteId, mockData.headers) + + // Assert + await expect(action()).rejects.toThrow('network error in sendErrorCallback: HTTP test error') + expect(axios.request).toHaveBeenCalledTimes(1) + }) + + it('handles a http bad status code', async () => { + // Arrange + expect.assertions(2) + quotesModel.db.getParticipantEndpoint.mockReturnValueOnce(mockData.endpoints.payeefsp) + quotesModel.generateRequestHeaders.mockReturnValueOnce({}) + const error = new Error('Test Error') + const fspiopError = ErrorHandler.ReformatFSPIOPError(error) + axios.request.mockReturnValueOnce({ + status: Enum.Http.ReturnCodes.BADREQUEST.CODE + }) + + // Act + const action = async () => quotesModel.sendErrorCallback('payeefsp', fspiopError, mockData.quoteId, mockData.headers) + + // Assert + await expect(action()).rejects.toThrow('Got non-success response sending error callback') + expect(axios.request).toHaveBeenCalledTimes(1) + }) + }) + + describe('checkDuplicateQuoteRequest', () => { + beforeEach(() => { + // restore the current method in test to its original implementation + quotesModel.checkDuplicateQuoteRequest.mockRestore() + quotesModel.calculateRequestHash.mockRestore() + }) + + it('handles a non-duplicate request', async () => { + // Arrange + expect.assertions(2) + quotesModel.db.getQuoteDuplicateCheck.mockReturnValueOnce(undefined) + const expected = { + isResend: false, + isDuplicateId: false + } + + // Act + const result = await quotesModel.checkDuplicateQuoteRequest(mockData.quoteRequest) + + // Assert + expect(result).toEqual(expected) + expect(quotesModel.db.getQuoteDuplicateCheck).toHaveBeenCalledTimes(1) + }) + + it('handles a duplicate id', async () => { + // Arrange + expect.assertions(2) + quotesModel.db.getQuoteDuplicateCheck.mockReturnValueOnce({ + hash: 'this_hash_will_not_match' + }) + const expected = { + isResend: false, + isDuplicateId: true + } + + // Act + const result = await quotesModel.checkDuplicateQuoteRequest(mockData.quoteRequest) + + // Assert + expect(result).toEqual(expected) + expect(quotesModel.db.getQuoteDuplicateCheck).toHaveBeenCalledTimes(1) + }) + + it('handles a matching hash', async () => { + // Arrange + expect.assertions(2) + quotesModel.db.getQuoteDuplicateCheck.mockReturnValueOnce({ + hash: quotesModel.calculateRequestHash(mockData.quoteRequest) + }) + const expected = { + isResend: true, + isDuplicateId: true + } + + // Act + const result = await quotesModel.checkDuplicateQuoteRequest(mockData.quoteRequest) + + // Assert + expect(result).toEqual(expected) + expect(quotesModel.db.getQuoteDuplicateCheck).toHaveBeenCalledTimes(1) + }) + + it('handles an exception when checking the duplicate', async () => { + // Arrange + expect.assertions(2) + quotesModel.db.getQuoteDuplicateCheck.mockImplementationOnce(() => { throw new Error('Duplicate check error') }) + + // Act + const action = async () => quotesModel.checkDuplicateQuoteRequest(mockData.quoteRequest) + + // Assert + await expect(action()).rejects.toThrow('Duplicate check error') + expect(quotesModel.db.getQuoteDuplicateCheck).toHaveBeenCalledTimes(1) + }) + }) + + describe('checkDuplicateQuoteResponse', () => { + beforeEach(() => { + // restore the current method in test to its original implementation + quotesModel.checkDuplicateQuoteResponse.mockRestore() + quotesModel.calculateRequestHash.mockRestore() + }) + + it('handles a non-duplicate request', async () => { + // Arrange + // expect.assertions(2) + quotesModel.db.getQuoteResponseDuplicateCheck.mockReturnValueOnce(undefined) + const expected = { + isResend: false, + isDuplicateId: false + } + + // Act + const result = await quotesModel.checkDuplicateQuoteResponse(mockData.quoteId, mockData.quoteResponse) + + // Assert + expect(result).toEqual(expected) + expect(quotesModel.db.getQuoteResponseDuplicateCheck).toHaveBeenCalledTimes(1) + }) + + it('handles a duplicate id', async () => { + // Arrange + expect.assertions(2) + quotesModel.db.getQuoteResponseDuplicateCheck.mockReturnValueOnce({ + hash: 'this_hash_will_not_match' + }) + const expected = { + isResend: false, + isDuplicateId: true + } + + // Act + const result = await quotesModel.checkDuplicateQuoteResponse(mockData.quoteId, mockData.quoteResponse) + + // Assert + expect(result).toEqual(expected) + expect(quotesModel.db.getQuoteResponseDuplicateCheck).toHaveBeenCalledTimes(1) + }) + + it('handles a matching hash', async () => { + // Arrange + expect.assertions(2) + quotesModel.db.getQuoteResponseDuplicateCheck.mockReturnValueOnce({ + hash: quotesModel.calculateRequestHash(mockData.quoteResponse) + }) + const expected = { + isResend: true, + isDuplicateId: true + } + + // Act + const result = await quotesModel.checkDuplicateQuoteResponse(mockData.quoteId, mockData.quoteResponse) + + // Assert + expect(result).toEqual(expected) + expect(quotesModel.db.getQuoteResponseDuplicateCheck).toHaveBeenCalledTimes(1) + }) + + it('handles an exception when checking the duplicate', async () => { + // Arrange + // expect.assertions(2) + quotesModel.db.getQuoteResponseDuplicateCheck.mockImplementationOnce(() => { throw new Error('Duplicate check error') }) + + // Act + const action = async () => quotesModel.checkDuplicateQuoteResponse(mockData.quoteId, mockData.quoteResponse) + + // Assert + await expect(action()).rejects.toThrow('Duplicate check error') + expect(quotesModel.db.getQuoteResponseDuplicateCheck).toHaveBeenCalledTimes(1) + }) + }) + + describe('removeEmptyKeys', () => { + beforeEach(() => { + // restore the current method in test to its original implementation + quotesModel.removeEmptyKeys.mockRestore() + }) + + it('removes nothing if there are no empty keys', () => { + // Arrange + const input = { + a: 1, + b: 2, + c: 3 + } + const expected = { + a: 1, + b: 2, + c: 3 + } + + // Act + const result = quotesModel.removeEmptyKeys(input) + + // Assert + expect(result).toStrictEqual(expected) + }) + + it('removes a key and if it is undefined', () => { + // Arrange + const input = { + a: 1, + b: 2, + c: undefined + } + const expected = { + a: 1, + b: 2 + } + + // Act + const result = quotesModel.removeEmptyKeys(input) + + // Assert + expect(result).toStrictEqual(expected) + }) + + it('removes an empty key', () => { + // Arrange + const input = { + a: 1, + b: 2, + c: { + + } + } + const expected = { + a: 1, + b: 2 + } + + // Act + const result = quotesModel.removeEmptyKeys(input) + + // Assert + expect(result).toStrictEqual(expected) + }) + + it('removes a nested empty key', () => { + // Arrange + const input = { + a: 1, + b: 2, + c: { + d: { + + } + } + } + const expected = { + a: 1, + b: 2, + c: {} + } + + // Act + const result = quotesModel.removeEmptyKeys(input) + + // Assert + expect(result).toStrictEqual(expected) + }) + }) + + describe('generateRequestHeaders', () => { + beforeEach(() => { + // restore the current method in test to its original implementation + quotesModel.generateRequestHeaders.mockRestore() + quotesModel.removeEmptyKeys.mockRestore() + }) + + it('generates the default request headers', () => { + // Arrange + const expected = { + 'Content-Type': 'application/vnd.interoperability.quotes+json;version=1.0', + 'FSPIOP-Destination': 'dfsp2', + 'FSPIOP-Source': 'dfsp1' + } + + // Act + const result = quotesModel.generateRequestHeaders(mockData.headers, true) + + // Assert + expect(result).toStrictEqual(expected) + }) + + it('generates default request headers, including the Accept', () => { + // Arrange + const expected = { + Accept: 'application/vnd.interoperability.quotes+json;version=1', + 'Content-Type': 'application/vnd.interoperability.quotes+json;version=1.0', + 'FSPIOP-Destination': 'dfsp2', + 'FSPIOP-Source': 'dfsp1' + } + + // Act + const result = quotesModel.generateRequestHeaders(mockData.headers, false) + + // Assert + expect(result).toStrictEqual(expected) + }) + }) + + describe('writeLog', () => { + beforeEach(() => { + // restore the current method in test to its original implementation + quotesModel.writeLog.mockRestore() + }) + + it('writes to the log', () => { + // Arrange + // Act + quotesModel.writeLog('test message') + + // Assert + expect(Logger.info).toBeCalledTimes(1) + }) + }) }) diff --git a/test/unit/model/rules.test.js b/test/unit/model/rules.test.js index 543a4c10..a03d2029 100644 --- a/test/unit/model/rules.test.js +++ b/test/unit/model/rules.test.js @@ -120,6 +120,31 @@ describe('RulesEngine', () => { expect(events).toEqual([event]) }) + it('returns the expected events when using jsonpath and deepEqual operator', async () => { + const conditions = { + any: [{ + fact: 'payload', + path: '$.payer.partyIdInfo.fspId', + operator: 'deepEqual', + value: 'payerfsp' + }] + } + const event = { + type: RulesEngine.events.INVALID_QUOTE_REQUEST + } + const testFacts = { + payload: { + payer: { + partyIdInfo: { + fspId: 'payerfsp' + } + } + } + } + const { events } = await RulesEngine.run([{ conditions, event }], testFacts) + expect(events).toEqual([event]) + }) + it('returns the expected events when using jsonpath fact-fact comparison', async () => { const conditions = { any: [{ diff --git a/test/unit/server.test.js b/test/unit/server.test.js new file mode 100644 index 00000000..3533a26e --- /dev/null +++ b/test/unit/server.test.js @@ -0,0 +1,127 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the 'License') and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Initial contribution + -------------------- + The initial functionality and code base was donated by the Mowali project working in conjunction with MTN and Orange as service provides. + * Project: Mowali + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Crosslake + - Lewis Daly + -------------- + ******/ + +let Hapi +let Logger +let Database + +describe('Server', () => { + beforeEach(() => { + jest.resetModules() + + jest.mock('@hapi/hapi') + jest.mock('@mojaloop/central-services-logger') + jest.mock('../../src/data/cachedDatabase') + + Hapi = require('@hapi/hapi') + Logger = require('@mojaloop/central-services-logger') + Database = require('../../src/data/cachedDatabase') + }) + + it('runs the server', async () => { + // Arrange + Database.mockImplementationOnce(() => ({ + connect: jest.fn().mockResolvedValueOnce() + })) + const mockRegister = jest.fn() + const mockStart = jest.fn() + const mockSetHost = jest.fn() + const mockLog = jest.fn() + Hapi.Server.mockImplementationOnce(() => ({ + app: { + database: null + }, + register: mockRegister, + start: mockStart, + plugins: { + openapi: { + setHost: mockSetHost + } + }, + log: mockLog, + info: { + host: 'localhost', + port: 3333, + uri: 'http://localhost:3333' + } + })) + + // Act + const server = require('../../src/server') + await server() + + // Assert + expect(mockRegister).toHaveBeenCalledTimes(1) + expect(mockStart).toHaveBeenCalledTimes(1) + expect(mockSetHost).toHaveBeenCalledTimes(1) + expect(mockLog).toHaveBeenCalledTimes(1) + }) + + it('handles exception when starting', async () => { + // Arrange + Database.mockImplementationOnce(() => ({ + connect: jest.fn().mockResolvedValueOnce() + })) + const mockRegister = jest.fn().mockImplementationOnce(() => { throw new Error('Test Error') }) + const mockStart = jest.fn() + const mockSetHost = jest.fn() + const mockLog = jest.fn() + Hapi.Server.mockImplementationOnce(() => ({ + app: { + database: null + }, + register: mockRegister, + start: mockStart, + plugins: { + openapi: { + setHost: mockSetHost + } + }, + log: mockLog, + info: { + host: 'localhost', + port: 3333, + uri: 'http://localhost:3333' + } + })) + + // Act + const server = require('../../src/server') + await server() + + // Assert + expect(mockRegister).toHaveBeenCalledTimes(1) + expect(mockStart).not.toHaveBeenCalled() + expect(mockSetHost).not.toHaveBeenCalled() + expect(mockLog).not.toHaveBeenCalled() + expect(Logger.error).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/util/helper.js b/test/util/helper.js index 90ca90ab..532ea7af 100644 --- a/test/util/helper.js +++ b/test/util/helper.js @@ -23,6 +23,34 @@ ******/ 'use strict' +/** + * @object baseMockRequest + * + * @description A basic mock request object for passing into handlers + * + */ +const baseMockRequest = { + headers: { + 'fspiop-source': 'payerfsp' + }, + info: { + id: '12345' + }, + params: { + id: 'quoteId12345' + }, + server: { + app: { + database: jest.fn() + }, + log: jest.fn() + }, + span: { + setTags: jest.fn(), + audit: jest.fn() + } +} + /** * @function defaultHeaders * @@ -49,5 +77,6 @@ function defaultHeaders () { } module.exports = { + baseMockRequest, defaultHeaders }