/** * 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 URI from 'urijs'; import fetchMock from 'fetch-mock'; import { FeatureFlag, SupersetClient, getChartMetadataRegistry, getChartBuildQueryRegistry, QueryFormData, JsonObject, AnnotationLayer, AnnotationType, AnnotationSourceType, AnnotationStyle, } from '@superset-ui/core'; import { LOG_EVENT } from 'src/logger/actions'; import * as exploreUtils from 'src/explore/exploreUtils'; import * as actions from 'src/components/Chart/chartAction'; import * as asyncEvent from 'src/middleware/asyncEvent'; import { handleChartDataResponse } from 'src/components/Chart/chartAction'; import * as dataMaskActions from 'src/dataMask/actions'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { initialState } from 'src/SqlLab/fixtures'; interface MockState { charts: { [key: string]: { latestQueryFormData?: { time_grain_sqla?: string; granularity_sqla?: string; }; queryController?: AbortController; }; }; common: { conf: { SUPERSET_WEBSERVER_TIMEOUT?: number; }; }; } const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); const mockGetState = (): MockState => ({ charts: { chartKey: { latestQueryFormData: { time_grain_sqla: 'P1D', granularity_sqla: 'Date', }, }, }, common: { conf: {}, }, }); jest.mock('@superset-ui/core', () => ({ ...jest.requireActual('@superset-ui/core'), getChartMetadataRegistry: jest.fn(), getChartBuildQueryRegistry: jest.fn(), })); const mockedGetChartMetadataRegistry = getChartMetadataRegistry as jest.MockedFunction< typeof getChartMetadataRegistry >; const mockedGetChartBuildQueryRegistry = getChartBuildQueryRegistry as jest.MockedFunction< typeof getChartBuildQueryRegistry >; // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('chart actions', () => { const MOCK_URL = '/mockURL'; let dispatch: jest.Mock; let getExploreUrlStub: jest.SpyInstance; let getChartDataUriStub: jest.SpyInstance; let buildV1ChartDataPayloadStub: jest.SpyInstance; let waitForAsyncDataStub: jest.SpyInstance; let fakeMetadata: { useLegacyApi?: boolean; viz_type?: string }; beforeAll(() => { fetchMock.get('glob:*api/v1/security/csrf_token/*', { result: '1234' }); }); const setupDefaultFetchMock = (): void => { fetchMock.post(`glob:*${MOCK_URL}*`, { json: {} }, { name: MOCK_URL }); }; beforeEach(() => { setupDefaultFetchMock(); }); afterEach(() => fetchMock.clearHistory().removeRoutes()); beforeEach(() => { dispatch = jest.fn(); getExploreUrlStub = jest .spyOn(exploreUtils, 'getExploreUrl') .mockImplementation(() => MOCK_URL); getChartDataUriStub = jest .spyOn(exploreUtils, 'getChartDataUri') .mockImplementation(({ qs }: { qs?: Record }) => URI(MOCK_URL).query(qs || {}), ); buildV1ChartDataPayloadStub = jest .spyOn(exploreUtils, 'buildV1ChartDataPayload') .mockResolvedValue({ some_param: 'fake query!', result_type: 'full', result_format: 'json', } as unknown as Awaited< ReturnType >); fakeMetadata = { useLegacyApi: true }; mockedGetChartMetadataRegistry.mockImplementation( () => ({ get: () => fakeMetadata, }) as unknown as ReturnType, ); mockedGetChartBuildQueryRegistry.mockImplementation( () => ({ get: () => () => ({ some_param: 'fake query!', result_type: 'full', result_format: 'json', }), }) as unknown as ReturnType, ); waitForAsyncDataStub = jest .spyOn(asyncEvent, 'waitForAsyncData') .mockImplementation((data: unknown) => Promise.resolve(data)); }); test('should drop stale success dispatches when a newer controller has replaced ours in state', async () => { const chartKey = 'stale_success_test'; const formData: Partial = { slice_id: 456, datasource: 'table__1', viz_type: 'table', }; // A controller belonging to a *newer* in-flight request, already stored // in state by the time this thunk's response resolves. const newerController = new AbortController(); const state: MockState = { charts: { [chartKey]: { queryController: newerController, }, }, common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 60, }, }, }; const getState = jest.fn(() => state); const dispatchMock = jest.fn(); const getChartDataRequestSpy = jest .spyOn(actions, 'getChartDataRequest') .mockResolvedValue({ response: { status: 200 } as Response, json: { result: [{ data: [{ stale: true }] }] }, }); const handleChartDataResponseSpy = jest .spyOn(actions, 'handleChartDataResponse') .mockResolvedValue([{ data: [{ stale: true }] }]); const updateDataMaskSpy = jest .spyOn(dataMaskActions, 'updateDataMask') .mockReturnValue({ type: 'UPDATE_DATA_MASK' } as ReturnType< typeof dataMaskActions.updateDataMask >); const getQuerySettingsStub = jest .spyOn(exploreUtils, 'getQuerySettings') .mockReturnValue([false, () => {}] as unknown as ReturnType< typeof exploreUtils.getQuerySettings >); try { const thunkAction = actions.exploreJSON( formData as QueryFormData, false, undefined, chartKey, ); await thunkAction( dispatchMock as unknown as actions.ChartThunkDispatch, getState as unknown as () => actions.RootState, undefined, ); // CHART_UPDATE_STARTED is fine (it ran before the gate), // but CHART_UPDATE_SUCCEEDED must NOT have fired with the stale data. const dispatchedTypes = dispatchMock.mock.calls.map( ([action]) => action?.type, ); expect(dispatchedTypes).toContain(actions.CHART_UPDATE_STARTED); expect(dispatchedTypes).not.toContain(actions.CHART_UPDATE_SUCCEEDED); } finally { getChartDataRequestSpy.mockRestore(); handleChartDataResponseSpy.mockRestore(); updateDataMaskSpy.mockRestore(); getQuerySettingsStub.mockRestore(); } }); test('should defer abort of previous controller to avoid Redux state mutation', async () => { jest.useFakeTimers(); const chartKey = 'defer_abort_test'; const formData: Partial = { slice_id: 123, datasource: 'table__1', viz_type: 'table', }; const oldController = new AbortController(); const abortSpy = jest.spyOn(oldController, 'abort'); const state: MockState = { charts: { [chartKey]: { queryController: oldController, }, }, common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 60, }, }, }; const getState = jest.fn(() => state); const dispatchMock = jest.fn(); const getChartDataRequestSpy = jest .spyOn(actions, 'getChartDataRequest') .mockResolvedValue({ response: { status: 200 } as Response, json: { result: [] }, }); const handleChartDataResponseSpy = jest .spyOn(actions, 'handleChartDataResponse') .mockResolvedValue([]); const updateDataMaskSpy = jest .spyOn(dataMaskActions, 'updateDataMask') .mockReturnValue({ type: 'UPDATE_DATA_MASK' } as ReturnType< typeof dataMaskActions.updateDataMask >); const getQuerySettingsStub = jest .spyOn(exploreUtils, 'getQuerySettings') .mockReturnValue([false, () => {}] as unknown as ReturnType< typeof exploreUtils.getQuerySettings >); try { const thunkAction = actions.exploreJSON( formData as QueryFormData, false, undefined, chartKey, ); const promise = thunkAction( dispatchMock as unknown as actions.ChartThunkDispatch, getState as unknown as () => actions.RootState, undefined, ); expect(abortSpy).not.toHaveBeenCalled(); expect(oldController.signal.aborted).toBe(false); jest.runOnlyPendingTimers(); expect(abortSpy).toHaveBeenCalledTimes(1); expect(oldController.signal.aborted).toBe(true); await promise; } finally { getChartDataRequestSpy.mockRestore(); handleChartDataResponseSpy.mockRestore(); updateDataMaskSpy.mockRestore(); getQuerySettingsStub.mockRestore(); abortSpy.mockRestore(); jest.useRealTimers(); } }); afterEach(() => { getExploreUrlStub.mockRestore(); getChartDataUriStub.mockRestore(); buildV1ChartDataPayloadStub.mockRestore(); fetchMock.clearHistory(); waitForAsyncDataStub.mockRestore(); ( global as unknown as { featureFlags: Record } ).featureFlags = { [FeatureFlag.GlobalAsyncQueries]: false, }; }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('v1 API', () => { beforeEach(() => { fakeMetadata = { viz_type: 'my_viz', useLegacyApi: false }; }); test('should query with the built query', async () => { const actionThunk = actions.postChartFormData( {} as QueryFormData, false, undefined, undefined, ); await actionThunk( dispatch as unknown as actions.ChartThunkDispatch, mockGetState as unknown as () => actions.RootState, undefined, ); expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1); expect(fetchMock.callHistory.calls(MOCK_URL)[0].options.body).toBe( JSON.stringify({ some_param: 'fake query!', result_type: 'full', result_format: 'json', }), ); expect(dispatch.mock.calls[0][0].type).toBe(actions.CHART_UPDATE_STARTED); }); test('should handle the bigint without regression', async () => { getChartDataUriStub.mockRestore(); const mockBigIntUrl = '/mock/chart/data/bigint'; const expectedBigNumber = '9223372036854775807'; fetchMock.post(mockBigIntUrl, `{ "value": ${expectedBigNumber} }`, { name: mockBigIntUrl, }); getChartDataUriStub = jest .spyOn(exploreUtils, 'getChartDataUri') .mockImplementation(() => URI(mockBigIntUrl)); const { json } = await actions.getChartDataRequest({ formData: fakeMetadata as QueryFormData, }); expect(fetchMock.callHistory.calls(mockBigIntUrl)).toHaveLength(1); expect((json as JsonObject).value.toString()).toEqual(expectedBigNumber); }); test('handleChartDataResponse should return result if GlobalAsyncQueries flag is disabled', async () => { const result = await handleChartDataResponse( { status: 200 } as Response, { result: [ 1, 2, 3, ] as unknown as actions.ChartDataRequestResponse['json']['result'], }, ); expect(result).toEqual([1, 2, 3]); }); test('handleChartDataResponse should handle responses when GlobalAsyncQueries flag is enabled and results are returned synchronously', async () => { ( global as unknown as { featureFlags: Record } ).featureFlags = { [FeatureFlag.GlobalAsyncQueries]: true, }; const result = await handleChartDataResponse( { status: 200 } as Response, { result: [ 1, 2, 3, ] as unknown as actions.ChartDataRequestResponse['json']['result'], }, ); expect(result).toEqual([1, 2, 3]); }); test('handleChartDataResponse should handle responses when GlobalAsyncQueries flag is enabled and query is running asynchronously', async () => { ( global as unknown as { featureFlags: Record } ).featureFlags = { [FeatureFlag.GlobalAsyncQueries]: true, }; const result = await handleChartDataResponse( { status: 202 } as Response, { result: [ 1, 2, 3, ] as unknown as actions.ChartDataRequestResponse['json']['result'], }, ); expect(result).toEqual([1, 2, 3]); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('legacy API', () => { beforeEach(() => { fakeMetadata = { useLegacyApi: true }; }); test('should dispatch CHART_UPDATE_STARTED action before the query', () => { const actionThunk = actions.postChartFormData({ viz_type: 'table', } as QueryFormData); return actionThunk( dispatch as unknown as actions.ChartThunkDispatch, mockGetState as unknown as () => actions.RootState, undefined, ).then(() => { // chart update, trigger query, update form data, success expect(dispatch.mock.calls.length).toBe(5); expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1); expect(dispatch.mock.calls[0][0].type).toBe( actions.CHART_UPDATE_STARTED, ); }); }); test('should dispatch TRIGGER_QUERY action with the query', () => { const actionThunk = actions.postChartFormData({ viz_type: 'table', } as QueryFormData); return actionThunk( dispatch as unknown as actions.ChartThunkDispatch, mockGetState as unknown as () => actions.RootState, undefined, ).then(() => { // chart update, trigger query, update form data, success expect(dispatch.mock.calls.length).toBe(5); expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1); expect(dispatch.mock.calls[1][0].type).toBe(actions.TRIGGER_QUERY); }); }); test('should dispatch UPDATE_QUERY_FORM_DATA action with the query', () => { const actionThunk = actions.postChartFormData({ viz_type: 'table', } as QueryFormData); return actionThunk( dispatch as unknown as actions.ChartThunkDispatch, mockGetState as unknown as () => actions.RootState, undefined, ).then(() => { // chart update, trigger query, update form data, success expect(dispatch.mock.calls.length).toBe(5); expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1); expect(dispatch.mock.calls[2][0].type).toBe( actions.UPDATE_QUERY_FORM_DATA, ); }); }); test('should dispatch logEvent async action', () => { const actionThunk = actions.postChartFormData({ viz_type: 'table', } as QueryFormData); return actionThunk( dispatch as unknown as actions.ChartThunkDispatch, mockGetState as unknown as () => actions.RootState, undefined, ).then(() => { // chart update, trigger query, update form data, success expect(dispatch.mock.calls.length).toBe(5); expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1); expect(typeof dispatch.mock.calls[3][0]).toBe('function'); dispatch.mock.calls[3][0](dispatch); expect(dispatch.mock.calls.length).toBe(6); expect(dispatch.mock.calls[5][0].type).toBe(LOG_EVENT); }); }); test('should dispatch CHART_UPDATE_SUCCEEDED action upon success', () => { // Pass a viz_type so getQuerySettings returns useLegacyApi from the mocked registry const actionThunk = actions.postChartFormData({ viz_type: 'table', } as QueryFormData); return actionThunk( dispatch as unknown as actions.ChartThunkDispatch, mockGetState as unknown as () => actions.RootState, undefined, ).then(() => { // chart update, trigger query, update form data, success expect(dispatch.mock.calls.length).toBe(5); expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1); expect(dispatch.mock.calls[4][0].type).toBe( actions.CHART_UPDATE_SUCCEEDED, ); }); }); test('should dispatch CHART_UPDATE_FAILED action upon query timeout', () => { const unresolvingPromise = new Promise(() => {}); fetchMock.removeRoute(MOCK_URL); fetchMock.post(MOCK_URL, () => unresolvingPromise, { name: MOCK_URL, }); const timeoutInSec = 1 / 1000; const actionThunk = actions.postChartFormData( {} as QueryFormData, false, timeoutInSec, ); return actionThunk( dispatch as unknown as actions.ChartThunkDispatch, mockGetState as unknown as () => actions.RootState, undefined, ).then(() => { // chart update, trigger query, update form data, fail expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1); expect(dispatch.mock.calls.length).toBe(5); expect(dispatch.mock.calls[4][0].type).toBe( actions.CHART_UPDATE_FAILED, ); fetchMock.removeRoute(MOCK_URL); setupDefaultFetchMock(); }); }); test('should dispatch CHART_UPDATE_FAILED action upon non-timeout non-abort failure', () => { fetchMock.removeRoute(MOCK_URL); fetchMock.post( MOCK_URL, { throws: { statusText: 'misc error' } }, { name: MOCK_URL }, ); const timeoutInSec = 100; // Set to a time that is longer than the time this will take to fail const actionThunk = actions.postChartFormData( {} as QueryFormData, false, timeoutInSec, ); return actionThunk( dispatch as unknown as actions.ChartThunkDispatch, mockGetState as unknown as () => actions.RootState, undefined, ).then(() => { // chart update, trigger query, update form data, fail expect(dispatch.mock.calls.length).toBe(5); const updateFailedAction = dispatch.mock.calls[4][0]; expect(updateFailedAction.type).toBe(actions.CHART_UPDATE_FAILED); expect(updateFailedAction.queriesResponse[0].error).toBe('misc error'); fetchMock.removeRoute(MOCK_URL); setupDefaultFetchMock(); }); }); test('should dispatch CHART_UPDATE_STOPPED action upon abort', () => { fetchMock.removeRoute(MOCK_URL); fetchMock.post( MOCK_URL, { throws: { name: 'AbortError' } }, { name: MOCK_URL }, ); const timeoutInSec = 100; const actionThunk = actions.postChartFormData( {} as QueryFormData, false, timeoutInSec, ); return actionThunk( dispatch as unknown as actions.ChartThunkDispatch, mockGetState as unknown as () => actions.RootState, undefined, ).then(() => { const types = dispatch.mock.calls .map((call: [{ type?: string }]) => call[0] && call[0].type) .filter(Boolean); expect(types).toContain(actions.CHART_UPDATE_STOPPED); expect(types).not.toContain(actions.CHART_UPDATE_FAILED); fetchMock.removeRoutes(); setupDefaultFetchMock(); }); }); test('should handle the bigint without regression', async () => { getExploreUrlStub.mockRestore(); const mockBigIntUrl = '/mock/chart/data/bigint'; const expectedBigNumber = '9223372036854775807'; fetchMock.post(mockBigIntUrl, `{ "value": ${expectedBigNumber} }`, { name: mockBigIntUrl, }); getExploreUrlStub = jest .spyOn(exploreUtils, 'getExploreUrl') .mockImplementation(() => mockBigIntUrl); // Need viz_type to trigger the mocked getChartMetadataRegistry for legacy API const { json } = await actions.getChartDataRequest({ formData: { ...fakeMetadata, viz_type: 'table' } as QueryFormData, }); expect(fetchMock.callHistory.calls(mockBigIntUrl)).toHaveLength(1); expect((json.result[0] as JsonObject).value.toString()).toEqual( expectedBigNumber, ); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('runAnnotationQuery', () => { const mockDispatch = jest.fn(); beforeEach(() => { jest.clearAllMocks(); }); test('should dispatch annotationQueryStarted and annotationQuerySuccess on successful query', async () => { const annotation = { name: 'Holidays', annotationType: AnnotationType.Event, sourceType: AnnotationSourceType.Native, color: null, opacity: undefined, style: AnnotationStyle.Solid, width: 1, showMarkers: false, hideLine: false, value: 1, overrides: { time_range: null, }, show: true, showLabel: false, titleColumn: '', descriptionColumns: [], timeColumn: '', intervalEndColumn: '', } as AnnotationLayer; const key = undefined; const postSpy = jest.spyOn(SupersetClient, 'post'); postSpy.mockImplementation( () => Promise.resolve({ json: { result: [] } }) as unknown as ReturnType< typeof SupersetClient.post >, ); const buildV1ChartDataPayloadSpy = jest.spyOn( exploreUtils, 'buildV1ChartDataPayload', ); const queryFunc = actions.runAnnotationQuery({ annotation, key }); await queryFunc( mockDispatch as unknown as actions.ChartThunkDispatch, mockGetState as unknown as () => actions.RootState, undefined, ); expect(buildV1ChartDataPayloadSpy).toHaveBeenCalledWith({ formData: { granularity: 'Date', granularity_sqla: 'Date', time_grain_sqla: 'P1D', }, force: false, resultFormat: 'json', resultType: 'full', }); }); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('chart actions timeout', () => { beforeEach(() => { jest.clearAllMocks(); }); test('should use the timeout from arguments when given', async () => { const postSpy = jest.spyOn(SupersetClient, 'post'); postSpy.mockImplementation( () => Promise.resolve({ json: { result: [] } }) as unknown as ReturnType< typeof SupersetClient.post >, ); const timeout = 10; // Set the timeout value here const formData: Partial = { datasource: 'table__1' }; // Set the formData here const key = 'chartKey'; // Set the chart key here const store = mockStore(initialState); await store.dispatch( actions.runAnnotationQuery({ annotation: { value: 'annotationValue', sourceType: AnnotationSourceType.Native, overrides: {}, } as unknown as AnnotationLayer, timeout, formData: formData as QueryFormData, key, // eslint-disable-next-line @typescript-eslint/no-explicit-any }) as any, ); const expectedPayload = { url: expect.any(String) as string, signal: expect.any(AbortSignal) as AbortSignal, timeout: timeout * 1000, headers: { 'Content-Type': 'application/json' }, jsonPayload: expect.any(Object) as JsonObject, }; expect(postSpy).toHaveBeenCalledWith(expectedPayload); }); test('should use the timeout from common.conf when not passed as an argument', async () => { const postSpy = jest.spyOn(SupersetClient, 'post'); postSpy.mockImplementation( () => Promise.resolve({ json: { result: [] } }) as unknown as ReturnType< typeof SupersetClient.post >, ); const formData: Partial = { datasource: 'table__1' }; // Set the formData here const key = 'chartKey'; // Set the chart key here const store = mockStore(initialState); await store.dispatch( actions.runAnnotationQuery({ annotation: { value: 'annotationValue', sourceType: AnnotationSourceType.Native, overrides: {}, } as unknown as AnnotationLayer, timeout: undefined, formData: formData as QueryFormData, key, // eslint-disable-next-line @typescript-eslint/no-explicit-any }) as any, ); const expectedPayload = { url: expect.any(String) as string, signal: expect.any(AbortSignal) as AbortSignal, timeout: ( initialState.common.conf as unknown as { SUPERSET_WEBSERVER_TIMEOUT: number; } ).SUPERSET_WEBSERVER_TIMEOUT * 1000, headers: { 'Content-Type': 'application/json' }, jsonPayload: expect.any(Object) as JsonObject, }; expect(postSpy).toHaveBeenCalledWith(expectedPayload); }); });