UNPKG

@mcdevsl/mc-superset-ui

Version:
582 lines (504 loc) 20.9 kB
/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import fetchMock from 'fetch-mock'; import callApi from '@superset-ui/core/src/connection/callApi/callApi'; import * as constants from '@superset-ui/core/src/connection/constants'; import { CallApi, JsonObject } from '@superset-ui/core/src/connection/types'; import { DEFAULT_FETCH_RETRY_OPTIONS } from '@superset-ui/core/src/connection/constants'; import { LOGIN_GLOB } from '../fixtures/constants'; describe('callApi()', () => { beforeAll(() => { fetchMock.get(LOGIN_GLOB, { result: '1234' }); }); afterAll(fetchMock.restore); const mockGetUrl = '/mock/get/url'; const mockPostUrl = '/mock/post/url'; const mockPutUrl = '/mock/put/url'; const mockPatchUrl = '/mock/patch/url'; const mockCacheUrl = '/mock/cache/url'; const mockNotFound = '/mock/notfound'; const mockErrorUrl = '/mock/error/url'; const mock503 = '/mock/503'; const mockGetPayload = { get: 'payload' }; const mockPostPayload = { post: 'payload' }; const mockPutPayload = { post: 'payload' }; const mockPatchPayload = { post: 'payload' }; const mockCachePayload = { status: 200, body: 'BODY', headers: { Etag: 'etag' }, }; const mockErrorPayload = { status: 500, statusText: 'Internal error' }; fetchMock.get(mockGetUrl, mockGetPayload); fetchMock.post(mockPostUrl, mockPostPayload); fetchMock.put(mockPutUrl, mockPutPayload); fetchMock.patch(mockPatchUrl, mockPatchPayload); fetchMock.get(mockCacheUrl, mockCachePayload); fetchMock.get(mockNotFound, { status: 404 }); fetchMock.get(mock503, { status: 503 }); fetchMock.get(mockErrorUrl, () => Promise.reject(mockErrorPayload)); afterEach(fetchMock.reset); describe('request config', () => { it('calls the right url with the specified method', async () => { expect.assertions(4); await Promise.all([ callApi({ url: mockGetUrl, method: 'GET' }), callApi({ url: mockPostUrl, method: 'POST' }), callApi({ url: mockPutUrl, method: 'PUT' }), callApi({ url: mockPatchUrl, method: 'PATCH' }), ]); expect(fetchMock.calls(mockGetUrl)).toHaveLength(1); expect(fetchMock.calls(mockPostUrl)).toHaveLength(1); expect(fetchMock.calls(mockPutUrl)).toHaveLength(1); expect(fetchMock.calls(mockPatchUrl)).toHaveLength(1); }); it('passes along mode, cache, credentials, headers, body, signal, and redirect parameters in the request', async () => { expect.assertions(8); const mockRequest: CallApi = { url: mockGetUrl, mode: 'cors', cache: 'default', credentials: 'include', headers: { custom: 'header', }, redirect: 'follow', signal: undefined, body: 'BODY', }; await callApi(mockRequest); const calls = fetchMock.calls(mockGetUrl); const fetchParams = calls[0][1]; expect(calls).toHaveLength(1); expect(fetchParams.mode).toBe(mockRequest.mode); expect(fetchParams.cache).toBe(mockRequest.cache); expect(fetchParams.credentials).toBe(mockRequest.credentials); expect(fetchParams.headers).toEqual( expect.objectContaining(mockRequest.headers) as typeof fetchParams.headers, ); expect(fetchParams.redirect).toBe(mockRequest.redirect); expect(fetchParams.signal).toBe(mockRequest.signal); expect(fetchParams.body).toBe(mockRequest.body); }); }); describe('POST requests', () => { it('encodes key,value pairs from postPayload', async () => { expect.assertions(3); const postPayload = { key: 'value', anotherKey: 1237 }; await callApi({ url: mockPostUrl, method: 'POST', postPayload }); const calls = fetchMock.calls(mockPostUrl); expect(calls).toHaveLength(1); const fetchParams = calls[0][1]; const body = fetchParams.body as FormData; Object.entries(postPayload).forEach(([key, value]) => { expect(body.get(key)).toBe(JSON.stringify(value)); }); }); // the reason for this is to omit strings like 'undefined' from making their way to the backend it('omits key,value pairs from postPayload that have undefined values (POST)', async () => { expect.assertions(3); const postPayload = { key: 'value', noValue: undefined }; await callApi({ url: mockPostUrl, method: 'POST', postPayload }); const calls = fetchMock.calls(mockPostUrl); expect(calls).toHaveLength(1); const fetchParams = calls[0][1]; const body = fetchParams.body as FormData; expect(body.get('key')).toBe(JSON.stringify(postPayload.key)); expect(body.get('noValue')).toBeNull(); }); it('respects the stringify flag in POST requests', async () => { const postPayload = { string: 'value', number: 1237, array: [1, 2, 3], object: { a: 'a', 1: 1 }, null: null, emptyString: '', }; expect.assertions(1 + 3 * Object.keys(postPayload).length); await Promise.all([ callApi({ url: mockPostUrl, method: 'POST', postPayload }), callApi({ url: mockPostUrl, method: 'POST', postPayload, stringify: false }), callApi({ url: mockPostUrl, method: 'POST', jsonPayload: postPayload }), ]); const calls = fetchMock.calls(mockPostUrl); expect(calls).toHaveLength(3); const stringified = calls[0][1].body as FormData; const unstringified = calls[1][1].body as FormData; const jsonRequestBody = JSON.parse(calls[2][1].body as string) as JsonObject; Object.entries(postPayload).forEach(([key, value]) => { expect(stringified.get(key)).toBe(JSON.stringify(value)); expect(unstringified.get(key)).toBe(String(value)); expect(jsonRequestBody[key]).toEqual(value); }); }); }); describe('PUT requests', () => { it('encodes key,value pairs from postPayload', async () => { expect.assertions(3); const postPayload = { key: 'value', anotherKey: 1237 }; await callApi({ url: mockPutUrl, method: 'PUT', postPayload }); const calls = fetchMock.calls(mockPutUrl); expect(calls).toHaveLength(1); const fetchParams = calls[0][1]; const body = fetchParams.body as FormData; Object.entries(postPayload).forEach(([key, value]) => { expect(body.get(key)).toBe(JSON.stringify(value)); }); }); // the reason for this is to omit strings like 'undefined' from making their way to the backend it('omits key,value pairs from postPayload that have undefined values (PUT)', async () => { expect.assertions(3); const postPayload = { key: 'value', noValue: undefined }; await callApi({ url: mockPutUrl, method: 'PUT', postPayload }); const calls = fetchMock.calls(mockPutUrl); expect(calls).toHaveLength(1); const fetchParams = calls[0][1]; const body = fetchParams.body as FormData; expect(body.get('key')).toBe(JSON.stringify(postPayload.key)); expect(body.get('noValue')).toBeNull(); }); it('respects the stringify flag in PUT requests', async () => { const postPayload = { string: 'value', number: 1237, array: [1, 2, 3], object: { a: 'a', 1: 1 }, null: null, emptyString: '', }; expect.assertions(1 + 2 * Object.keys(postPayload).length); await Promise.all([ callApi({ url: mockPutUrl, method: 'PUT', postPayload }), callApi({ url: mockPutUrl, method: 'PUT', postPayload, stringify: false }), ]); const calls = fetchMock.calls(mockPutUrl); expect(calls).toHaveLength(2); const stringified = calls[0][1].body as FormData; const unstringified = calls[1][1].body as FormData; Object.entries(postPayload).forEach(([key, value]) => { expect(stringified.get(key)).toBe(JSON.stringify(value)); expect(unstringified.get(key)).toBe(String(value)); }); }); }); describe('PATCH requests', () => { it('encodes key,value pairs from postPayload', async () => { expect.assertions(3); const postPayload = { key: 'value', anotherKey: 1237 }; await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload }); const calls = fetchMock.calls(mockPatchUrl); expect(calls).toHaveLength(1); const fetchParams = calls[0][1]; const body = fetchParams.body as FormData; Object.entries(postPayload).forEach(([key, value]) => { expect(body.get(key)).toBe(JSON.stringify(value)); }); }); // the reason for this is to omit strings like 'undefined' from making their way to the backend it('omits key,value pairs from postPayload that have undefined values (PATCH)', async () => { expect.assertions(3); const postPayload = { key: 'value', noValue: undefined }; await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload }); const calls = fetchMock.calls(mockPatchUrl); expect(calls).toHaveLength(1); const fetchParams = calls[0][1]; const body = fetchParams.body as FormData; expect(body.get('key')).toBe(JSON.stringify(postPayload.key)); expect(body.get('noValue')).toBeNull(); }); it('respects the stringify flag in PATCH requests', async () => { const postPayload = { string: 'value', number: 1237, array: [1, 2, 3], object: { a: 'a', 1: 1 }, null: null, emptyString: '', }; expect.assertions(1 + 2 * Object.keys(postPayload).length); await Promise.all([ callApi({ url: mockPatchUrl, method: 'PATCH', postPayload }), callApi({ url: mockPatchUrl, method: 'PATCH', postPayload, stringify: false }), ]); const calls = fetchMock.calls(mockPatchUrl); expect(calls).toHaveLength(2); const stringified = calls[0][1].body as FormData; const unstringified = calls[1][1].body as FormData; Object.entries(postPayload).forEach(([key, value]) => { expect(stringified.get(key)).toBe(JSON.stringify(value)); expect(unstringified.get(key)).toBe(String(value)); }); }); }); describe('caching', () => { const origLocation = window.location; beforeAll(() => { Object.defineProperty(window, 'location', { value: {} }); }); afterAll(() => { Object.defineProperty(window, 'location', { value: origLocation }); }); beforeEach(async () => { window.location.protocol = 'https:'; await caches.delete(constants.CACHE_KEY); }); it('caches requests with ETags', async () => { expect.assertions(2); await callApi({ url: mockCacheUrl, method: 'GET' }); const calls = fetchMock.calls(mockCacheUrl); expect(calls).toHaveLength(1); const supersetCache = await caches.open(constants.CACHE_KEY); const cachedResponse = await supersetCache.match(mockCacheUrl); expect(cachedResponse).toBeDefined(); }); it('will not use cache when running off an insecure connection', async () => { expect.assertions(2); window.location.protocol = 'http:'; await callApi({ url: mockCacheUrl, method: 'GET' }); const calls = fetchMock.calls(mockCacheUrl); expect(calls).toHaveLength(1); const supersetCache = await caches.open(constants.CACHE_KEY); const cachedResponse = await supersetCache.match(mockCacheUrl); expect(cachedResponse).toBeUndefined(); }); it('works when the Cache API is disabled', async () => { expect.assertions(5); // eslint-disable-next-line no-import-assign Object.defineProperty(constants, 'CACHE_AVAILABLE', { value: false }); const firstResponse = await callApi({ url: mockCacheUrl, method: 'GET' }); const calls = fetchMock.calls(mockCacheUrl); expect(calls).toHaveLength(1); const firstBody = await firstResponse.text(); expect(firstBody).toEqual('BODY'); const secondResponse = await callApi({ url: mockCacheUrl, method: 'GET' }); const fetchParams = calls[1][1]; expect(calls).toHaveLength(2); // second call should not have If-None-Match header expect(fetchParams.headers).toBeUndefined(); const secondBody = await secondResponse.text(); expect(secondBody).toEqual('BODY'); // eslint-disable-next-line no-import-assign Object.defineProperty(constants, 'CACHE_AVAILABLE', { value: true }); }); it('sends known ETags in the If-None-Match header', async () => { expect.assertions(3); // first call sets the cache await callApi({ url: mockCacheUrl, method: 'GET' }); const calls = fetchMock.calls(mockCacheUrl); expect(calls).toHaveLength(1); // second call sends the Etag in the If-None-Match header await callApi({ url: mockCacheUrl, method: 'GET' }); const fetchParams = calls[1][1]; const headers = { 'If-None-Match': 'etag' }; expect(calls).toHaveLength(2); expect(fetchParams.headers).toEqual( expect.objectContaining(headers) as typeof fetchParams.headers, ); }); it('reuses cached responses on 304 status', async () => { expect.assertions(3); // first call sets the cache await callApi({ url: mockCacheUrl, method: 'GET' }); const calls = fetchMock.calls(mockCacheUrl); expect(calls).toHaveLength(1); // second call reuses the cached payload on a 304 const mockCachedPayload = { status: 304 }; fetchMock.get(mockCacheUrl, mockCachedPayload, { overwriteRoutes: true }); const secondResponse = await callApi({ url: mockCacheUrl, method: 'GET' }); expect(calls).toHaveLength(2); const secondBody = await secondResponse.text(); expect(secondBody).toEqual('BODY'); }); it('throws error when cache fails on 304', async () => { expect.assertions(2); // this should never happen, since a 304 is only returned if we have // the cached response and sent the If-None-Match header const mockUncachedUrl = '/mock/uncached/url'; const mockCachedPayload = { status: 304 }; let error; fetchMock.get(mockUncachedUrl, mockCachedPayload); try { await callApi({ url: mockUncachedUrl, method: 'GET' }); } catch (err) { error = err; } finally { const calls = fetchMock.calls(mockUncachedUrl); expect(calls).toHaveLength(1); expect((error as { message: string }).message).toEqual( 'Received 304 but no content is cached!', ); } }); it('returns original response if no Etag', async () => { expect.assertions(3); const url = mockGetUrl; const response = await callApi({ url, method: 'GET' }); const calls = fetchMock.calls(url); expect(calls).toHaveLength(1); expect(response.status).toEqual(200); const body = await response.json(); expect(body as typeof mockGetPayload).toEqual(mockGetPayload); }); it('returns original response if status not 304 or 200', async () => { expect.assertions(2); const url = mockNotFound; const response = await callApi({ url, method: 'GET' }); const calls = fetchMock.calls(url); expect(calls).toHaveLength(1); expect(response.status).toEqual(404); }); }); it('rejects after retrying thrice if the request throws', async () => { expect.assertions(3); let error; try { await callApi({ fetchRetryOptions: DEFAULT_FETCH_RETRY_OPTIONS, url: mockErrorUrl, method: 'GET', }); } catch (err) { error = err; } finally { const err = error as { status: number; statusText: string }; expect(fetchMock.calls(mockErrorUrl)).toHaveLength(4); expect(err.status).toBe(mockErrorPayload.status); expect(err.statusText).toBe(mockErrorPayload.statusText); } }); it('rejects without retries if the config is set to 0 retries', async () => { expect.assertions(3); let error; try { await callApi({ fetchRetryOptions: { retries: 0 }, url: mockErrorUrl, method: 'GET', }); } catch (err) { error = err as { status: number; statusText: string }; } finally { expect(fetchMock.calls(mockErrorUrl)).toHaveLength(1); expect(error?.status).toBe(mockErrorPayload.status); expect(error?.statusText).toBe(mockErrorPayload.statusText); } }); it('rejects after retrying thrice if the request returns a 503', async () => { expect.assertions(2); const url = mock503; const response = await callApi({ fetchRetryOptions: DEFAULT_FETCH_RETRY_OPTIONS, url, method: 'GET', }); const calls = fetchMock.calls(url); expect(calls).toHaveLength(4); expect(response.status).toEqual(503); }); it('invalid json for postPayload should thrown error', async () => { expect.assertions(2); let error; try { await callApi({ url: mockPostUrl, method: 'POST', postPayload: 'haha', }); } catch (err) { error = err; } finally { expect(error).toBeInstanceOf(Error); expect(error.message).toEqual('Invalid payload:\n\nhaha'); } }); it('should accept search params object', async () => { expect.assertions(3); window.location.href = 'http://localhost'; fetchMock.get(`glob:*/get-search*`, { yes: 'ok' }); const response = await callApi({ url: '/get-search', searchParams: { abc: 1, }, method: 'GET', }); const result = await response.json(); expect(response.status).toEqual(200); expect(result).toEqual({ yes: 'ok' }); expect(fetchMock.lastUrl()).toEqual(`http://localhost/get-search?abc=1`); }); it('should accept URLSearchParams', async () => { expect.assertions(2); window.location.href = 'http://localhost'; fetchMock.post(`glob:*/post-search*`, { yes: 'ok' }); await callApi({ url: '/post-search', searchParams: new URLSearchParams({ abc: '1', }), method: 'POST', jsonPayload: { request: 'ok' }, }); expect(fetchMock.lastUrl()).toEqual(`http://localhost/post-search?abc=1`); expect(fetchMock.lastOptions()).toEqual( expect.objectContaining({ body: JSON.stringify({ request: 'ok' }), }), ); }); it('should throw when both payloads provided', async () => { expect.assertions(1); fetchMock.post('/post-both-payload', {}); let error; try { await callApi({ url: '/post-both-payload', method: 'POST', postPayload: { a: 1 }, jsonPayload: '{}', }); } catch (err) { error = err; } finally { expect((error as Error).message).toContain('provide only one of jsonPayload or postPayload'); } }); it('should accept FormData as postPayload', async () => { expect.assertions(1); fetchMock.post('/post-formdata', {}); const payload = new FormData(); await callApi({ url: '/post-formdata', method: 'POST', postPayload: payload, }); expect(fetchMock.lastOptions().body).toBe(payload); }); it('should ignore "null" postPayload string', async () => { expect.assertions(1); fetchMock.post('/post-null-postpayload', {}); await callApi({ url: '/post-formdata', method: 'POST', postPayload: 'null', }); expect(fetchMock.lastOptions().body).toBeUndefined(); }); });