├── .github └── workflows │ └── build.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── example.js ├── index.d.ts ├── index.js ├── index.test-d.ts ├── package.json ├── renovate.json └── test.js /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: ${{ matrix.os }} 9 | 10 | strategy: 11 | matrix: 12 | node-version: [18.x, 20.x, 22.x] 13 | os: [ubuntu-latest, windows-latest, macOS-latest] 14 | 15 | steps: 16 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 17 | with: 18 | persist-credentials: false 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | - name: Install 26 | run: | 27 | npm install 28 | - name: Test 29 | run: | 30 | npm test 31 | 32 | coverage: 33 | name: Coverage 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 38 | 39 | - name: Use Node.js 22.x 40 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 41 | with: 42 | node-version: 22.x 43 | 44 | - name: Install 45 | run: | 46 | npm install 47 | - name: Coverage check 48 | run: | 49 | npm run coverage:check 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | .DS_Store 107 | package-lock.json 108 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 303 See Other 2 | 3 | Location: https://www.elastic.co/community/codeofconduct 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 Elastic and contributors 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Elasticsearch Node.js client mock utility 4 | 5 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](http://standardjs.com/) ![build](https://github.com/elastic/elasticsearch-js-mock/workflows/build/badge.svg) 6 | 7 | When testing your application you don't always need to have an Elasticsearch instance up and running, 8 | but you might still need to use the client for fetching some data. 9 | If you are facing this situation, this library is what you need. 10 | 11 | Use `v1.0.0` for `@elastic/elasticsearch` ≤ v7 compatibility and `v2.0.0` for `@elastic/elasticsearch` ≥ v8 compatibility. 12 | 13 | ### Features 14 | 15 | - Simple and intuitive API 16 | - Mocks only the http layer, leaving the rest of the client working as usual 17 | - Maximum flexibility thanks to "strict" or "loose" mocks 18 | 19 | ## Install 20 | ``` 21 | npm install @elastic/elasticsearch-mock --save-dev 22 | ``` 23 | 24 | ## Usage 25 | 26 | ```js 27 | const { Client } = require('@elastic/elasticsearch') 28 | const Mock = require('@elastic/elasticsearch-mock') 29 | 30 | const mock = new Mock() 31 | const client = new Client({ 32 | node: 'http://localhost:9200', 33 | Connection: mock.getConnection() 34 | }) 35 | 36 | mock.add({ 37 | method: 'GET', 38 | path: '/_cat/health' 39 | }, () => { 40 | return { status: 'ok' } 41 | }) 42 | 43 | client.cat.health() 44 | .then(console.log) 45 | .catch(console.log) 46 | ``` 47 | 48 | ## API 49 | 50 | #### `Constructor` 51 | 52 | Before start using the library you need to create a new instance: 53 | ```js 54 | const Mock = require('@elastic/elasticsearch-mock') 55 | const mock = new Mock() 56 | ``` 57 | 58 | #### `add` 59 | 60 | Adds a new mock for a given pattern and assigns it to a resolver function. 61 | 62 | ```js 63 | // every GET request to the `/_cat/health` path 64 | // will return `{ status: 'ok' }` 65 | mock.add({ 66 | method: 'GET', 67 | path: '/_cat/health' 68 | }, () => { 69 | return { status: 'ok' } 70 | }) 71 | ``` 72 | 73 | You can also specify multiple methods and/or paths at the same time: 74 | ```js 75 | // This mock will catch every search request against any index 76 | mock.add({ 77 | method: ['GET', 'POST'], 78 | path: ['/_search', '/:index/_search'] 79 | }, () => { 80 | return { status: 'ok' } 81 | }) 82 | ``` 83 | 84 | #### `get` 85 | 86 | Returns the matching resolver function for the given pattern, it returns `null` if there is not a matching pattern. 87 | 88 | ```js 89 | const fn = mock.get({ 90 | method: 'GET', 91 | path: '/_cat/health' 92 | }) 93 | ``` 94 | 95 | #### `clear` 96 | 97 | Clears/removes mocks for specific route(s). 98 | 99 | ```js 100 | mock.clear({ 101 | method: ['GET'], 102 | path: ['/_search', '/:index/_search'] 103 | }) 104 | ``` 105 | 106 | #### `clearAll` 107 | 108 | Clears all mocks. 109 | 110 | ```js 111 | mock.clearAll() 112 | ``` 113 | 114 | #### `getConnection` 115 | 116 | Returns a custom `Connection` class that you **must** pass to the Elasticsearch client instance. 117 | 118 | ```js 119 | const { Client } = require('@elastic/elasticsearch') 120 | const Mock = require('@elastic/elasticsearch-mock') 121 | 122 | const mock = new Mock() 123 | const client = new Client({ 124 | node: 'http://localhost:9200', 125 | Connection: mock.getConnection() 126 | }) 127 | ``` 128 | 129 | ### Mock patterns 130 | 131 | A pattern is an object that describes an http query to Elasticsearch, and it looks like this: 132 | ```ts 133 | interface MockPattern { 134 | method: string 135 | path: string 136 | querystring?: Record 137 | body?: Record 138 | } 139 | ``` 140 | 141 | The more field you specify, the more the mock will be strict, for example: 142 | ```js 143 | mock.add({ 144 | method: 'GET', 145 | path: '/_cat/health' 146 | querystring: { pretty: 'true' } 147 | }, () => { 148 | return { status: 'ok' } 149 | }) 150 | 151 | client.cat.health() 152 | .then(console.log) 153 | .catch(console.log) // 404 error 154 | 155 | client.cat.health({ pretty: true }) 156 | .then(console.log) // { status: 'ok' } 157 | .catch(console.log) 158 | ``` 159 | 160 | You can craft custom responses for different queries: 161 | 162 | ```js 163 | mock.add({ 164 | method: 'POST', 165 | path: '/indexName/_search' 166 | body: { query: { match_all: {} } } 167 | }, () => { 168 | return { 169 | hits: { 170 | total: { value: 1, relation: 'eq' }, 171 | hits: [{ _source: { baz: 'faz' } }] 172 | } 173 | } 174 | }) 175 | 176 | mock.add({ 177 | method: 'POST', 178 | path: '/indexName/_search', 179 | body: { query: { match: { foo: 'bar' } } } 180 | }, () => { 181 | return { 182 | hits: { 183 | total: { value: 0, relation: 'eq' }, 184 | hits: [] 185 | } 186 | } 187 | }) 188 | ``` 189 | 190 | You can also specify dynamic urls: 191 | ```js 192 | mock.add({ 193 | method: 'GET', 194 | path: '/:index/_count' 195 | }, () => { 196 | return { count: 42 } 197 | }) 198 | 199 | client.count({ index: 'foo' }) 200 | .then(console.log) // => { count: 42 } 201 | .catch(console.log) 202 | 203 | client.count({ index: 'bar' }) 204 | .then(console.log) // => { count: 42 } 205 | .catch(console.log) 206 | ``` 207 | 208 | Wildcards are supported as well. 209 | ```js 210 | mock.add({ 211 | method: 'HEAD', 212 | path: '*' 213 | }, () => { 214 | return '' 215 | }) 216 | 217 | client.indices.exists({ index: 'foo' }) 218 | .then(console.log) // => true 219 | .catch(console.log) 220 | 221 | client.ping() 222 | .then(console.log) // => true 223 | .catch(console.log) 224 | ``` 225 | 226 | ### Dynamic responses 227 | 228 | The resolver function takes a single parameter which represent the API call that has been made by the client. 229 | You can use it to craft dynamic responses. 230 | 231 | ```js 232 | mock.add({ 233 | method: 'POST', 234 | path: '/indexName/_search', 235 | }, params => { 236 | return { query: params.body.query } 237 | }) 238 | ``` 239 | 240 | ### Errors 241 | 242 | This utility uses the same error classes of the Elasticsearch client, if you want to return an error for a specific API call, you should use the `ResponseError` class: 243 | 244 | ```js 245 | const { errors } = require('@elastic/elasticsearch') 246 | const Mock = require('@elastic/elasticsearch-mock') 247 | 248 | const mock = new Mock() 249 | mock.add({ 250 | method: 'GET', 251 | path: '/_cat/health' 252 | }, () => { 253 | return new errors.ResponseError({ 254 | body: { errors: {}, status: 500 }, 255 | statusCode: 500 256 | }) 257 | }) 258 | ``` 259 | 260 | ## License 261 | 262 | This software is licensed under the [Apache 2 license](./LICENSE). 263 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to Elasticsearch B.V. under one or more contributor 3 | * license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright 5 | * ownership. Elasticsearch B.V. licenses this file to you under 6 | * the Apache License, Version 2.0 (the "License"); you may 7 | * not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | 'use strict' 21 | 22 | const { Client } = require('@elastic/elasticsearch') 23 | const Mock = require('./') 24 | 25 | const mock = new Mock() 26 | const client = new Client({ 27 | node: 'http://localhost:9200', 28 | Connection: mock.getConnection() 29 | }) 30 | 31 | mock.add({ 32 | method: 'GET', 33 | path: '/_cat/health' 34 | }, () => { 35 | return { status: 'ok' } 36 | }) 37 | 38 | mock.add({ 39 | method: 'HEAD', 40 | path: '*' 41 | }, () => { 42 | return { ping: 'ok' } 43 | }) 44 | 45 | mock.add({ 46 | method: 'POST', 47 | path: '/test/_search' 48 | }, () => { 49 | return { 50 | hits: { 51 | total: { 52 | value: 1, 53 | relation: 'eq' 54 | }, 55 | hits: [ 56 | { _source: { foo: 'bar' } } 57 | ] 58 | } 59 | } 60 | }) 61 | 62 | mock.add({ 63 | method: 'POST', 64 | path: '/test/_search', 65 | body: { 66 | query: { 67 | match: { foo: 'bar' } 68 | } 69 | } 70 | }, () => { 71 | return { 72 | hits: { 73 | total: { 74 | value: 0, 75 | relation: 'eq' 76 | }, 77 | hits: [] 78 | } 79 | } 80 | }) 81 | 82 | client.cat.health(console.log) 83 | 84 | client.ping(console.log) 85 | 86 | client.search({ 87 | index: 'test', 88 | body: { 89 | query: { match_all: {} } 90 | } 91 | }, console.log) 92 | 93 | client.search({ 94 | index: 'test', 95 | body: { 96 | query: { 97 | match: { foo: 'bar' } 98 | } 99 | } 100 | }, console.log) 101 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to Elasticsearch B.V. under one or more contributor 3 | * license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright 5 | * ownership. Elasticsearch B.V. licenses this file to you under 6 | * the Apache License, Version 2.0 (the "License"); you may 7 | * not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | import { BaseConnection } from '@elastic/elasticsearch' 21 | 22 | declare class ClientMock { 23 | constructor() 24 | add(pattern: MockPattern, resolver: ResolverFn): ClientMock 25 | get(pattern: MockPattern): ResolverFn | null 26 | clear(pattern: Pick): ClientMock 27 | clearAll(): ClientMock 28 | getConnection(): typeof BaseConnection 29 | } 30 | 31 | export declare type ResolverFn = (params: MockPattern) => Record | string 32 | 33 | export interface MockPattern { 34 | method: string | string[] 35 | path: string | string[] 36 | querystring?: Record 37 | body?: Record | Record[] 38 | } 39 | 40 | export default ClientMock 41 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to Elasticsearch B.V. under one or more contributor 3 | * license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright 5 | * ownership. Elasticsearch B.V. licenses this file to you under 6 | * the Apache License, Version 2.0 (the "License"); you may 7 | * not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | 'use strict' 21 | 22 | const { gunzip, createGunzip } = require('zlib') 23 | const querystring = require('querystring') 24 | const { BaseConnection, errors } = require('@elastic/elasticsearch') 25 | const Router = require('find-my-way') 26 | const equal = require('fast-deep-equal') 27 | const kRouter = Symbol('elasticsearch-mock-router') 28 | 29 | /* istanbul ignore next */ 30 | const noop = () => {} 31 | const { 32 | ConfigurationError, 33 | ConnectionError, 34 | ResponseError, 35 | RequestAbortedError, 36 | ElasticsearchClientError 37 | } = errors 38 | 39 | class Mocker { 40 | constructor () { 41 | this[kRouter] = Router({ ignoreTrailingSlash: true }) 42 | } 43 | 44 | add (pattern, fn) { 45 | for (const key of ['method', 'path']) { 46 | if (Array.isArray(pattern[key])) { 47 | for (const value of pattern[key]) { 48 | this.add({ ...pattern, [key]: value }, fn) 49 | } 50 | return this 51 | } 52 | } 53 | 54 | if (typeof pattern.method !== 'string') throw new ConfigurationError('The method is not defined') 55 | if (typeof pattern.path !== 'string') throw new ConfigurationError('The path is not defined') 56 | if (typeof fn !== 'function') throw new ConfigurationError('The resolver function is not defined') 57 | 58 | // workaround since find-my-way no longer decodes URI escaped chars 59 | // https://github.com/delvedor/find-my-way/pull/282 60 | // https://github.com/delvedor/find-my-way/pull/286 61 | if (pattern.path.indexOf('%') > -1) pattern.path = decodeURIComponent(pattern.path) 62 | 63 | const handler = this[kRouter].find(pattern.method, pattern.path) 64 | if (handler) { 65 | handler.store.push({ ...pattern, fn }) 66 | // order the patterns in descending order, so we will match 67 | // more precise patterns first and the loose ones 68 | handler.store.sort((a, b) => Object.keys(b).length - Object.keys(a).length) 69 | } else { 70 | this[kRouter].on(pattern.method, pattern.path, noop, [{ ...pattern, fn }]) 71 | } 72 | 73 | return this 74 | } 75 | 76 | get (params) { 77 | if (typeof params.method !== 'string') throw new ConfigurationError('The method is not defined') 78 | if (typeof params.path !== 'string') throw new ConfigurationError('The path is not defined') 79 | 80 | // workaround since find-my-way no longer decodes URI escaped chars 81 | // https://github.com/delvedor/find-my-way/pull/282 82 | // https://github.com/delvedor/find-my-way/pull/286 83 | if (params.path.indexOf('%') > -1) params.path = decodeURIComponent(params.path) 84 | 85 | const handler = this[kRouter].find(params.method, params.path) 86 | if (!handler) return null 87 | for (const { body, querystring, fn } of handler.store) { 88 | if (body !== undefined && querystring !== undefined) { 89 | if (equal(params.body, body) && equal(params.querystring, querystring)) { 90 | return fn 91 | } 92 | } else if (body !== undefined && querystring === undefined) { 93 | if (equal(params.body, body)) { 94 | return fn 95 | } 96 | } else if (body === undefined && querystring !== undefined) { 97 | if (equal(params.querystring, querystring)) { 98 | return fn 99 | } 100 | } else { 101 | return fn 102 | } 103 | } 104 | return null 105 | } 106 | 107 | clear (pattern) { 108 | for (const key of ['method', 'path']) { 109 | if (Array.isArray(pattern[key])) { 110 | for (const value of pattern[key]) { 111 | this.clear({ ...pattern, [key]: value }) 112 | } 113 | return this 114 | } 115 | } 116 | 117 | if (typeof pattern.method !== 'string') throw new ConfigurationError('The method is not defined') 118 | if (typeof pattern.path !== 'string') throw new ConfigurationError('The path is not defined') 119 | 120 | this[kRouter].off(pattern.method, pattern.path) 121 | return this 122 | } 123 | 124 | clearAll () { 125 | this[kRouter].reset() 126 | return this 127 | } 128 | 129 | getConnection () { 130 | return buildConnectionClass(this) 131 | } 132 | } 133 | 134 | function buildConnectionClass (mocker) { 135 | class MockConnection extends BaseConnection { 136 | request (params, options) { 137 | const abortListener = () => { 138 | aborted = true 139 | } 140 | let aborted = false 141 | if (options.signal != null) { 142 | options.signal.addEventListener('abort', abortListener, { once: true }) 143 | } 144 | 145 | return new Promise((resolve, reject) => { 146 | normalizeParams(params, prepareResponse) 147 | function prepareResponse (error, params) { 148 | /* istanbul ignore next */ 149 | if (options.signal != null) { 150 | if ('removeEventListener' in options.signal) { 151 | options.signal.removeEventListener('abort', abortListener) 152 | } else { 153 | options.signal.removeListener('abort', abortListener) 154 | } 155 | } 156 | /* istanbul ignore next */ 157 | if (aborted) { 158 | return reject(new RequestAbortedError()) 159 | } 160 | /* istanbul ignore next */ 161 | if (error) { 162 | return reject(new ConnectionError(error.message)) 163 | } 164 | 165 | const response = {} 166 | let payload = '' 167 | let statusCode = 200 168 | 169 | const resolver = mocker.get(params) 170 | 171 | if (resolver === null) { 172 | payload = { error: 'Mock not found', params } 173 | statusCode = 404 174 | } else { 175 | payload = resolver(params) 176 | if (payload instanceof ResponseError) { 177 | statusCode = payload.statusCode 178 | payload = payload.body 179 | } else if (payload instanceof ElasticsearchClientError) { 180 | return reject(payload) 181 | } 182 | } 183 | 184 | response.body = typeof payload === 'string' ? payload : JSON.stringify(payload) 185 | response.statusCode = statusCode 186 | response.headers = { 187 | 'content-type': typeof payload === 'string' 188 | ? 'text/plain;utf=8' 189 | : 'application/json;utf=8', 190 | date: new Date().toISOString(), 191 | connection: 'keep-alive', 192 | 'x-elastic-product': 'Elasticsearch', 193 | 'content-length': Buffer.byteLength( 194 | typeof payload === 'string' ? payload : JSON.stringify(payload) 195 | ) 196 | } 197 | 198 | resolve(response) 199 | } 200 | }) 201 | } 202 | } 203 | 204 | return MockConnection 205 | } 206 | 207 | function normalizeParams (params, callback) { 208 | const normalized = { 209 | method: params.method, 210 | path: params.path, 211 | body: null, 212 | // querystring.parse returns a null object prototype 213 | // which break the fast-deep-equal algorithm 214 | querystring: { ...querystring.parse(params.querystring) } 215 | } 216 | 217 | const compression = (params.headers['Content-Encoding'] || params.headers['content-encoding']) === 'gzip' 218 | const type = params.headers['Content-Type'] || params.headers['content-type'] || '' 219 | 220 | if (isStream(params.body)) { 221 | normalized.body = '' 222 | const stream = compression ? params.body.pipe(createGunzip()) : params.body 223 | /* istanbul ignore next */ 224 | stream.on('error', err => callback(err, null)) 225 | stream.on('data', chunk => { normalized.body += chunk }) 226 | stream.on('end', () => { 227 | normalized.body = type.includes('x-ndjson') 228 | ? normalized.body.split(/\n|\n\r/).filter(Boolean).map(l => JSON.parse(l)) 229 | : JSON.parse(normalized.body) 230 | callback(null, normalized) 231 | }) 232 | } else if (params.body) { 233 | if (compression) { 234 | gunzip(params.body, (err, buffer) => { 235 | /* istanbul ignore next */ 236 | if (err) { 237 | return callback(err, null) 238 | } 239 | buffer = buffer.toString() 240 | normalized.body = type.includes('x-ndjson') 241 | ? buffer.split(/\n|\n\r/).filter(Boolean).map(l => JSON.parse(l)) 242 | : JSON.parse(buffer) 243 | callback(null, normalized) 244 | }) 245 | } else { 246 | normalized.body = type.includes('x-ndjson') 247 | ? params.body.split(/\n|\n\r/).filter(Boolean).map(l => JSON.parse(l)) 248 | : JSON.parse(params.body) 249 | setImmediate(callback, null, normalized) 250 | } 251 | } else { 252 | setImmediate(callback, null, normalized) 253 | } 254 | } 255 | 256 | function isStream (obj) { 257 | return obj != null && typeof obj.pipe === 'function' 258 | } 259 | 260 | module.exports = Mocker 261 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to Elasticsearch B.V. under one or more contributor 3 | * license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright 5 | * ownership. Elasticsearch B.V. licenses this file to you under 6 | * the Apache License, Version 2.0 (the "License"); you may 7 | * not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | import { expectType, expectError } from 'tsd' 21 | import { Client } from '@elastic/elasticsearch' 22 | import Mock, { MockPattern } from './' 23 | 24 | const mock = new Mock() 25 | const client = new Client({ 26 | node: 'http://localhost:9200', 27 | Connection: mock.getConnection() 28 | }) 29 | 30 | mock.add({ 31 | method: 'GET', 32 | path: '/' 33 | }, params => { 34 | expectType(params) 35 | return { status: 'ok' } 36 | }) 37 | 38 | mock.add({ 39 | method: ['GET', 'POST'], 40 | path: ['/_search', '/:index/_search'] 41 | }, params => { 42 | expectType(params) 43 | return { status: 'ok' } 44 | }) 45 | 46 | mock.add({ 47 | method: 'GET', 48 | path: '/', 49 | querystring: { pretty: 'true' } 50 | }, params => { 51 | expectType(params) 52 | return { status: 'ok' } 53 | }) 54 | 55 | mock.add({ 56 | method: 'POST', 57 | path: '/', 58 | querystring: { pretty: 'true' }, 59 | body: { foo: 'bar' } 60 | }, params => { 61 | expectType(params) 62 | return { status: 'ok' } 63 | }) 64 | 65 | mock.add({ 66 | method: 'POST', 67 | path: '/_bulk', 68 | body: [{ foo: 'bar' }] 69 | }, params => { 70 | expectType(params) 71 | return { status: 'ok' } 72 | }) 73 | 74 | mock.add({ 75 | method: 'GET', 76 | path: '/' 77 | }, params => { 78 | expectType(params) 79 | return 'ok' 80 | }) 81 | 82 | // querystring should only have string values 83 | expectError( 84 | mock.add({ 85 | method: 'GET', 86 | path: '/', 87 | querystring: { pretty: true } 88 | }, () => { 89 | return { status: 'ok' } 90 | }) 91 | ) 92 | 93 | // missing resolver function 94 | expectError( 95 | mock.add({ 96 | method: 'GET', 97 | path: '/' 98 | }) 99 | ) 100 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elastic/elasticsearch-mock", 3 | "version": "2.1.0", 4 | "description": "Mock utility for the Elasticsearch's Node.js client", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "test": "standard && ava -v && tsd", 9 | "coverage:report": "nyc ava && nyc report --reporter=lcov && echo \"\n==> open coverage/lcov-report/index.html\"", 10 | "coverage:check": "nyc ava && nyc check-coverage --branches=100 --lines=100 --functions=100 --statements=100" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/elastic/elasticsearch-js-mock.git" 15 | }, 16 | "keywords": [ 17 | "elasticsearch", 18 | "elastic", 19 | "kibana", 20 | "mapping", 21 | "REST", 22 | "search", 23 | "client", 24 | "index", 25 | "mock", 26 | "test", 27 | "stub", 28 | "sinon" 29 | ], 30 | "author": "Tomas Della Vedova", 31 | "license": "Apache-2.0", 32 | "bugs": { 33 | "url": "https://github.com/elastic/elasticsearch-js-mock/issues" 34 | }, 35 | "homepage": "https://github.com/elastic/elasticsearch-js-mock#readme", 36 | "devDependencies": { 37 | "@elastic/elasticsearch": "9.0.2", 38 | "ava": "6.3.0", 39 | "node-abort-controller": "3.1.1", 40 | "nyc": "17.1.0", 41 | "standard": "17.1.2", 42 | "tsd": "0.32.0" 43 | }, 44 | "dependencies": { 45 | "fast-deep-equal": "^3.1.3", 46 | "find-my-way": "^9.3.0", 47 | "into-stream": "^6.0.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>elastic/renovate-config" 5 | ], 6 | "schedule": [ 7 | "* * * * 0" 8 | ], 9 | "packageRules": [ 10 | { 11 | "matchDepTypes": [ 12 | "devDependencies" 13 | ], 14 | "automerge": true 15 | }, 16 | { 17 | "matchDepTypes": [ 18 | "dependencies" 19 | ], 20 | "rangeStrategy": "bump", 21 | "automerge": true 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Licensed to Elasticsearch B.V. under one or more contributor 3 | * license agreements. See the NOTICE file distributed with 4 | * this work for additional information regarding copyright 5 | * ownership. Elasticsearch B.V. licenses this file to you under 6 | * the Apache License, Version 2.0 (the "License"); you may 7 | * not use this file except in compliance with the License. 8 | * You may obtain a copy of the License at 9 | * 10 | * http://www.apache.org/licenses/LICENSE-2.0 11 | * 12 | * Unless required by applicable law or agreed to in writing, 13 | * software distributed under the License is distributed on an 14 | * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | * KIND, either express or implied. See the License for the 16 | * specific language governing permissions and limitations 17 | * under the License. 18 | */ 19 | 20 | 'use strict' 21 | 22 | const test = require('ava') 23 | const { Client, errors } = require('@elastic/elasticsearch') 24 | const { AbortController } = require('node-abort-controller') 25 | const intoStream = require('into-stream') 26 | const Mock = require('./') 27 | 28 | test('Should mock an API', async t => { 29 | const mock = new Mock() 30 | const client = new Client({ 31 | node: 'http://localhost:9200', 32 | Connection: mock.getConnection() 33 | }) 34 | 35 | mock.add({ 36 | method: 'GET', 37 | path: '/_cat/indices' 38 | }, () => { 39 | return { status: 'ok' } 40 | }) 41 | 42 | const response = await client.cat.indices({}, { meta: true }) 43 | t.deepEqual(response.body, { status: 'ok' }) 44 | t.is(response.statusCode, 200) 45 | }) 46 | 47 | test('Mock granularity', async t => { 48 | const mock = new Mock() 49 | const client = new Client({ 50 | node: 'http://localhost:9200', 51 | Connection: mock.getConnection() 52 | }) 53 | 54 | mock.add({ 55 | method: 'POST', 56 | path: '/test/_search' 57 | }, () => { 58 | return { 59 | hits: { 60 | total: { value: 1, relation: 'eq' }, 61 | hits: [{ _source: { baz: 'faz' } }] 62 | } 63 | } 64 | }) 65 | 66 | mock.add({ 67 | method: 'POST', 68 | path: '/test/_search', 69 | body: { query: { match: { foo: 'bar' } } } 70 | }, () => { 71 | return { 72 | hits: { 73 | total: { value: 0, relation: 'eq' }, 74 | hits: [] 75 | } 76 | } 77 | }) 78 | 79 | let response = await client.search({ 80 | index: 'test', 81 | query: { match_all: {} } 82 | }) 83 | 84 | t.deepEqual(response, { 85 | hits: { 86 | total: { value: 1, relation: 'eq' }, 87 | hits: [{ _source: { baz: 'faz' } }] 88 | } 89 | }) 90 | 91 | response = await client.search({ 92 | index: 'test', 93 | query: { match: { foo: 'bar' } } 94 | }) 95 | 96 | t.deepEqual(response, { 97 | hits: { 98 | total: { value: 0, relation: 'eq' }, 99 | hits: [] 100 | } 101 | }) 102 | }) 103 | 104 | test('Dynamic paths', async t => { 105 | const mock = new Mock() 106 | const client = new Client({ 107 | node: 'http://localhost:9200', 108 | Connection: mock.getConnection() 109 | }) 110 | 111 | mock.add({ 112 | method: 'GET', 113 | path: '/:index/_count' 114 | }, () => { 115 | return { count: 42 } 116 | }) 117 | 118 | let response = await client.count({ index: 'foo' }) 119 | t.deepEqual(response, { count: 42 }) 120 | 121 | response = await client.count({ index: 'bar' }) 122 | t.deepEqual(response, { count: 42 }) 123 | }) 124 | 125 | test('If an API has not been mocked, it should return a 404', async t => { 126 | const mock = new Mock() 127 | const client = new Client({ 128 | node: 'http://localhost:9200', 129 | Connection: mock.getConnection() 130 | }) 131 | 132 | try { 133 | await client.cat.indices() 134 | t.fail('Should throw') 135 | } catch (err) { 136 | t.true(err instanceof errors.ResponseError) 137 | t.is(err.body.error, 'Mock not found') 138 | t.is(err.statusCode, 404) 139 | } 140 | }) 141 | 142 | test('Should handle compressed request', async t => { 143 | const mock = new Mock() 144 | const client = new Client({ 145 | node: 'http://localhost:9200', 146 | compression: true, 147 | Connection: mock.getConnection() 148 | }) 149 | 150 | mock.add({ 151 | method: 'POST', 152 | path: '/test/_search' 153 | }, () => { 154 | return { 155 | hits: { 156 | total: { value: 1, relation: 'eq' }, 157 | hits: [{ _source: { baz: 'faz' } }] 158 | } 159 | } 160 | }) 161 | 162 | mock.add({ 163 | method: 'POST', 164 | path: '/test/_search', 165 | body: { query: { match: { foo: 'bar' } } } 166 | }, () => { 167 | return { 168 | hits: { 169 | total: { value: 0, relation: 'eq' }, 170 | hits: [] 171 | } 172 | } 173 | }) 174 | 175 | const response = await client.search({ 176 | index: 'test', 177 | query: { match_all: {} } 178 | }) 179 | 180 | t.deepEqual(response, { 181 | hits: { 182 | total: { value: 1, relation: 'eq' }, 183 | hits: [{ _source: { baz: 'faz' } }] 184 | } 185 | }) 186 | }) 187 | 188 | test('Should handle streaming body with transport.request', async t => { 189 | const mock = new Mock() 190 | const client = new Client({ 191 | node: 'http://localhost:9200', 192 | Connection: mock.getConnection() 193 | }) 194 | 195 | mock.add({ 196 | method: 'POST', 197 | path: '/test/_search' 198 | }, () => { 199 | return { 200 | hits: { 201 | total: { value: 1, relation: 'eq' }, 202 | hits: [{ _source: { baz: 'faz' } }] 203 | } 204 | } 205 | }) 206 | 207 | mock.add({ 208 | method: 'POST', 209 | path: '/test/_search', 210 | body: { query: { match: { foo: 'bar' } } } 211 | }, () => { 212 | return { 213 | hits: { 214 | total: { value: 0, relation: 'eq' }, 215 | hits: [] 216 | } 217 | } 218 | }) 219 | 220 | const response = await client.transport.request({ 221 | method: 'POST', 222 | path: '/test/_search', 223 | body: intoStream(JSON.stringify({ query: { match: { foo: 'bar' } } })) 224 | }) 225 | 226 | t.deepEqual(response, { 227 | hits: { 228 | total: { value: 0, relation: 'eq' }, 229 | hits: [] 230 | } 231 | }) 232 | }) 233 | 234 | test('Should handle compressed streaming body with transport.request', async t => { 235 | const mock = new Mock() 236 | const client = new Client({ 237 | node: 'http://localhost:9200', 238 | compression: true, 239 | Connection: mock.getConnection() 240 | }) 241 | 242 | mock.add({ 243 | method: 'POST', 244 | path: '/test/_search' 245 | }, () => { 246 | return { 247 | hits: { 248 | total: { value: 1, relation: 'eq' }, 249 | hits: [{ _source: { baz: 'faz' } }] 250 | } 251 | } 252 | }) 253 | 254 | mock.add({ 255 | method: 'POST', 256 | path: '/test/_search', 257 | body: { query: { match: { foo: 'bar' } } } 258 | }, () => { 259 | return { 260 | hits: { 261 | total: { value: 0, relation: 'eq' }, 262 | hits: [] 263 | } 264 | } 265 | }) 266 | 267 | const response = await client.transport.request({ 268 | method: 'POST', 269 | path: '/test/_search', 270 | body: intoStream(JSON.stringify({ query: { match: { foo: 'bar' } } })) 271 | }) 272 | 273 | t.deepEqual(response, { 274 | hits: { 275 | total: { value: 0, relation: 'eq' }, 276 | hits: [] 277 | } 278 | }) 279 | }) 280 | 281 | test('Abort a request', async t => { 282 | const mock = new Mock() 283 | const client = new Client({ 284 | node: 'http://localhost:9200', 285 | Connection: mock.getConnection() 286 | }) 287 | 288 | const ac = new AbortController() 289 | const p = client.cat.indices({}, { signal: ac.signal }) 290 | ac.abort() 291 | 292 | try { 293 | await p 294 | t.fail('Should throw') 295 | } catch (err) { 296 | t.true(err instanceof errors.RequestAbortedError) 297 | } 298 | }) 299 | 300 | test('Return a response error', async t => { 301 | const mock = new Mock() 302 | const client = new Client({ 303 | node: 'http://localhost:9200', 304 | Connection: mock.getConnection() 305 | }) 306 | 307 | mock.add({ 308 | method: 'GET', 309 | path: '/_cat/indices' 310 | }, () => { 311 | return new errors.ResponseError({ 312 | body: { errors: {}, status: 500 }, 313 | statusCode: 500 314 | }) 315 | }) 316 | 317 | try { 318 | await client.cat.indices() 319 | t.fail('Should throw') 320 | } catch (err) { 321 | t.deepEqual(err.body, { errors: {}, status: 500 }) 322 | t.is(err.statusCode, 500) 323 | } 324 | }) 325 | 326 | test('Return a timeout error', async t => { 327 | const mock = new Mock() 328 | const client = new Client({ 329 | node: 'http://localhost:9200', 330 | Connection: mock.getConnection() 331 | }) 332 | 333 | mock.add({ 334 | method: 'GET', 335 | path: '/_cat/indices' 336 | }, () => { 337 | return new errors.TimeoutError() 338 | }) 339 | 340 | try { 341 | await client.cat.indices() 342 | t.fail('Should throw') 343 | } catch (err) { 344 | t.true(err instanceof errors.TimeoutError) 345 | } 346 | }) 347 | 348 | test('The mock function should receive the request parameters', async t => { 349 | const mock = new Mock() 350 | const client = new Client({ 351 | node: 'http://localhost:9200', 352 | compression: true, 353 | Connection: mock.getConnection() 354 | }) 355 | 356 | mock.add({ 357 | method: 'POST', 358 | path: '/test/_search' 359 | }, params => params) 360 | 361 | const response = await client.search({ 362 | index: 'test', 363 | query: { match_all: {} } 364 | }) 365 | 366 | t.deepEqual(response, { 367 | method: 'POST', 368 | path: '/test/_search', 369 | querystring: {}, 370 | body: { 371 | query: { match_all: {} } 372 | } 373 | }) 374 | }) 375 | 376 | test('Should handle the same mock with different body/querystring', async t => { 377 | const mock = new Mock() 378 | const client = new Client({ 379 | node: 'http://localhost:9200', 380 | Connection: mock.getConnection() 381 | }) 382 | 383 | mock.add({ 384 | method: 'POST', 385 | path: '/test/_search', 386 | querystring: { pretty: 'true' }, 387 | body: { query: { match_all: {} } } 388 | }, () => { 389 | return { 390 | hits: { 391 | total: { value: 1, relation: 'eq' }, 392 | hits: [{ _source: { baz: 'faz' } }] 393 | } 394 | } 395 | }) 396 | 397 | mock.add({ 398 | method: 'POST', 399 | path: '/test/_search', 400 | querystring: { pretty: 'true' }, 401 | body: { query: { match: { foo: 'bar' } } } 402 | }, () => { 403 | return { 404 | hits: { 405 | total: { value: 0, relation: 'eq' }, 406 | hits: [] 407 | } 408 | } 409 | }) 410 | 411 | let response = await client.search({ 412 | index: 'test', 413 | pretty: true, 414 | query: { match_all: {} } 415 | }) 416 | 417 | t.deepEqual(response, { 418 | hits: { 419 | total: { value: 1, relation: 'eq' }, 420 | hits: [{ _source: { baz: 'faz' } }] 421 | } 422 | }) 423 | 424 | response = await client.search({ 425 | index: 'test', 426 | pretty: true, 427 | query: { match: { foo: 'bar' } } 428 | }) 429 | 430 | t.deepEqual(response, { 431 | hits: { 432 | total: { value: 0, relation: 'eq' }, 433 | hits: [] 434 | } 435 | }) 436 | }) 437 | 438 | test('Discriminate on the querystring', async t => { 439 | const mock = new Mock() 440 | const client = new Client({ 441 | node: 'http://localhost:9200', 442 | Connection: mock.getConnection() 443 | }) 444 | 445 | mock.add({ 446 | method: 'GET', 447 | path: '/_cat/indices' 448 | }, () => { 449 | return { querystring: false } 450 | }) 451 | 452 | mock.add({ 453 | method: 'GET', 454 | path: '/_cat/indices', 455 | querystring: { pretty: 'true' } 456 | }, () => { 457 | return { querystring: true } 458 | }) 459 | 460 | const response = await client.cat.indices({ pretty: true }, { meta: true }) 461 | t.deepEqual(response.body, { querystring: true }) 462 | t.is(response.statusCode, 200) 463 | }) 464 | 465 | test('The handler for the route exists, but the request is not enough precise', async t => { 466 | const mock = new Mock() 467 | const client = new Client({ 468 | node: 'http://localhost:9200', 469 | Connection: mock.getConnection() 470 | }) 471 | 472 | mock.add({ 473 | method: 'GET', 474 | path: '/_cat/indices', 475 | querystring: { human: 'true' } 476 | }, () => { 477 | return { status: 'ok' } 478 | }) 479 | 480 | mock.add({ 481 | method: 'GET', 482 | path: '/_cat/indices', 483 | querystring: { pretty: 'true' } 484 | }, () => { 485 | return { status: 'ok' } 486 | }) 487 | 488 | try { 489 | await client.cat.indices() 490 | t.fail('Should throw') 491 | } catch (err) { 492 | t.true(err instanceof errors.ResponseError) 493 | t.is(err.body.error, 'Mock not found') 494 | t.is(err.statusCode, 404) 495 | } 496 | }) 497 | 498 | test('Send back a plain string', async t => { 499 | const mock = new Mock() 500 | const client = new Client({ 501 | node: 'http://localhost:9200', 502 | Connection: mock.getConnection() 503 | }) 504 | 505 | mock.add({ 506 | method: 'GET', 507 | path: '/_cat/indices' 508 | }, () => { 509 | return 'ok' 510 | }) 511 | 512 | const response = await client.cat.indices({}, { meta: true }) 513 | t.is(response.body, 'ok') 514 | t.is(response.statusCode, 200) 515 | t.is(response.headers['content-type'], 'text/plain;utf=8') 516 | }) 517 | 518 | test('Should ignore trailing slashes', async t => { 519 | const mock = new Mock() 520 | const client = new Client({ 521 | node: 'http://localhost:9200', 522 | Connection: mock.getConnection() 523 | }) 524 | 525 | mock.add({ 526 | method: 'GET', 527 | path: '/_cat/indices/' 528 | }, () => { 529 | return { status: 'ok' } 530 | }) 531 | 532 | const response = await client.cat.indices({}, { meta: true }) 533 | t.deepEqual(response.body, { status: 'ok' }) 534 | t.is(response.statusCode, 200) 535 | }) 536 | 537 | test('.add should throw if method and path are not defined', async t => { 538 | const mock = new Mock() 539 | 540 | try { 541 | mock.add({ path: '/' }, () => {}) 542 | t.fail('Should throw') 543 | } catch (err) { 544 | t.true(err instanceof errors.ConfigurationError) 545 | t.is(err.message, 'The method is not defined') 546 | } 547 | 548 | try { 549 | mock.add({ method: 'GET' }, () => {}) 550 | t.fail('Should throw') 551 | } catch (err) { 552 | t.true(err instanceof errors.ConfigurationError) 553 | t.is(err.message, 'The path is not defined') 554 | } 555 | 556 | try { 557 | mock.add({ method: 'GET', path: '/' }) 558 | t.fail('Should throw') 559 | } catch (err) { 560 | t.true(err instanceof errors.ConfigurationError) 561 | t.is(err.message, 'The resolver function is not defined') 562 | } 563 | }) 564 | 565 | test('Define multiple methods at once', async t => { 566 | const mock = new Mock() 567 | const client = new Client({ 568 | node: 'http://localhost:9200', 569 | Connection: mock.getConnection() 570 | }) 571 | 572 | mock.add({ 573 | method: ['GET', 'POST'], 574 | path: '/:index/_search' 575 | }, () => { 576 | return { status: 'ok' } 577 | }) 578 | 579 | let response = await client.search({ 580 | index: 'test', 581 | q: 'foo:bar' 582 | }, { meta: true }) 583 | t.deepEqual(response.body, { status: 'ok' }) 584 | t.is(response.statusCode, 200) 585 | 586 | response = await client.search({ 587 | index: 'test', 588 | query: { match: { foo: 'bar' } } 589 | }, { meta: true }) 590 | t.deepEqual(response.body, { status: 'ok' }) 591 | t.is(response.statusCode, 200) 592 | }) 593 | 594 | test('Define multiple paths at once', async t => { 595 | const mock = new Mock() 596 | const client = new Client({ 597 | node: 'http://localhost:9200', 598 | Connection: mock.getConnection() 599 | }) 600 | 601 | mock.add({ 602 | method: 'GET', 603 | path: ['/test1/_search', '/test2/_search'] 604 | }, () => { 605 | return { status: 'ok' } 606 | }) 607 | 608 | let response = await client.search({ 609 | index: 'test1', 610 | q: 'foo:bar' 611 | }, { meta: true }) 612 | t.deepEqual(response.body, { status: 'ok' }) 613 | t.is(response.statusCode, 200) 614 | 615 | response = await client.search({ 616 | index: 'test2', 617 | q: 'foo:bar' 618 | }, { meta: true }) 619 | t.deepEqual(response.body, { status: 'ok' }) 620 | t.is(response.statusCode, 200) 621 | }) 622 | 623 | test('Define multiple paths and method at once', async t => { 624 | const mock = new Mock() 625 | const client = new Client({ 626 | node: 'http://localhost:9200', 627 | Connection: mock.getConnection() 628 | }) 629 | 630 | mock.add({ 631 | method: ['GET', 'POST'], 632 | path: ['/test1/_search', '/test2/_search'] 633 | }, () => { 634 | return { status: 'ok' } 635 | }) 636 | 637 | let response = await client.search({ 638 | index: 'test1', 639 | q: 'foo:bar' 640 | }, { meta: true }) 641 | t.deepEqual(response.body, { status: 'ok' }) 642 | t.is(response.statusCode, 200) 643 | 644 | response = await client.search({ 645 | index: 'test2', 646 | q: 'foo:bar' 647 | }, { meta: true }) 648 | t.deepEqual(response.body, { status: 'ok' }) 649 | t.is(response.statusCode, 200) 650 | 651 | response = await client.search({ 652 | index: 'test1', 653 | query: { match: { foo: 'bar' } } 654 | }, { meta: true }) 655 | t.deepEqual(response.body, { status: 'ok' }) 656 | t.is(response.statusCode, 200) 657 | 658 | response = await client.search({ 659 | index: 'test2', 660 | query: { match: { foo: 'bar' } } 661 | }, { meta: true }) 662 | t.deepEqual(response.body, { status: 'ok' }) 663 | t.is(response.statusCode, 200) 664 | }) 665 | 666 | test('ndjson API support', async t => { 667 | const mock = new Mock() 668 | const client = new Client({ 669 | node: 'http://localhost:9200', 670 | Connection: mock.getConnection() 671 | }) 672 | 673 | mock.add({ 674 | method: 'POST', 675 | path: '/_bulk' 676 | }, params => { 677 | t.deepEqual(params.body, [ 678 | { foo: 'bar' }, 679 | { baz: 'fa\nz' } 680 | ]) 681 | return { status: 'ok' } 682 | }) 683 | 684 | const response = await client.bulk({ 685 | operations: [ 686 | { foo: 'bar' }, 687 | { baz: 'fa\nz' } 688 | ] 689 | }, { meta: true }) 690 | t.deepEqual(response.body, { status: 'ok' }) 691 | t.is(response.statusCode, 200) 692 | }) 693 | 694 | test('ndjson API support (with compression)', async t => { 695 | const mock = new Mock() 696 | const client = new Client({ 697 | node: 'http://localhost:9200', 698 | Connection: mock.getConnection(), 699 | compression: true 700 | }) 701 | 702 | mock.add({ 703 | method: 'POST', 704 | path: '/_bulk' 705 | }, params => { 706 | t.deepEqual(params.body, [ 707 | { foo: 'bar' }, 708 | { baz: 'fa\nz' } 709 | ]) 710 | return { status: 'ok' } 711 | }) 712 | 713 | const response = await client.bulk({ 714 | operations: [ 715 | { foo: 'bar' }, 716 | { baz: 'fa\nz' } 717 | ] 718 | }, { meta: true }) 719 | t.deepEqual(response.body, { status: 'ok' }) 720 | t.is(response.statusCode, 200) 721 | }) 722 | 723 | test('ndjson API support (as stream) with transport.request', async t => { 724 | const mock = new Mock() 725 | const client = new Client({ 726 | node: 'http://localhost:9200', 727 | Connection: mock.getConnection() 728 | }) 729 | 730 | mock.add({ 731 | method: 'POST', 732 | path: '/_bulk' 733 | }, params => { 734 | t.deepEqual(params.body, [ 735 | { foo: 'bar' }, 736 | { baz: 'fa\nz' } 737 | ]) 738 | return { status: 'ok' } 739 | }) 740 | 741 | const response = await client.transport.request({ 742 | method: 'POST', 743 | path: '/_bulk', 744 | bulkBody: intoStream(client.serializer.ndserialize([ 745 | { foo: 'bar' }, 746 | { baz: 'fa\nz' } 747 | ])) 748 | }, { meta: true }) 749 | t.deepEqual(response.body, { status: 'ok' }) 750 | t.is(response.statusCode, 200) 751 | }) 752 | 753 | test('ndjson API support (as stream with compression) with transport.request', async t => { 754 | const mock = new Mock() 755 | const client = new Client({ 756 | node: 'http://localhost:9200', 757 | Connection: mock.getConnection(), 758 | compression: true 759 | }) 760 | 761 | mock.add({ 762 | method: 'POST', 763 | path: '/_bulk' 764 | }, params => { 765 | t.deepEqual(params.body, [ 766 | { foo: 'bar' }, 767 | { baz: 'fa\nz' } 768 | ]) 769 | return { status: 'ok' } 770 | }) 771 | 772 | const response = await client.transport.request({ 773 | method: 'POST', 774 | path: '/_bulk', 775 | bulkBody: intoStream(client.serializer.ndserialize([ 776 | { foo: 'bar' }, 777 | { baz: 'fa\nz' } 778 | ])) 779 | }, { meta: true }) 780 | t.deepEqual(response.body, { status: 'ok' }) 781 | t.is(response.statusCode, 200) 782 | }) 783 | 784 | test('Should clear individual mocks', async t => { 785 | const mock = new Mock() 786 | const client = new Client({ 787 | node: 'http://localhost:9200', 788 | Connection: mock.getConnection() 789 | }) 790 | 791 | mock.add({ 792 | method: 'GET', 793 | path: ['/test1/_search', '/test2/_search'] 794 | }, () => { 795 | return { status: 'ok' } 796 | }) 797 | 798 | // Clear test1 but not test2 799 | mock.clear({ method: 'GET', path: ['/test1/_search'] }) 800 | 801 | // test2 still works 802 | const response = await client.search({ 803 | index: 'test2', 804 | q: 'foo:bar' 805 | }, { meta: true }) 806 | t.deepEqual(response.body, { status: 'ok' }) 807 | t.is(response.statusCode, 200) 808 | 809 | // test1 does not 810 | try { 811 | await client.search({ 812 | index: 'test1', 813 | q: 'foo:bar' 814 | }) 815 | t.fail('Should throw') 816 | } catch (err) { 817 | t.true(err instanceof errors.ResponseError) 818 | t.is(err.body.error, 'Mock not found') 819 | t.is(err.statusCode, 404) 820 | } 821 | }) 822 | 823 | test('.mock should throw if method and path are not defined', async t => { 824 | const mock = new Mock() 825 | 826 | try { 827 | mock.clear({ path: '/' }, () => {}) 828 | t.fail('Should throw') 829 | } catch (err) { 830 | t.true(err instanceof errors.ConfigurationError) 831 | t.is(err.message, 'The method is not defined') 832 | } 833 | 834 | try { 835 | mock.clear({ method: 'GET' }, () => {}) 836 | t.fail('Should throw') 837 | } catch (err) { 838 | t.true(err instanceof errors.ConfigurationError) 839 | t.is(err.message, 'The path is not defined') 840 | } 841 | }) 842 | 843 | test('Should clear all mocks', async t => { 844 | const mock = new Mock() 845 | const client = new Client({ 846 | node: 'http://localhost:9200', 847 | Connection: mock.getConnection() 848 | }) 849 | 850 | mock.add({ 851 | method: 'GET', 852 | path: ['/test1/_search', '/test2/_search'] 853 | }, () => { 854 | return { status: 'ok' } 855 | }) 856 | 857 | // Clear mocks 858 | mock.clearAll() 859 | 860 | try { 861 | await client.search({ 862 | index: 'test1', 863 | q: 'foo:bar' 864 | }) 865 | t.fail('Should throw') 866 | } catch (err) { 867 | t.true(err instanceof errors.ResponseError) 868 | t.is(err.body.error, 'Mock not found') 869 | t.is(err.statusCode, 404) 870 | } 871 | try { 872 | await client.search({ 873 | index: 'test2', 874 | q: 'foo:bar' 875 | }) 876 | t.fail('Should throw') 877 | } catch (err) { 878 | t.true(err instanceof errors.ResponseError) 879 | t.is(err.body.error, 'Mock not found') 880 | t.is(err.statusCode, 404) 881 | } 882 | }) 883 | 884 | test('Path should match URL-encoded characters for e.g. comma in multi-index operations', async t => { 885 | t.plan(1) 886 | 887 | const mock = new Mock() 888 | const client = new Client({ 889 | node: 'http://localhost:9200', 890 | Connection: mock.getConnection() 891 | }) 892 | 893 | const spy = (_req, _res, _params, _store, _searchParams) => { 894 | t.pass('Callback function was called') 895 | return {} 896 | } 897 | 898 | mock.add( 899 | { 900 | method: 'DELETE', 901 | path: '/some-type-index-123tobedeleted%2Csome-type-index-456tobedeleted' 902 | }, 903 | spy 904 | ) 905 | 906 | await client.indices.delete({ 907 | index: [ 908 | 'some-type-index-123tobedeleted', 909 | 'some-type-index-456tobedeleted' 910 | ] 911 | }) 912 | }) 913 | 914 | test('Path should match unencoded comma in path', async t => { 915 | t.plan(1) 916 | 917 | const mock = new Mock() 918 | const client = new Client({ 919 | node: 'http://localhost:9200', 920 | Connection: mock.getConnection() 921 | }) 922 | 923 | const spy = (_req, _res, _params, _store, _searchParams) => { 924 | t.pass('Callback function was called') 925 | return {} 926 | } 927 | 928 | mock.add( 929 | { 930 | method: 'DELETE', 931 | path: '/some-type-index-123tobedeleted,some-type-index-456tobedeleted' 932 | }, 933 | spy 934 | ) 935 | 936 | await client.indices.delete({ 937 | index: [ 938 | 'some-type-index-123tobedeleted', 939 | 'some-type-index-456tobedeleted' 940 | ] 941 | }) 942 | }) 943 | 944 | test('Validate types on get()', t => { 945 | t.plan(4) 946 | 947 | const mock = new Mock() 948 | mock.add( 949 | { 950 | method: 'GET', 951 | path: '/foo' 952 | }, 953 | () => {} 954 | ) 955 | 956 | try { 957 | mock.get({ method: 'GET', path: null }) 958 | t.fail('should throw') 959 | } catch (err) { 960 | t.true(err instanceof errors.ConfigurationError) 961 | t.is(err.message, 'The path is not defined') 962 | } 963 | 964 | try { 965 | mock.get({ method: null, path: '/foo' }) 966 | t.fail('should throw') 967 | } catch (err) { 968 | t.true(err instanceof errors.ConfigurationError) 969 | t.is(err.message, 'The method is not defined') 970 | } 971 | }) 972 | 973 | test('should show passed params when no mock is found', async t => { 974 | const mock = new Mock() 975 | mock.add({ method: 'DELETE', path: '/bar' }, () => {}) 976 | const client = new Client({ 977 | node: 'http://localhost:9200', 978 | Connection: mock.getConnection() 979 | }) 980 | 981 | try { 982 | await client.info() 983 | t.fail('should throw') 984 | } catch (err) { 985 | t.deepEqual(err.body, { 986 | error: 'Mock not found', 987 | params: { 988 | body: null, 989 | method: 'GET', 990 | path: '/', 991 | querystring: {} 992 | } 993 | }) 994 | } 995 | }) 996 | --------------------------------------------------------------------------------