diff --git a/test/lib/api.spec.js b/test/lib/api.spec.js index 02b13e762..953b53d4c 100644 --- a/test/lib/api.spec.js +++ b/test/lib/api.spec.js @@ -1,263 +1,319 @@ const { assert, expect } = require('chai'); const sinon = require('sinon'); const rewire = require('rewire'); +const rpn = require('request-promise-native'); let api = rewire('../../src/lib/api'); const environment = require('../../src/lib/environment'); const log = require('../../src/lib/log'); +const apiStub = require('../api-stub'); describe('api', () => { - let mockRequest; - beforeEach(() => { - mockRequest = { - get: sinon.stub().resolves(), - post: sinon.stub().resolves(), - put: sinon.stub().resolves(), - }; - api.__set__('request', mockRequest); - sinon.stub(environment, 'apiUrl').get(() => 'http://example.com/db-name'); - }); - afterEach(() => { - sinon.restore(); - api = rewire('../../src/lib/api'); - }); - - it('defaults to live requests', async () => { - sinon.stub(environment, 'isArchiveMode').get(() => false); - await api().version(); - expect(mockRequest.get.callCount).to.eq(1); - }); - - describe('formsValidate', async () => { - - it('should fail if validate endpoint returns invalid JSON', async () => { - sinon.stub(environment, 'isArchiveMode').get(() => false); - mockRequest.post.resolves('--NOT JSON--'); - try { - await api().formsValidate(''); - assert.fail('Expected assertion'); - } catch (e) { - expect(e.message).to.eq( - 'Invalid JSON response validating XForm against the API: --NOT JSON--'); - } + describe('methods', () => { + let mockRequest; + beforeEach(() => { + mockRequest = { + get: sinon.stub().resolves(), + post: sinon.stub().resolves(), + put: sinon.stub().resolves(), + }; + api.__set__('request', mockRequest); + sinon.stub(environment, 'apiUrl').get(() => 'http://example.com/db-name'); + }); + afterEach(() => { + sinon.restore(); + api = rewire('../../src/lib/api'); }); - it('should not fail if validate endpoint does not exist', async () => { + it('defaults to live requests', async () => { sinon.stub(environment, 'isArchiveMode').get(() => false); - mockRequest.post.rejects({name: 'StatusCodeError', statusCode: 404}); - let result = await api().formsValidate(''); - expect(result).to.deep.eq({ok: true, formsValidateEndpointFound: false}); - expect(mockRequest.post.callCount).to.eq(1); - // second call - result = await api().formsValidate('Another XML'); - expect(result).to.deep.eq({ok: true, formsValidateEndpointFound: false}); - expect(mockRequest.post.callCount).to.eq(1); // still HTTP client called only once + await api().version(); + expect(mockRequest.get.callCount).to.eq(1); }); - it('should not call API when --archive mode and response still ok', async () => { - api.__set__('environment', sinon.stub({ isArchiveMode: true })); - let result = await api().formsValidate(''); - expect(result).to.deep.eq({ok: true}); - expect(mockRequest.post.callCount).to.eq(0); - // second call - result = await api().formsValidate('Another XML'); - expect(result).to.deep.eq({ok: true}); - expect(mockRequest.post.callCount).to.eq(0); // still HTTP not called even once - }); - }); + describe('formsValidate', async () => { - describe('archive mode', async () => { - beforeEach(() => sinon.stub(environment, 'isArchiveMode').get(() => true)); + it('should fail if validate endpoint returns invalid JSON', async () => { + sinon.stub(environment, 'isArchiveMode').get(() => false); + mockRequest.post.resolves('--NOT JSON--'); + try { + await api().formsValidate(''); + assert.fail('Expected assertion'); + } catch (e) { + expect(e.message).to.eq( + 'Invalid JSON response validating XForm against the API: --NOT JSON--'); + } + }); - it('does not initiate requests to api', async () => { - await api().version(); - expect(mockRequest.get.callCount).to.eq(0); - }); + it('should not fail if validate endpoint does not exist', async () => { + sinon.stub(environment, 'isArchiveMode').get(() => false); + mockRequest.post.rejects({name: 'StatusCodeError', statusCode: 404}); + let result = await api().formsValidate(''); + expect(result).to.deep.eq({ok: true, formsValidateEndpointFound: false}); + expect(mockRequest.post.callCount).to.eq(1); + // second call + result = await api().formsValidate('Another XML'); + expect(result).to.deep.eq({ok: true, formsValidateEndpointFound: false}); + expect(mockRequest.post.callCount).to.eq(1); // still HTTP client called only once + }); - it('throws not supported for undefined interfaces', () => { - expect(api().getAppSettings).to.throw('getAppSettings not supported in --archive mode'); + it('should not call API when --archive mode and response still ok', async () => { + api.__set__('environment', sinon.stub({ isArchiveMode: true })); + let result = await api().formsValidate(''); + expect(result).to.deep.eq({ok: true}); + expect(mockRequest.post.callCount).to.eq(0); + // second call + result = await api().formsValidate('Another XML'); + expect(result).to.deep.eq({ok: true}); + expect(mockRequest.post.callCount).to.eq(0); // still HTTP not called even once + }); }); - }); - describe('updateAppSettings', async() => { + describe('archive mode', async () => { + beforeEach(() => sinon.stub(environment, 'isArchiveMode').get(() => true)); - it('changes settings on server', async () => { - sinon.stub(environment, 'isArchiveMode').get(() => false); - // GET /api/v1/settings/deprecated-transitions from logDeprecatedTransitions() - mockRequest.get.onCall(0).resolves([]); - // PUT /_design/medic/_rewrite/update_settings/medic?replace=1 from updateAppSettings() - mockRequest.put.onCall(0).resolves({ ok: true }); - const response = await api().updateAppSettings(JSON.stringify({ - transitions: [ 'test' ] - })); - expect(response.ok).to.equal(true); - expect(mockRequest.get.callCount).to.equal(1); - expect(mockRequest.put.callCount).to.equal(1); - expect(mockRequest.put.args[0][0].body).to.equal('{"transitions":["test"]}'); + it('does not initiate requests to api', async () => { + await api().version(); + expect(mockRequest.get.callCount).to.eq(0); + }); + + it('throws not supported for undefined interfaces', () => { + expect(api().getAppSettings).to.throw('getAppSettings not supported in --archive mode'); + }); }); - it('throws error when server throws', async () => { - sinon.stub(environment, 'isArchiveMode').get(() => false); - mockRequest.get.onCall(0).resolves([]); - mockRequest.put.onCall(0).resolves({ ok: true }); - try { - await api().updateAppSettings(JSON.stringify({})); - } catch(err) { - expect(err.error).to.equal('random'); - expect(mockRequest.get.callCount).to.equal(0); + describe('updateAppSettings', async() => { + + it('changes settings on server', async () => { + sinon.stub(environment, 'isArchiveMode').get(() => false); + // GET /api/v1/settings/deprecated-transitions from logDeprecatedTransitions() + mockRequest.get.onCall(0).resolves([]); + // PUT /_design/medic/_rewrite/update_settings/medic?replace=1 from updateAppSettings() + mockRequest.put.onCall(0).resolves({ ok: true }); + const response = await api().updateAppSettings(JSON.stringify({ + transitions: [ 'test' ] + })); + expect(response.ok).to.equal(true); + expect(mockRequest.get.callCount).to.equal(1); expect(mockRequest.put.callCount).to.equal(1); - } - }); + expect(mockRequest.put.args[0][0].body).to.equal('{"transitions":["test"]}'); + }); - it('logs and continues when using deprecated transitions', async () => { - sinon.stub(environment, 'isArchiveMode').get(() => false); - sinon.stub(log, 'warn'); - mockRequest.get.onCall(0).resolves([ { - name: 'go', - deprecated: true, - deprecatedIn: '3.10.0', - deprecationMessage: 'Use go2 instead' - } ]); - mockRequest.put.onCall(0).resolves({ ok: true }); - await api().updateAppSettings(JSON.stringify({ - transitions: { go: { disable: false } } - })); - expect(log.warn.callCount).to.equal(1); - expect(log.warn.args[0][0]).to.equal('Use go2 instead'); - }); + it('throws error when server throws', async () => { + sinon.stub(environment, 'isArchiveMode').get(() => false); + mockRequest.get.onCall(0).resolves([]); + mockRequest.put.onCall(0).resolves({ ok: true }); + try { + await api().updateAppSettings(JSON.stringify({})); + } catch(err) { + expect(err.error).to.equal('random'); + expect(mockRequest.get.callCount).to.equal(0); + expect(mockRequest.put.callCount).to.equal(1); + } + }); - it('continues when deprecated transitions call throws', async () => { - sinon.stub(environment, 'isArchiveMode').get(() => false); - sinon.stub(log, 'warn'); - mockRequest.get.onCall(0).rejects({ statusCode: 500, message: 'some error' }); - mockRequest.put.onCall(0).resolves({ ok: true }); - await api().updateAppSettings(JSON.stringify({ - transitions: [ 'test' ] - })); - expect(log.warn.callCount).to.equal(1); - }); + it('logs and continues when using deprecated transitions', async () => { + sinon.stub(environment, 'isArchiveMode').get(() => false); + sinon.stub(log, 'warn'); + mockRequest.get.onCall(0).resolves([ { + name: 'go', + deprecated: true, + deprecatedIn: '3.10.0', + deprecationMessage: 'Use go2 instead' + } ]); + mockRequest.put.onCall(0).resolves({ ok: true }); + await api().updateAppSettings(JSON.stringify({ + transitions: { go: { disable: false } } + })); + expect(log.warn.callCount).to.equal(1); + expect(log.warn.args[0][0]).to.equal('Use go2 instead'); + }); - }); + it('continues when deprecated transitions call throws', async () => { + sinon.stub(environment, 'isArchiveMode').get(() => false); + sinon.stub(log, 'warn'); + mockRequest.get.onCall(0).rejects({ statusCode: 500, message: 'some error' }); + mockRequest.put.onCall(0).resolves({ ok: true }); + await api().updateAppSettings(JSON.stringify({ + transitions: [ 'test' ] + })); + expect(log.warn.callCount).to.equal(1); + }); - describe('getCompressibleTypes', async () => { - it('call the API and parse types from string correctly', async () => { - sinon.stub(environment, 'isArchiveMode').get(() => false); - sinon.stub(environment, 'force').get(() => false); - mockRequest.get.resolves({'compressible_types':'text/*, application/*', 'compression_level':'8'}); - const cacheSpy = new Map(); - const cacheGetSpy = sinon.spy(cacheSpy, 'get'); - api.__set__('cache', cacheSpy); - let compressibleTypes = await api().getCompressibleTypes(); - expect(compressibleTypes).to.deep.eq(['text/*', 'application/*']); - assert.equal(mockRequest.get.callCount, 1); - assert.equal(cacheGetSpy.callCount, 0); - - // second time the cache is used - compressibleTypes = await api().getCompressibleTypes(); - expect(compressibleTypes).to.deep.eq(['text/*', 'application/*']); // same values from cache - assert.equal(mockRequest.get.callCount, 1); // still 1 request - assert.equal(cacheGetSpy.callCount, 1); }); - it('returns empty if API returns 404', async () => { - sinon.stub(environment, 'isArchiveMode').get(() => false); - sinon.stub(environment, 'force').get(() => false); - mockRequest.get.rejects({statusCode:404}); - const cacheSpy = new Map(); - const cacheGetSpy = sinon.spy(cacheSpy, 'get'); - api.__set__('cache', cacheSpy); - let compressibleTypes = await api().getCompressibleTypes(); - expect(compressibleTypes).to.deep.eq([]); - assert.equal(mockRequest.get.callCount, 1); - assert.equal(cacheGetSpy.callCount, 0); - - // second time the cache is used - compressibleTypes = await api().getCompressibleTypes(); - expect(compressibleTypes).to.deep.eq([]); // same values from cache - assert.equal(mockRequest.get.callCount, 1); // still 1 request - assert.equal(cacheGetSpy.callCount, 1); - }); + describe('getCompressibleTypes', async () => { + it('call the API and parse types from string correctly', async () => { + sinon.stub(environment, 'isArchiveMode').get(() => false); + sinon.stub(environment, 'force').get(() => false); + mockRequest.get.resolves({'compressible_types':'text/*, application/*', 'compression_level':'8'}); + const cacheSpy = new Map(); + const cacheGetSpy = sinon.spy(cacheSpy, 'get'); + api.__set__('cache', cacheSpy); + let compressibleTypes = await api().getCompressibleTypes(); + expect(compressibleTypes).to.deep.eq(['text/*', 'application/*']); + assert.equal(mockRequest.get.callCount, 1); + assert.equal(cacheGetSpy.callCount, 0); - it('returns empty if API returns error and without caching result', async () => { - sinon.stub(environment, 'isArchiveMode').get(() => false); - sinon.stub(environment, 'force').get(() => false); - const getReqStub = mockRequest.get; - getReqStub.onCall(0).rejects('The error'); - getReqStub.onCall(1).resolves({'compressible_types':'text/*, application/*', 'compression_level':'8'}); - const cacheSpy = new Map(); - const cacheGetSpy = sinon.spy(cacheSpy, 'get'); - api.__set__('cache', cacheSpy); - let compressibleTypes = await api().getCompressibleTypes(); - expect(compressibleTypes).to.deep.eq([]); - assert.equal(mockRequest.get.callCount, 1); - assert.equal(cacheGetSpy.callCount, 0); - - // second time cache is NOT used and value from API is returned - compressibleTypes = await api().getCompressibleTypes(); - expect(compressibleTypes).to.deep.eq(['text/*', 'application/*']); // values from API second call - assert.equal(mockRequest.get.callCount, 2); // 2 requests total - assert.equal(cacheGetSpy.callCount, 0); // cache not used + // second time the cache is used + compressibleTypes = await api().getCompressibleTypes(); + expect(compressibleTypes).to.deep.eq(['text/*', 'application/*']); // same values from cache + assert.equal(mockRequest.get.callCount, 1); // still 1 request + assert.equal(cacheGetSpy.callCount, 1); + }); + + it('returns empty if API returns 404', async () => { + sinon.stub(environment, 'isArchiveMode').get(() => false); + sinon.stub(environment, 'force').get(() => false); + mockRequest.get.rejects({statusCode:404}); + const cacheSpy = new Map(); + const cacheGetSpy = sinon.spy(cacheSpy, 'get'); + api.__set__('cache', cacheSpy); + let compressibleTypes = await api().getCompressibleTypes(); + expect(compressibleTypes).to.deep.eq([]); + assert.equal(mockRequest.get.callCount, 1); + assert.equal(cacheGetSpy.callCount, 0); + + // second time the cache is used + compressibleTypes = await api().getCompressibleTypes(); + expect(compressibleTypes).to.deep.eq([]); // same values from cache + assert.equal(mockRequest.get.callCount, 1); // still 1 request + assert.equal(cacheGetSpy.callCount, 1); + }); + + it('returns empty if API returns error and without caching result', async () => { + sinon.stub(environment, 'isArchiveMode').get(() => false); + sinon.stub(environment, 'force').get(() => false); + const getReqStub = mockRequest.get; + getReqStub.onCall(0).rejects('The error'); + getReqStub.onCall(1).resolves({'compressible_types':'text/*, application/*', 'compression_level':'8'}); + const cacheSpy = new Map(); + const cacheGetSpy = sinon.spy(cacheSpy, 'get'); + api.__set__('cache', cacheSpy); + let compressibleTypes = await api().getCompressibleTypes(); + expect(compressibleTypes).to.deep.eq([]); + assert.equal(mockRequest.get.callCount, 1); + assert.equal(cacheGetSpy.callCount, 0); + + // second time cache is NOT used and value from API is returned + compressibleTypes = await api().getCompressibleTypes(); + expect(compressibleTypes).to.deep.eq(['text/*', 'application/*']); // values from API second call + assert.equal(mockRequest.get.callCount, 2); // 2 requests total + assert.equal(cacheGetSpy.callCount, 0); // cache not used + }); }); - }); - describe('available', async () => { + describe('available', async () => { - beforeEach(() => sinon.stub(environment, 'apiUrl').get(() => 'http://api/medic')); + beforeEach(() => sinon.stub(environment, 'apiUrl').get(() => 'http://api/medic')); - async function testAvailableError(response, expected) { - sinon.stub(environment, 'isArchiveMode').get(() => false); - mockRequest.get.rejects(response); - try { - await api().available(); - assert.fail('Expected error to be thrown'); - } catch(e) { - expect(e.message).to.eq(expected); + async function testAvailableError(response, expected) { + sinon.stub(environment, 'isArchiveMode').get(() => false); + mockRequest.get.rejects(response); + try { + await api().available(); + assert.fail('Expected error to be thrown'); + } catch(e) { + expect(e.message).to.eq(expected); + } } - } - it('should not throw if no error found in request', async () => { - sinon.stub(environment, 'isArchiveMode').get(() => false); - mockRequest.get.resolves('okey dokey'); - await api().available(); + it('should not throw if no error found in request', async () => { + sinon.stub(environment, 'isArchiveMode').get(() => false); + mockRequest.get.resolves('okey dokey'); + await api().available(); + }); + + it('should throw if request fails to connect', async () => { + await testAvailableError( + {}, + 'Failed to get a response from http://api/medic/. Maybe you entered the wrong URL, ' + + 'wrong port or the instance is not started. Please check and try again.' + ); + }); + + it('should throw if request returns authentication error', async () => { + await testAvailableError( + { statusCode: 401 }, + 'Authentication failed connecting to http://api/medic/. ' + + 'Check the supplied username and password and try again.' + ); + }); + + it('should throw if request returns permissions error', async () => { + await testAvailableError( + { statusCode: 403 }, + 'Insufficient permissions connecting to http://api/medic/. ' + + 'You need to use admin permissions to execute this command.' + ); + }); + + it('should throw if request returns unknown error', async () => { + await testAvailableError( + { statusCode: 503 }, + 'Received error code 503 connecting to http://api/medic/. ' + + 'Check the server and and try again.' + ); + }); + + it('should return if archive mode is enabled even when api is not available', async () => { + sinon.stub(environment, 'isArchiveMode').get(() => true); + mockRequest.get.rejects('Ups'); + await api().available(); + expect(mockRequest.get.callCount).to.eq(0); // api is not called + }); }); + }); - it('should throw if request fails to connect', async () => { - await testAvailableError( - {}, - 'Failed to get a response from http://api/medic/. Maybe you entered the wrong URL, ' + - 'wrong port or the instance is not started. Please check and try again.' - ); + describe('retry mechanism', function () { + this.timeout(15000); + + let spyGet; + beforeEach(() => { + apiStub.start(); + sinon.stub(environment, 'isArchiveMode').get(() => false); + spyGet = sinon.spy(rpn, 'get'); }); - it('should throw if request returns authentication error', async () => { - await testAvailableError( - { statusCode: 401 }, - 'Authentication failed connecting to http://api/medic/. ' + - 'Check the supplied username and password and try again.' - ); + afterEach(() => { + sinon.restore(); + return apiStub.stop(); }); - it('should throw if request returns permissions error', async () => { - await testAvailableError( - { statusCode: 403 }, - 'Insufficient permissions connecting to http://api/medic/. ' + - 'You need to use admin permissions to execute this command.' + it('should throw after the request failed 6 times', async () => { + apiStub.giveResponses( + { status: 404, body: { error: 'not_found' } }, + { status: 404, body: { error: 'not_found' } }, + { status: 404, body: { error: 'not_found' } }, + { status: 404, body: { error: 'not_found' } }, + { status: 404, body: { error: 'not_found' } }, + { status: 404, body: { error: 'not_found' } }, ); + + try { + await api().version(); + } catch (error) { + expect(error.statusCode).to.eq(404); + expect(error.error).to.deep.eq({ error: 'not_found' }); + expect(spyGet.callCount).to.eq(6); + } }); - it('should throw if request returns unknown error', async () => { - await testAvailableError( - { statusCode: 503 }, - 'Received error code 503 connecting to http://api/medic/. ' + - 'Check the server and and try again.' + it('should successfully request the version from the API despite it failing 3 times', async () => { + apiStub.giveResponses( + { status: 500, body: { error: 'internal_server_error' } }, + { status: 500, body: { error: 'internal_server_error' } }, + { status: 500, body: { error: 'internal_server_error' } }, + { status: 200, body: { version: '3.5.0' } }, ); - }); - it('should return if archive mode is enabled even when api is not available', async () => { - sinon.stub(environment, 'isArchiveMode').get(() => true); - mockRequest.get.rejects('Ups'); - await api().available(); - expect(mockRequest.get.callCount).to.eq(0); // api is not called + try { + const version = await api().version(); + expect(spyGet.callCount).to.eq(4); + expect(version).to.eq('3.5.0'); + } catch (error) { + expect.fail('no error should be thrown'); + } }); }); });