UNPKG

@anvilco/anvil

Version:
971 lines (802 loc) 32.2 kB
import fs from 'fs' import path from 'path' import { RateLimiter } from 'limiter' import { AbortSignal } from 'abort-controller' import Anvil from '../index' const assetsDir = path.join(__dirname, 'assets') function mockNodeFetchResponse (options = {}) { const { headers: headersIn = {}, ...rest } = options const { status, statusText, json, buffer, arrayBuffer, headers = { 'x-ratelimit-limit': 1, 'x-ratelimit-interval-ms': 1000, ...headersIn, }, body, } = rest const mock = { status, statusText: statusText || ((status && status >= 200 && status < 300) ? 'OK' : 'Please specify error statusText for testing'), } mock.json = typeof json === 'function' ? json : () => json if (json) { headers['content-type'] = 'application/json' } mock.buffer = typeof buffer === 'function' ? buffer : () => buffer instanceof Buffer ? buffer : Buffer.from(buffer) if (arrayBuffer) { mock.arrayBuffer = typeof buffer === 'function' ? arrayBuffer : () => arrayBuffer } else { mock.arrayBuffer = () => { const buffer = mock.buffer() const ab = new ArrayBuffer(buffer.length) const view = new Uint8Array(ab) for (let i = 0; i < buffer.length; ++i) { view[i] = buffer[i] } return ab } } mock.body = body mock.headers = { get: (header) => headers[header], } return mock } function fakeThrottle (fn) { return fn(() => fakeThrottle(fn)) } let FormDataModule describe('Anvil API Client', function () { before(async function () { FormDataModule ??= await import('formdata-polyfill/esm.min.js') }) beforeEach(function () { sinon.stub(Anvil.prototype, '_throttle').callsFake(fakeThrottle) }) afterEach(function () { sinon.restore() }) describe('constructor', function () { it('throws an error when no options specified', async function () { expect(() => new Anvil()).to.throw('options are required') }) it('throws an error when no apiKey or accessToken specified', async function () { expect(() => new Anvil({})).to.throw('apiKey or accessToken required') }) it('builds a Basic auth header when apiKey passed in', async function () { const apiKey = 'abc123' const client = new Anvil({ apiKey }) expect(client.authHeader).to.equal(`Basic ${Buffer.from(`${apiKey}:`, 'ascii').toString('base64')}`) }) it('builds a Bearer auth header when accessToken passed in', async function () { const accessToken = 'def345' const client = new Anvil({ accessToken }) expect(client.authHeader).to.equal(`Bearer ${Buffer.from(accessToken, 'ascii').toString('base64')}`) }) }) describe('REST endpoints', function () { let client beforeEach(async function () { client = new Anvil({ apiKey: 'abc123' }) sinon.stub(client, '_request') }) describe('requestREST', function () { let options, clientOptions, data, result it('returns statusCode and data when specified', async function () { options = { method: 'POST', } clientOptions = { dataType: 'json', } data = { result: 'ok' } client._request.callsFake((url, options) => { return Promise.resolve( mockNodeFetchResponse({ status: 200, json: data, headers: { 'content-type': 'application/json' }, }), ) }) const result = await client.requestREST('/test', options, clientOptions) expect(client._request).to.have.been.calledOnce expect(result.statusCode).to.eql(200) expect(result.data).to.eql(data) }) it('rejects promise when error', async function () { options = { method: 'POST' } client._request.callsFake((url, options) => { throw new Error('problem') }) await expect(client.requestREST('/test', options)).to.eventually.have.been.rejectedWith('problem') }) it('handles various error response structures', async function () { options = { method: 'GET', } clientOptions = { dataType: 'json', } const errors = [ { name: 'AssertionError', message: 'PDF did not generate properly from given HTML!', }, { name: 'ValidationError', fields: [{ message: 'Required', property: 'data' }], }, ] for (const error of errors) { client._request.callsFake((url, options) => { return Promise.resolve( mockNodeFetchResponse({ // Some calls (like those to GraphQL) will return 200 / OKs but actually contain // errors status: 200, statusText: 'OK', json: () => error, headers: { 'content-type': 'application/json' }, }), ) }) const result = await client.requestREST('/some-endpoint', options, clientOptions) expect(result.statusCode).to.eql(200) expect(result.errors).to.eql([error]) } }) it('recovers when JSON parsing of error response fails AND gives default error structure', async function () { options = { method: 'GET', } clientOptions = { dataType: 'json', } client._request.callsFake((url, options) => { return Promise.resolve( mockNodeFetchResponse({ status: 404, statusText: 'Not Found', json: () => JSON.parse('will not parse'), }), ) }) const result = await client.requestREST('/non-existing-endpoint', options, clientOptions) expect(result.statusCode).to.eql(404) expect(result.errors).to.be.an('array').of.length(1) expect(result.errors[0]).to.include({ name: 'SyntaxError', message: 'Unexpected token w in JSON at position 0', code: undefined, cause: undefined, }) }) it('sets the rate limiter from the response headers', async function () { // Originally, these are true expect(client.hasSetLimiterFromResponse).to.eql(false) expect(client.limiterSettingInProgress).to.eql(false) expect(client.rateLimiterSetupPromise).to.be.an.instanceof(Promise) expect(client.limitTokens).to.eql(1) expect(client.limitIntervalMs).to.eql(1000) expect(client.limiter).to.be.an.instanceof(RateLimiter) client._request.callsFake((url, options) => { return Promise.resolve( mockNodeFetchResponse({ status: 200, json: data, headers: { 'x-ratelimit-limit': 42, 'x-ratelimit-interval-ms': 4200, }, }), ) }) const result = await client.requestREST('/test', options, clientOptions) // Afterwards, these are true expect(client._request).to.have.been.calledOnce expect(result.statusCode).to.eql(200) expect(result.data).to.eql(data) expect(client.hasSetLimiterFromResponse).to.eql(true) expect(client.limitTokens).to.eql(42) expect(client.limitIntervalMs).to.eql(4200) expect(client.limiter).to.be.an.instanceof(RateLimiter) }) it('retries when a 429 response', async function () { options = { method: 'POST' } clientOptions = { dataType: 'json' } data = { result: 'ok' } client._request.onCall(0).callsFake((url, options) => { return Promise.resolve( mockNodeFetchResponse({ status: 429, headers: { 'retry-after': '0.2', // in seconds }, }), ) }) client._request.onCall(1).callsFake((url, options) => { return Promise.resolve( mockNodeFetchResponse({ status: 200, json: data, }), ) }) result = await client.requestREST('/test', options, clientOptions) expect(client._request).to.have.been.calledTwice expect(result.statusCode).to.eql(200) expect(result.data).to.eql(data) }) }) describe('fillPDF', function () { def('statusCode', 200) beforeEach(async function () { client._request.callsFake((url, options) => { return Promise.resolve( mockNodeFetchResponse({ status: $.statusCode, buffer: $.buffer, json: $.json, }), ) }) }) context('everything goes well', function () { def('buffer', 'This would be PDF data...') def('payload', { title: 'Test', fontSize: 8, textColor: '#CC0000', data: { helloId: 'hello!', }, }) it('returns data', async function () { const payload = $.payload const result = await client.fillPDF('cast123', payload) expect(result.statusCode).to.eql(200) expect(result.data.toString()).to.eql('This would be PDF data...') expect(client._request).to.have.been.calledOnce const [url, options] = client._request.lastCall.args expect(url).to.eql('/api/v1/fill/cast123.pdf') expect(options).to.eql({ method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json', }, }) }) it('works with `versionNumber`', async function () { const payload = $.payload const result = await client.fillPDF('cast123', payload, { versionNumber: 5 }) expect(result.statusCode).to.eql(200) expect(result.data.toString()).to.eql('This would be PDF data...') expect(client._request).to.have.been.calledOnce const [url, options] = client._request.lastCall.args expect(url).to.eql('/api/v1/fill/cast123.pdf?versionNumber=5') expect(options).to.eql({ method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json', }, }) }) }) context('server 400s with errors array in JSON', function () { const errors = [{ message: 'problem' }] def('statusCode', 400) def('json', { errors }) it('finds errors and puts them in response', async function () { const result = await client.fillPDF('cast123', {}) expect(client._request).to.have.been.calledOnce expect(result.statusCode).to.eql(400) expect(result.errors).to.eql(errors) }) }) context('server 401s with single error in response', function () { const error = { name: 'AuthorizationError', message: 'problem' } def('statusCode', 401) def('json', error) it('finds error and puts it in the response', async function () { const result = await client.fillPDF('cast123', {}) expect(client._request).to.have.been.calledOnce expect(result.statusCode).to.eql(401) expect(result.errors).to.eql([error]) }) }) }) describe('generatePDF', function () { def('statusCode', 200) beforeEach(async function () { client._request.callsFake((url, options) => { return Promise.resolve( mockNodeFetchResponse({ status: $.statusCode, buffer: $.buffer, json: $.json, }), ) }) }) context('everything goes well', function () { def('buffer', 'This would be PDF data...') it('returns data', async function () { const payload = { title: 'Test', data: [{ label: 'hello!', }], } const result = await client.generatePDF(payload) expect(result.statusCode).to.eql(200) expect(result.data.toString()).to.eql('This would be PDF data...') expect(client._request).to.have.been.calledOnce const [url, options] = client._request.lastCall.args expect(url).to.eql('/api/v1/generate-pdf') expect(options).to.eql({ method: 'POST', body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json', }, }) }) }) context('server 400s with errors array in JSON', function () { const errors = [{ message: 'problem' }] def('statusCode', 400) def('json', { errors }) it('finds errors and puts them in response', async function () { const result = await client.generatePDF('cast123', {}) expect(client._request).to.have.been.calledOnce expect(result.statusCode).to.eql(400) expect(result.errors).to.eql(errors) }) }) context('server 401s with single error in response', function () { const error = { name: 'AuthorizationError', message: 'Not logged in.' } def('statusCode', 401) def('json', error) it('finds error and puts it in the response', async function () { const result = await client.generatePDF('cast123', {}) expect(client._request).to.have.been.calledOnce expect(result.statusCode).to.eql(401) expect(result.errors).to.eql([error]) }) }) }) describe('downloadDocuments', function () { def('statusCode', 200) def('buffer', 'This would be Zip file data buffer...') def('body', 'This would be Zip file data stream') def('nodeFetchResponse', () => mockNodeFetchResponse({ status: $.statusCode, buffer: $.buffer, body: $.body, json: $.json, })) beforeEach(async function () { client._request.callsFake((url, options) => { return Promise.resolve($.nodeFetchResponse) }) }) context('everything goes well', function () { it('returns data as buffer', async function () { const { statusCode, response, data, errors } = await client.downloadDocuments('docGroupEid123') expect(data).to.be.an.instanceOf(Buffer) expect(statusCode).to.eql(200) expect(response).to.deep.eql($.nodeFetchResponse) expect(data.toString()).to.eql($.buffer) expect(errors).to.be.undefined }) it('returns data asn arrayBuffer', async function () { const { statusCode, response, data, errors } = await client.downloadDocuments('docGroupEid123', { dataType: 'arrayBuffer' }) expect(data).to.be.an.instanceOf(ArrayBuffer) expect(statusCode).to.eql(200) expect(response).to.deep.eql($.nodeFetchResponse) expect(Buffer.from(data).toString()).to.eql($.buffer) expect(errors).to.be.undefined }) it('returns data as stream', async function () { const { statusCode, response, data, errors } = await client.downloadDocuments('docGroupEid123', { dataType: 'stream' }) expect(statusCode).to.eql(200) expect(response).to.deep.eql($.nodeFetchResponse) expect(data).to.eql($.body) expect(errors).to.be.undefined }) }) context('unsupported options', function () { it('raises appropriate error', async function () { try { await client.downloadDocuments('docGroupEid123', { dataType: 'json' }) } catch (e) { expect(e.message).to.eql('dataType must be one of: stream|buffer|arrayBuffer') } }) }) context('server 400s with errors array in JSON', function () { const errors = [{ message: 'problem' }] def('statusCode', 400) def('json', { errors }) it('finds errors and puts them in response', async function () { const { statusCode, errors } = await client.downloadDocuments('docGroupEid123') expect(client._request).to.have.been.calledOnce expect(statusCode).to.eql(400) expect(errors).to.eql(errors) }) }) context('server 401s with single error in response', function () { const error = { name: 'AuthorizationError', message: 'problem' } def('statusCode', 401) def('json', error) it('finds error and puts it in the response', async function () { const { statusCode, errors } = await client.downloadDocuments('docGroupEid123') expect(client._request).to.have.been.calledOnce expect(statusCode).to.eql(401) expect(errors).to.eql([error]) }) }) }) }) describe('GraphQL', function () { const client = new Anvil({ apiKey: 'abc123' }) describe('requestGraphQL', function () { beforeEach(function () { sinon.stub(client, '_wrapRequest') client._wrapRequest.callsFake(async () => ({})) sinon.stub(client, '_request') }) describe('without files', function () { it('stringifies query and variables', async function () { const query = { foo: 'bar' } const variables = { baz: 'bop' } const clientOptions = { yo: 'mtvRaps' } await client.requestGraphQL({ query, variables }, clientOptions) expect(client._wrapRequest).to.have.been.calledOnce const [fn, clientOptionsReceived] = client._wrapRequest.lastCall.args expect(clientOptions).to.eql(clientOptionsReceived) fn() expect(client._request).to.have.been.calledOnce const [, options] = client._request.lastCall.args const { method, headers, body, } = options expect(method).to.eql('POST') expect(headers).to.eql({ 'Content-Type': 'application/json' }) expect(body).to.eql(JSON.stringify({ query, variables })) }) }) describe('with files', function () { beforeEach(function () { sinon.spy(FormDataModule.FormData.prototype, 'append') }) describe('schema is good', function () { const query = { foo: 'bar', baz: null } const clientOptions = { yo: 'mtvRaps' } afterEach(function () { if ($.willFail) { expect(client._wrapRequest).to.not.have.been.called return } expect(client._wrapRequest).to.have.been.calledOnce const [fn, clientOptionsReceived] = client._wrapRequest.lastCall.args expect(clientOptions).to.eql(clientOptionsReceived) fn() expect(client._request).to.have.been.calledOnce const [, options] = client._request.lastCall.args const { method, headers, body, signal, } = options expect(method).to.eql('POST') if ($.isBase64) { expect(headers).to.eql({ 'Content-Type': 'application/json', }) // Vars are untouched expect(JSON.parse(body).variables).to.eql($.variables) } else { expect(headers).to.eql({}) // node-fetch will add appropriate header expect(body).to.be.an.instanceof(FormDataModule.FormData) expect(signal).to.be.an.instanceof(AbortSignal) expect( FormDataModule.FormData.prototype.append.withArgs( 'map', JSON.stringify({ 1: ['variables.aNested.file'] }), ), ).calledOnce } }) context('using a Buffer', function () { def('variables', () => ({ aNested: { file: Anvil.prepareGraphQLFile(Buffer.from(''), { filename: 'test.pdf' }), }, })) it('creates a FormData and appends the files map when prepareGraphQLFile was used', async function () { await client.requestGraphQL({ query, variables: $.variables }, clientOptions) }) context('file is a Buffer', function () { def('willFail', () => true) def('variables', () => ({ aNested: { file: Buffer.from(''), }, })) it('throws an error creating a FormData and appending to the files map when a raw Buffer is used', async function () { await expect(client.requestGraphQL({ query, variables: $.variables }, clientOptions)) .to.eventually.be.rejectedWith('When passing a Buffer to prepareGraphQLFile, `options.filename` must be provided') }) }) }) context('file is a Stream', function () { def('variables', () => { const file = fs.createReadStream(path.join(assetsDir, 'dummy.pdf')) return { aNested: { file, }, } }) it('creates a FormData and appends the files map', async function () { await client.requestGraphQL({ query, variables: $.variables }, clientOptions) }) }) context('file is a base64 upload', function () { def('isBase64', () => true) def('variables', () => { return { aNested: { file: { data: Buffer.from('Base64 Data').toString('base64'), filename: 'omgwow.pdf', mimetype: 'application/pdf', }, }, } }) it('does not touch the variables at all', async function () { await client.requestGraphQL({ query, variables: $.variables }, clientOptions) }) }) }) describe('schema is not good', function () { context('file is not a stream or buffer', function () { it('throws error about the schema', async function () { const query = { foo: 'bar' } const variables = { file: 'i am not a file', } await expect(client.requestGraphQL({ query, variables })).to.eventually.be.rejectedWith('Invalid File schema detected') }) }) }) }) }) describe('createEtchPacket', function () { beforeEach(function () { sinon.stub(client, 'requestGraphQL') }) context('mutation is specified', function () { it('calls requestGraphQL with overridden mutation', async function () { const variables = { foo: 'bar' } const mutationOverride = 'createEtchPacketOverride()' await client.createEtchPacket({ variables, mutation: mutationOverride }) expect(client.requestGraphQL).to.have.been.calledOnce const [options, clientOptions] = client.requestGraphQL.lastCall.args const { query, variables: variablesReceived, } = options expect(variables).to.eql(variablesReceived) expect(query).to.include(mutationOverride) expect(clientOptions).to.eql({ dataType: 'json' }) }) }) context('no responseQuery specified', function () { it('calls requestGraphQL with default responseQuery', async function () { const variables = { foo: 'bar' } await client.createEtchPacket({ variables }) expect(client.requestGraphQL).to.have.been.calledOnce const [options, clientOptions] = client.requestGraphQL.lastCall.args const { query, variables: variablesReceived, } = options expect(variables).to.eql(variablesReceived) expect(query).to.include('documentGroup {') // "documentGroup" is in the default responseQuery expect(clientOptions).to.eql({ dataType: 'json' }) }) }) context('responseQuery specified', function () { it('calls requestGraphQL with overridden responseQuery', async function () { const variables = { foo: 'bar' } const responseQuery = 'onlyInATest {}' await client.createEtchPacket({ variables, responseQuery }) expect(client.requestGraphQL).to.have.been.calledOnce const [options, clientOptions] = client.requestGraphQL.lastCall.args const { query, variables: variablesReceived, } = options expect(variables).to.eql(variablesReceived) expect(query).to.include(responseQuery) expect(clientOptions).to.eql({ dataType: 'json' }) }) }) }) describe('generateEtchSignUrl', function () { def('statusCode', 200) beforeEach(async function () { sinon.stub(client, '_request') client._request.callsFake((url, options) => { return Promise.resolve($.nodeFetchResponse) }) }) context('everything goes well', function () { def('data', { data: { generateEtchSignURL: 'http://www.testing.com', }, }) def('nodeFetchResponse', () => mockNodeFetchResponse({ status: $.statusCode, json: $.data, })) it('returns url successfully', async function () { const variables = { clientUserId: 'foo', signerEid: 'bar' } const { statusCode, url, errors } = await client.generateEtchSignUrl({ variables }) expect(statusCode).to.eql(200) expect(url).to.be.eql($.data.data.generateEtchSignURL) expect(errors).to.be.undefined }) }) context('generate URL failures', function () { def('data', { data: {}, }) def('nodeFetchResponse', () => mockNodeFetchResponse({ status: $.statusCode, json: $.data, })) it('returns undefined url', async function () { const variables = { clientUserId: 'foo', signerEid: 'bar' } const { statusCode, url, errors } = await client.generateEtchSignUrl({ variables }) expect(statusCode).to.eql(200) expect(url).to.be.undefined expect(errors).to.be.undefined }) }) }) describe('getEtchPacket', function () { def('variables', { eid: 'etchPacketEid123' }) beforeEach(function () { sinon.stub(client, 'requestGraphQL') }) context('no responseQuery specified', function () { it('calls requestGraphQL with default responseQuery', async function () { await client.getEtchPacket({ variables: $.variables }) expect(client.requestGraphQL).to.have.been.calledOnce const [options, clientOptions] = client.requestGraphQL.lastCall.args const { query, variables: variablesReceived, } = options expect($.variables).to.eql(variablesReceived) expect(query).to.include('documentGroup {') expect(clientOptions).to.eql({ dataType: 'json' }) }) }) context('responseQuery specified', function () { it('calls requestGraphQL with overridden responseQuery', async function () { const responseQuery = 'myCustomResponseQuery' await client.getEtchPacket({ variables: $.variables, responseQuery }) expect(client.requestGraphQL).to.have.been.calledOnce const [options, clientOptions] = client.requestGraphQL.lastCall.args const { query, variables: variablesReceived, } = options expect($.variables).to.eql(variablesReceived) expect(query).to.include(responseQuery) expect(clientOptions).to.eql({ dataType: 'json' }) }) }) }) describe('forgeSubmit', function () { beforeEach(function () { sinon.stub(client, 'requestGraphQL') }) it('calls requestGraphQL with overridden mutation', async function () { const variables = { foo: 'bar' } const mutationOverride = 'forgeSubmitOverride()' await client.forgeSubmit({ variables, mutation: mutationOverride }) expect(client.requestGraphQL).to.have.been.calledOnce const [options, clientOptions] = client.requestGraphQL.lastCall.args const { query, variables: variablesReceived, } = options expect(variables).to.eql(variablesReceived) expect(query).to.include(mutationOverride) expect(clientOptions).to.eql({ dataType: 'json' }) }) it('calls requestGraphQL with default responseQuery', async function () { const variables = { foo: 'bar' } await client.forgeSubmit({ variables }) expect(client.requestGraphQL).to.have.been.calledOnce const [options, clientOptions] = client.requestGraphQL.lastCall.args const { query, variables: variablesReceived, } = options expect(variables).to.eql(variablesReceived) expect(query).to.include('signer {') expect(clientOptions).to.eql({ dataType: 'json' }) }) it('calls requestGraphQL with overridden responseQuery', async function () { const variables = { foo: 'bar' } const customResponseQuery = 'myCustomResponseQuery' await client.forgeSubmit({ variables, responseQuery: customResponseQuery }) expect(client.requestGraphQL).to.have.been.calledOnce const [options, clientOptions] = client.requestGraphQL.lastCall.args const { query, variables: variablesReceived, } = options expect(variables).to.eql(variablesReceived) expect(query).to.include(customResponseQuery) expect(clientOptions).to.eql({ dataType: 'json' }) }) }) describe('removeWeldData', function () { beforeEach(function () { sinon.stub(client, 'requestGraphQL') }) it('calls requestGraphQL with overridden mutation', async function () { const variables = { foo: 'bar' } const mutationOverride = 'removeWeldDataOverride()' await client.removeWeldData({ variables, mutation: mutationOverride }) expect(client.requestGraphQL).to.have.been.calledOnce const [options, clientOptions] = client.requestGraphQL.lastCall.args const { query, variables: variablesReceived, } = options expect(variables).to.eql(variablesReceived) expect(query).to.include(mutationOverride) expect(clientOptions).to.eql({ dataType: 'json' }) }) }) }) describe('prepareGraphQLFile', function () { it('works', function () { expect(() => Anvil.prepareGraphQLFile(Buffer.from('test')), ).to.throw('When passing a Buffer to prepareGraphQLFile, `options.filename` must be provided. If you think you can ignore this, please pass `options.ignoreFilenameValidation` as `true`.') let uploadWithOptions = Anvil.prepareGraphQLFile(Buffer.from('test'), { ignoreFilenameValidation: true }) expect(uploadWithOptions).to.be.ok expect(uploadWithOptions.streamLikeThing).to.be.ok expect(uploadWithOptions.formDataAppendOptions).to.eql({}) uploadWithOptions = Anvil.prepareGraphQLFile(Buffer.from('test'), { filename: 'test.pdf' }) expect(uploadWithOptions).to.be.ok expect(uploadWithOptions.streamLikeThing).to.be.ok expect(uploadWithOptions.formDataAppendOptions).to.include({ filename: 'test.pdf', }) }) }) })