/** * 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 configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import type { ThunkDispatch } from 'redux-thunk'; import type { AnyAction } from 'redux'; import { waitFor } from 'spec/helpers/testing-library'; import * as actions from 'src/SqlLab/actions/sqlLab'; import type { QueryEditor, Table, SqlLabRootState } from 'src/SqlLab/types'; import { LOG_EVENT } from 'src/logger/actions'; import { defaultQueryEditor, query as queryFixture, initialState, queryId, } from 'src/SqlLab/fixtures'; import { SupersetClient, isFeatureEnabled } from '@superset-ui/core'; import { ADD_TOAST } from 'src/components/MessageToasts/actions'; import { EMPTY_STATE_QE_ID } from 'src/SqlLab/hooks/useQueryEditor'; import { ToastType } from '../../components/MessageToasts/types'; const isFeatureEnabledMock = isFeatureEnabled as unknown as jest.Mock; const query = { ...queryFixture, id: queryId } as any; // Cast fixture to satisfy SqlLabRootState for getState callbacks in thunk tests const typedInitialState = initialState as unknown as SqlLabRootState; type DispatchExts = ThunkDispatch; const middlewares = [thunk]; // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockStore = configureMockStore(middlewares); jest.mock('nanoid', () => ({ nanoid: () => 'abcd', })); afterAll(() => { jest.resetAllMocks(); }); jest.mock('@superset-ui/core', () => ({ ...jest.requireActual('@superset-ui/core'), isFeatureEnabled: jest.fn(), })); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('getUpToDateQuery', () => { test('should return the up to date query editor state', () => { const outOfUpdatedQueryEditor = { ...defaultQueryEditor, schema: null as unknown as string, sql: 'SELECT ...', }; const queryEditor = { ...defaultQueryEditor, sql: 'SELECT * FROM table', }; const state = { sqlLab: { queryEditors: [queryEditor], unsavedQueryEditor: {}, }, } as unknown as SqlLabRootState; expect(actions.getUpToDateQuery(state, outOfUpdatedQueryEditor)).toEqual( queryEditor, ); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('async actions', () => { const mockBigNumber = '9223372036854775807'; const queryEditor = { ...defaultQueryEditor, id: 'abcd', autorun: false, latestQueryId: null, sql: 'SELECT *\nFROM\nWHERE', name: 'Untitled Query 1', }; let dispatch: jest.Mock; const fetchQueryEndpoint = 'glob:*/api/v1/sqllab/results/*'; const runQueryEndpoint = 'glob:*/api/v1/sqllab/execute/'; beforeEach(() => { dispatch = jest.fn(); fetchMock.removeRoute(fetchQueryEndpoint); fetchMock.get( fetchQueryEndpoint, JSON.stringify({ data: mockBigNumber, query: { sqlEditorId: 'dfsadfs' }, }), { name: fetchQueryEndpoint }, ); fetchMock.removeRoute(runQueryEndpoint); fetchMock.post(runQueryEndpoint, `{ "data": ${mockBigNumber} }`, { name: runQueryEndpoint, }); }); afterEach(() => { fetchMock.clearHistory(); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('saveQuery', () => { const saveQueryEndpoint = 'glob:*/api/v1/saved_query/'; fetchMock.post(saveQueryEndpoint, { results: { json: {} } }); const makeRequest = () => { const request = actions.saveQuery(query, queryId); return request(dispatch, () => typedInitialState, undefined); }; test('posts to the correct url', () => { expect.assertions(1); const store = mockStore(initialState); return store.dispatch(actions.saveQuery(query, queryId)).then(() => { expect(fetchMock.callHistory.calls(saveQueryEndpoint)).toHaveLength(1); }); }); test('posts the correct query object', () => { const store = mockStore(initialState); return store.dispatch(actions.saveQuery(query, queryId)).then(() => { const call = fetchMock.callHistory.calls(saveQueryEndpoint)[0]; const formData = JSON.parse(call.options.body as string); const mappedQueryToServer = actions.convertQueryToServer(query); // The 'id' field is excluded from the POST payload since it's for new queries expect(formData.id).toBeUndefined(); Object.keys(mappedQueryToServer).forEach(key => { if (key !== 'id') { expect(formData[key]).toBeDefined(); } }); }); }); test('calls 3 dispatch actions', () => { expect.assertions(1); return makeRequest().then(() => { expect(dispatch.mock.calls.length).toBe(2); }); }); test('calls QUERY_EDITOR_SAVED after making a request', () => { expect.assertions(1); return makeRequest().then(() => { expect(dispatch.mock.calls[0][0].type).toBe(actions.QUERY_EDITOR_SAVED); }); }); test('onSave calls QUERY_EDITOR_SAVED and QUERY_EDITOR_SET_TITLE', () => { expect.assertions(1); const store = mockStore(initialState); const expectedActionTypes = [ actions.QUERY_EDITOR_SAVED, actions.QUERY_EDITOR_SET_TITLE, ]; return store.dispatch(actions.saveQuery(query, queryId)).then(() => { expect(store.getActions().map(a => a.type)).toEqual( expectedActionTypes, ); }); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('formatQuery', () => { const formatQueryEndpoint = 'glob:*/api/v1/sqllab/format_sql/'; const expectedSql = 'SELECT 1'; beforeEach(() => { fetchMock.removeRoute(formatQueryEndpoint); fetchMock.post( formatQueryEndpoint, { result: expectedSql }, { name: formatQueryEndpoint, }, ); }); test('posts to the correct url', async () => { const store = mockStore(initialState); store.dispatch(actions.formatQuery(query as unknown as QueryEditor)); await waitFor(() => expect(fetchMock.callHistory.calls(formatQueryEndpoint)).toHaveLength( 1, ), ); expect(store.getActions()[0].type).toBe(actions.QUERY_EDITOR_SET_SQL); expect(store.getActions()[0].sql).toBe(expectedSql); }); test('sends only sql in request body when no dbId or templateParams', async () => { const queryEditorWithoutExtras = { ...defaultQueryEditor, sql: 'SELECT * FROM table', dbId: null, templateParams: null, }; const state = { sqlLab: { queryEditors: [queryEditorWithoutExtras], unsavedQueryEditor: {}, }, }; const store = mockStore(state); store.dispatch( actions.formatQuery(queryEditorWithoutExtras as unknown as QueryEditor), ); await waitFor(() => expect(fetchMock.callHistory.calls(formatQueryEndpoint)).toHaveLength( 1, ), ); const call = fetchMock.callHistory.calls(formatQueryEndpoint)[0]; const body = JSON.parse(call.options.body as string); expect(body).toEqual({ sql: 'SELECT * FROM table' }); expect(body.database_id).toBeUndefined(); expect(body.template_params).toBeUndefined(); }); test('includes database_id in request when dbId is provided', async () => { const queryEditorWithDb = { ...defaultQueryEditor, sql: 'SELECT * FROM table', dbId: 5, templateParams: null, }; const state = { sqlLab: { queryEditors: [queryEditorWithDb], unsavedQueryEditor: {}, }, }; const store = mockStore(state); store.dispatch( actions.formatQuery(queryEditorWithDb as unknown as QueryEditor), ); await waitFor(() => expect(fetchMock.callHistory.calls(formatQueryEndpoint)).toHaveLength( 1, ), ); const call = fetchMock.callHistory.calls(formatQueryEndpoint)[0]; const body = JSON.parse(call.options.body as string); expect(body).toEqual({ sql: 'SELECT * FROM table', database_id: 5, }); }); test('includes template_params as string when provided as string', async () => { const queryEditorWithTemplateString = { ...defaultQueryEditor, sql: 'SELECT * FROM table WHERE id = {{ user_id }}', dbId: 5, templateParams: '{"user_id": 123}', }; const state = { sqlLab: { queryEditors: [queryEditorWithTemplateString], unsavedQueryEditor: {}, }, }; const store = mockStore(state); store.dispatch( actions.formatQuery( queryEditorWithTemplateString as unknown as QueryEditor, ), ); await waitFor(() => expect(fetchMock.callHistory.calls(formatQueryEndpoint)).toHaveLength( 1, ), ); const call = fetchMock.callHistory.calls(formatQueryEndpoint)[0]; const body = JSON.parse(call.options.body as string); expect(body).toEqual({ sql: 'SELECT * FROM table WHERE id = {{ user_id }}', database_id: 5, template_params: '{"user_id": 123}', }); }); test('stringifies template_params when provided as object', async () => { const queryEditorWithTemplateObject = { ...defaultQueryEditor, sql: 'SELECT * FROM table WHERE id = {{ user_id }}', dbId: 5, templateParams: { user_id: 123, status: 'active' }, }; const state = { sqlLab: { queryEditors: [queryEditorWithTemplateObject], unsavedQueryEditor: {}, }, }; const store = mockStore(state); store.dispatch( actions.formatQuery( queryEditorWithTemplateObject as unknown as QueryEditor, ), ); await waitFor(() => expect(fetchMock.callHistory.calls(formatQueryEndpoint)).toHaveLength( 1, ), ); const call = fetchMock.callHistory.calls(formatQueryEndpoint)[0]; const body = JSON.parse(call.options.body as string); expect(body).toEqual({ sql: 'SELECT * FROM table WHERE id = {{ user_id }}', database_id: 5, template_params: '{"user_id":123,"status":"active"}', }); }); test('dispatches QUERY_EDITOR_SET_SQL with formatted result', async () => { const formattedSql = 'SELECT\n *\nFROM\n table'; fetchMock.removeRoute(formatQueryEndpoint); fetchMock.route( formatQueryEndpoint, { result: formattedSql }, { name: formatQueryEndpoint }, ); const queryEditorToFormat = { ...defaultQueryEditor, sql: 'SELECT * FROM table', }; const state = { sqlLab: { queryEditors: [queryEditorToFormat], unsavedQueryEditor: {}, }, }; const store = mockStore(state); await store.dispatch(actions.formatQuery(queryEditorToFormat)); const dispatchedActions = store.getActions(); expect(dispatchedActions).toHaveLength(1); expect(dispatchedActions[0].type).toBe(actions.QUERY_EDITOR_SET_SQL); expect(dispatchedActions[0].sql).toBe(formattedSql); }); test('uses up-to-date query editor state from store', async () => { const outdatedQueryEditor = { ...defaultQueryEditor, sql: 'OLD SQL', dbId: 1, }; const upToDateQueryEditor = { ...defaultQueryEditor, sql: 'SELECT * FROM updated_table', dbId: 10, }; const state = { sqlLab: { queryEditors: [upToDateQueryEditor], unsavedQueryEditor: {}, }, }; const store = mockStore(state); // Pass outdated query editor, but expect the function to use the up-to-date one from store store.dispatch(actions.formatQuery(outdatedQueryEditor)); await waitFor(() => expect(fetchMock.callHistory.calls(formatQueryEndpoint)).toHaveLength( 1, ), ); const call = fetchMock.callHistory.calls(formatQueryEndpoint)[0]; const body = JSON.parse(call.options.body as string); expect(body.sql).toBe('SELECT * FROM updated_table'); expect(body.database_id).toBe(10); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('fetchQueryResults', () => { const makeRequest = () => { const store = mockStore(initialState); const request = actions.fetchQueryResults(query); return request(dispatch, store.getState, undefined); }; test('makes the fetch request', () => { expect.assertions(1); return makeRequest().then(() => { expect(fetchMock.callHistory.calls(fetchQueryEndpoint)).toHaveLength(1); }); }); test('calls requestQueryResults', () => { expect.assertions(1); return makeRequest().then(() => { expect(dispatch.mock.calls[0][0].type).toBe( actions.REQUEST_QUERY_RESULTS, ); }); }); /* oxlint-disable-next-line jest/no-disabled-tests */ test.skip('parses large number result without losing precision', () => makeRequest().then(() => { expect(fetchMock.callHistory.calls(fetchQueryEndpoint)).toHaveLength(1); expect(dispatch.mock.calls.length).toBe(2); expect( dispatch.mock.calls[1][ dispatch.mock.calls[1].length - 1 ].results.data.toString(), ).toBe(mockBigNumber); })); test('calls querySuccess on fetch success', () => { expect.assertions(1); const store = mockStore({}); const expectedActionTypes = [ actions.REQUEST_QUERY_RESULTS, actions.QUERY_SUCCESS, ]; return store.dispatch(actions.fetchQueryResults(query)).then(() => { expect(store.getActions().map(a => a.type)).toEqual( expectedActionTypes, ); }); }); test('calls queryFailed on fetch error', () => { expect.assertions(1); fetchMock.removeRoute(fetchQueryEndpoint); fetchMock.get( fetchQueryEndpoint, { throws: { message: 'error text' } }, { name: fetchQueryEndpoint }, ); const store = mockStore({}); const expectedActionTypes = [ actions.REQUEST_QUERY_RESULTS, actions.QUERY_FAILED, ]; return store.dispatch(actions.fetchQueryResults(query)).then(() => { expect(store.getActions().map(a => a.type)).toEqual( expectedActionTypes, ); }); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('runQuery without query params', () => { const makeRequest = () => { const request = actions.runQuery(query); return request(dispatch, () => typedInitialState, undefined); }; test('makes the fetch request', () => { expect.assertions(1); return makeRequest().then(() => { expect(fetchMock.callHistory.calls(runQueryEndpoint)).toHaveLength(1); }); }); test('calls startQuery', () => { expect.assertions(1); return makeRequest().then(() => { expect(dispatch.mock.calls[0][0].type).toBe(actions.START_QUERY); }); }); test('parses large number result without losing precision', () => makeRequest().then(() => { expect(fetchMock.callHistory.calls(runQueryEndpoint)).toHaveLength(1); expect(dispatch.mock.calls.length).toBe(2); expect( dispatch.mock.calls[1][ dispatch.mock.calls[1].length - 1 ].results.data.toString(), ).toBe(mockBigNumber); })); test('calls querySuccess on fetch success', () => { expect.assertions(1); const store = mockStore({}); const expectedActionTypes = [actions.START_QUERY, actions.QUERY_SUCCESS]; const { dispatch } = store; const request = actions.runQuery(query); return request(dispatch, () => typedInitialState, undefined).then(() => { expect(store.getActions().map(a => a.type)).toEqual( expectedActionTypes, ); }); }); test('calls queryFailed on fetch error and logs the error details', () => { expect.assertions(2); fetchMock.removeRoute(runQueryEndpoint); fetchMock.post( runQueryEndpoint, { throws: { message: 'error text', timeout: true, statusText: 'timeout', }, }, { name: runQueryEndpoint }, ); const store = mockStore({}); const expectedActionTypes = [ actions.START_QUERY, LOG_EVENT, actions.QUERY_FAILED, ]; const { dispatch } = store; const request = actions.runQuery(query); return request(dispatch, () => typedInitialState, undefined).then(() => { const actions = store.getActions(); expect(actions.map(a => a.type)).toEqual(expectedActionTypes); expect(actions[1].payload.eventData.issue_codes).toEqual([1000, 1001]); }); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('runQuery with query params', () => { let locationSpy: jest.SpyInstance; beforeAll(() => { const u = new URL('http://localhost/sqllab/?foo=bar'); locationSpy = jest.spyOn(window, 'location', 'get').mockReturnValue({ href: u.href, pathname: u.pathname, search: u.search, hash: u.hash, origin: u.origin, host: u.host, hostname: u.hostname, port: u.port, protocol: u.protocol, } as Location); }); afterAll(() => { locationSpy.mockRestore(); }); const makeRequest = () => { const request = actions.runQuery(query); return request(dispatch, () => typedInitialState, undefined); }; test('makes the fetch request', async () => { const runQueryEndpointWithParams = 'glob:*/api/v1/sqllab/execute/?foo=bar'; fetchMock.post( runQueryEndpointWithParams, `{ "data": ${mockBigNumber} }`, ); await makeRequest().then(() => { expect( fetchMock.callHistory.calls(runQueryEndpointWithParams), ).toHaveLength(1); }); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('reRunQuery', () => { test('creates new query with a new id', () => { const id = 'id'; const state = { sqlLab: { tabHistory: [id], queryEditors: [{ id, name: 'Dummy query editor' }], unsavedQueryEditor: {}, }, }; const store = mockStore(state); const request = actions.reRunQuery(query); request(store.dispatch, store.getState, undefined); expect(store.getActions()[0].query.id).toEqual('abcd'); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('postStopQuery', () => { const stopQueryEndpoint = 'glob:*/api/v1/query/stop'; fetchMock.post(stopQueryEndpoint, {}); const baseQuery = { ...query, id: 'test_foo', }; const makeRequest = () => { const request = actions.postStopQuery(baseQuery); return request(dispatch, () => typedInitialState, undefined); }; test('makes the fetch request', () => { expect.assertions(1); return makeRequest().then(() => { expect(fetchMock.callHistory.calls(stopQueryEndpoint)).toHaveLength(1); }); }); test('calls stopQuery', () => { expect.assertions(1); return makeRequest().then(() => { expect(dispatch.mock.calls[0][0].type).toBe(actions.STOP_QUERY); }); }); test('sends the correct data', () => { expect.assertions(1); return makeRequest().then(() => { const call = fetchMock.callHistory.calls(stopQueryEndpoint)[0]; const body = JSON.parse(call.options.body as string); expect(body.client_id).toBe(baseQuery.id); }); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('cloneQueryToNewTab', () => { test('creates new query editor', () => { expect.assertions(1); const id = 'id'; const state = { sqlLab: { tabHistory: [id], queryEditors: [{ id, name: 'out of updated title' }], unsavedQueryEditor: { id, name: 'Dummy query editor', }, }, }; const store = mockStore(state); const expectedActions = [ { type: actions.ADD_QUERY_EDITOR, queryEditor: { name: 'Copy of Dummy query editor', dbId: 1, catalog: query.catalog, schema: query.schema, autorun: true, sql: 'SELECT * FROM something', queryLimit: undefined, maxRow: undefined, id: 'abcd', immutableId: 'abcd', templateParams: undefined, inLocalStorage: true, loaded: true, }, }, ]; const request = actions.cloneQueryToNewTab(query, true); request(store.dispatch, store.getState, undefined); expect(store.getActions()).toEqual(expectedActions); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('popSavedQuery', () => { const supersetClientGetSpy = jest.spyOn(SupersetClient, 'get'); const store = mockStore({}); const mockSavedQueryApiResponse = { catalog: null, changed_by: { first_name: 'Superset', id: 1, last_name: 'Admin', }, changed_on: '2024-12-28T20:06:14.246743', changed_on_delta_humanized: '8 days ago', created_by: { first_name: 'Superset', id: 1, last_name: 'Admin', }, database: { database_name: 'examples', id: 2, }, description: 'A saved query description', id: 1, label: 'Query 1', schema: 'public', sql: 'SELECT * FROM channels', sql_tables: [ { catalog: null, schema: null, table: 'channels', }, ], template_parameters: null, }; const makeRequest = (id: string | number) => { const request = actions.popSavedQuery(String(id)); const { dispatch } = store; return request(dispatch, () => typedInitialState, undefined); }; beforeEach(() => { supersetClientGetSpy.mockClear(); store.clearActions(); }); afterAll(() => { supersetClientGetSpy.mockRestore(); }); test('calls API endpint with correct params', async () => { supersetClientGetSpy.mockResolvedValue({ json: { result: mockSavedQueryApiResponse }, } as any); await makeRequest(123); expect(supersetClientGetSpy).toHaveBeenCalledWith({ endpoint: '/api/v1/saved_query/123', }); }); test('dispatches addQueryEditor with correct params on successful API call', async () => { supersetClientGetSpy.mockResolvedValue({ json: { result: mockSavedQueryApiResponse }, } as any); const expectedParams = { name: 'Query 1', description: 'A saved query description', dbId: 2, catalog: null, schema: 'public', sql: 'SELECT * FROM channels', templateParams: null, remoteId: 1, }; await makeRequest(1); const addQueryEditorAction = store .getActions() .find(action => action.type === actions.ADD_QUERY_EDITOR); expect(addQueryEditorAction).toBeTruthy(); expect(addQueryEditorAction?.queryEditor).toEqual( expect.objectContaining(expectedParams), ); }); test('should dispatch addDangerToast on API error', async () => { supersetClientGetSpy.mockResolvedValue(new Error() as any); await makeRequest(1); const addToastAction = store .getActions() .find(action => action.type === ADD_TOAST); expect(addToastAction).toBeTruthy(); expect(addToastAction?.payload?.toastType).toBe(ToastType.Danger); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('addQueryEditor', () => { test('creates new query editor', () => { expect.assertions(1); const store = mockStore(initialState); const expectedActions = [ { type: actions.ADD_QUERY_EDITOR, queryEditor: { ...queryEditor, immutableId: 'abcd', inLocalStorage: true, loaded: true, }, }, ]; store.dispatch(actions.addQueryEditor(defaultQueryEditor)); expect(store.getActions()).toEqual(expectedActions); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('addNewQueryEditor', () => { test('creates new query editor with new tab name', () => { const store = mockStore({ ...initialState, sqlLab: { ...initialState.sqlLab, unsavedQueryEditor: { id: defaultQueryEditor.id, name: 'Untitled Query 6', }, }, }); const expectedActions = [ { type: actions.ADD_QUERY_EDITOR, queryEditor: { id: 'abcd', immutableId: 'abcd', sql: expect.stringContaining('SELECT ...'), name: `Untitled Query 7`, dbId: defaultQueryEditor.dbId, catalog: defaultQueryEditor.catalog, schema: defaultQueryEditor.schema, autorun: false, queryLimit: (defaultQueryEditor as any).queryLimit || initialState.common.conf.DEFAULT_SQLLAB_LIMIT, inLocalStorage: true, loaded: true, }, }, ]; const request = actions.addNewQueryEditor(); request(store.dispatch, store.getState, undefined); expect(store.getActions()).toEqual(expectedActions); }); test('creates a new query editor from the saved state in the empty tab', () => { const unsavedEmptyTabState = { id: EMPTY_STATE_QE_ID, dbId: 2, catalog: 'test_catalog', schema: 'test_schema', }; const store = mockStore({ ...initialState, sqlLab: { ...initialState.sqlLab, tabHistory: [EMPTY_STATE_QE_ID], unsavedQueryEditor: unsavedEmptyTabState, }, }); const expectedActions = [ { type: actions.ADD_QUERY_EDITOR, queryEditor: { id: 'abcd', immutableId: 'abcd', sql: expect.stringContaining('SELECT ...'), name: 'Untitled Query 4', dbId: unsavedEmptyTabState.dbId, catalog: unsavedEmptyTabState.catalog, schema: unsavedEmptyTabState.schema, inLocalStorage: true, autorun: false, queryLimit: initialState.common.conf.DEFAULT_SQLLAB_LIMIT, loaded: true, }, }, ]; const request = actions.addNewQueryEditor(); request(store.dispatch, store.getState, undefined); expect(store.getActions()).toEqual(expectedActions); }); }); }); test('set current query editor', () => { expect.assertions(1); const store = mockStore(initialState); const expectedActions = [ { type: actions.SET_ACTIVE_QUERY_EDITOR, queryEditor: defaultQueryEditor, }, ]; store.dispatch(actions.setActiveQueryEditor(defaultQueryEditor)); expect(store.getActions()).toEqual(expectedActions); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('swithQueryEditor', () => { test('switch to the next tab editor', () => { const store = mockStore(initialState); const expectedActions = [ { type: actions.SET_ACTIVE_QUERY_EDITOR, queryEditor: initialState.sqlLab.queryEditors[1], }, ]; store.dispatch(actions.switchQueryEditor()); expect(store.getActions()).toEqual(expectedActions); }); test('switch to the first tab editor once it reaches the rightmost tab', () => { const store = mockStore({ ...initialState, sqlLab: { ...initialState.sqlLab, tabHistory: [ initialState.sqlLab.queryEditors[ initialState.sqlLab.queryEditors.length - 1 ].id, ], }, }); const expectedActions = [ { type: actions.SET_ACTIVE_QUERY_EDITOR, queryEditor: initialState.sqlLab.queryEditors[0], }, ]; store.dispatch(actions.switchQueryEditor()); expect(store.getActions()).toEqual(expectedActions); }); test('switch to the previous tab editor', () => { const store = mockStore({ ...initialState, sqlLab: { ...initialState.sqlLab, tabHistory: [initialState.sqlLab.queryEditors[1].id], }, }); const expectedActions = [ { type: actions.SET_ACTIVE_QUERY_EDITOR, queryEditor: initialState.sqlLab.queryEditors[0], }, ]; store.dispatch(actions.switchQueryEditor(true)); expect(store.getActions()).toEqual(expectedActions); }); test('switch to the last tab editor once it reaches the leftmost tab', () => { const store = mockStore({ ...initialState, sqlLab: { ...initialState.sqlLab, tabHistory: [initialState.sqlLab.queryEditors[0].id], }, }); const expectedActions = [ { type: actions.SET_ACTIVE_QUERY_EDITOR, queryEditor: initialState.sqlLab.queryEditors[ initialState.sqlLab.queryEditors.length - 1 ], }, ]; store.dispatch(actions.switchQueryEditor(true)); expect(store.getActions()).toEqual(expectedActions); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('backend sync', () => { const updateTabStateEndpoint = 'glob:*/tabstateview/*'; fetchMock.put(updateTabStateEndpoint, {}); fetchMock.delete(updateTabStateEndpoint, {}); fetchMock.post(updateTabStateEndpoint, JSON.stringify({ id: 1 })); const updateTableSchemaEndpoint = 'glob:*/tableschemaview/*'; fetchMock.put(updateTableSchemaEndpoint, {}); fetchMock.delete(updateTableSchemaEndpoint, {}); fetchMock.post(updateTableSchemaEndpoint, JSON.stringify({ id: 1 })); const updateTableSchemaExpandedEndpoint = 'glob:**/tableschemaview/*/expanded'; fetchMock.post(updateTableSchemaExpandedEndpoint, {}); const getTableMetadataEndpoint = 'glob:**/api/v1/database/*/table_metadata/*'; fetchMock.get(getTableMetadataEndpoint, {}); const getExtraTableMetadataEndpoint = 'glob:**/api/v1/database/*/table_metadata/extra/*'; fetchMock.get(getExtraTableMetadataEndpoint, {}); beforeEach(() => { isFeatureEnabledMock.mockImplementation( (feature: string) => feature === 'SQLLAB_BACKEND_PERSISTENCE', ); }); afterEach(() => { isFeatureEnabledMock.mockRestore(); }); afterEach(() => fetchMock.clearHistory()); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('addQueryEditor', () => { test('creates the tab state in the local storage', () => { expect.assertions(2); const store = mockStore({}); const expectedActions = [ { type: actions.ADD_QUERY_EDITOR, queryEditor: { ...queryEditor, id: 'abcd', immutableId: 'abcd', loaded: true, inLocalStorage: true, }, }, ]; store.dispatch(actions.addQueryEditor(queryEditor)); expect(store.getActions()).toEqual(expectedActions); expect( fetchMock.callHistory.calls(updateTabStateEndpoint), ).toHaveLength(0); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('removeQueryEditor', () => { test('updates the tab state in the backend', () => { expect.assertions(1); const store = mockStore({}); const expectedActions = [ { type: actions.REMOVE_QUERY_EDITOR, queryEditor, }, ]; store.dispatch(actions.removeQueryEditor(queryEditor)); expect(store.getActions()).toEqual(expectedActions); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('queryEditorSetDb', () => { test('updates the tab state in the backend', () => { expect.assertions(1); const dbId = 42; const store = mockStore({}); const expectedActions = [ { type: actions.QUERY_EDITOR_SETDB, queryEditor, dbId, }, ]; store.dispatch(actions.queryEditorSetDb(queryEditor, dbId)); expect(store.getActions()).toEqual(expectedActions); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('queryEditorSetCatalog', () => { test('updates the tab state in the backend', () => { expect.assertions(1); const catalog = 'public'; const store = mockStore({}); const expectedActions = [ { type: actions.QUERY_EDITOR_SET_CATALOG, queryEditor, catalog, }, ]; store.dispatch(actions.queryEditorSetCatalog(queryEditor, catalog)); expect(store.getActions()).toEqual(expectedActions); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('queryEditorSetSchema', () => { test('updates the tab state in the backend', () => { expect.assertions(1); const schema = 'schema'; const store = mockStore({}); const expectedActions = [ { type: actions.QUERY_EDITOR_SET_SCHEMA, queryEditor, schema, }, ]; store.dispatch(actions.queryEditorSetSchema(queryEditor, schema)); expect(store.getActions()).toEqual(expectedActions); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('queryEditorSetAutorun', () => { test('updates the tab state in the backend', () => { expect.assertions(1); const autorun = true; const store = mockStore({}); const expectedActions = [ { type: actions.QUERY_EDITOR_SET_AUTORUN, queryEditor, autorun, }, ]; store.dispatch(actions.queryEditorSetAutorun(queryEditor, autorun)); expect(store.getActions()).toEqual(expectedActions); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('queryEditorSetTitle', () => { test('updates the tab state in the backend', () => { expect.assertions(1); const name = 'name'; const store = mockStore({}); const expectedActions = [ { type: actions.QUERY_EDITOR_SET_TITLE, queryEditor, name, }, ]; store.dispatch( actions.queryEditorSetTitle(queryEditor, name, queryEditor.id), ); expect(store.getActions()).toEqual(expectedActions); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('queryEditorSetAndSaveSql', () => { const sql = 'SELECT * '; const expectedActions = [ { type: actions.QUERY_EDITOR_SET_SQL, queryEditor, sql, }, ]; // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('with backend persistence flag on', () => { test('updates the tab state in the backend', () => { expect.assertions(2); const store = mockStore({ ...initialState, sqlLab: { ...initialState.sqlLab, queryEditors: [queryEditor], }, }); const request = actions.queryEditorSetAndSaveSql(queryEditor, sql); return request(store.dispatch, store.getState, undefined).then(() => { expect(store.getActions()).toEqual(expectedActions); expect( fetchMock.callHistory.calls(updateTabStateEndpoint), ).toHaveLength(1); }); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('with backend persistence flag off', () => { test('does not update the tab state in the backend', () => { isFeatureEnabledMock.mockImplementation( (feature: string) => !(feature === 'SQLLAB_BACKEND_PERSISTENCE'), ); const store = mockStore({ ...initialState, sqlLab: { ...initialState.sqlLab, queryEditors: [queryEditor], }, }); const request = actions.queryEditorSetAndSaveSql(queryEditor, sql); request(store.dispatch, store.getState, undefined); expect(store.getActions()).toEqual(expectedActions); expect( fetchMock.callHistory.calls(updateTabStateEndpoint), ).toHaveLength(0); isFeatureEnabledMock.mockRestore(); }); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('queryEditorSetQueryLimit', () => { test('updates the tab state in the backend', () => { expect.assertions(1); const queryLimit = 10; const store = mockStore({}); const expectedActions = [ { type: actions.QUERY_EDITOR_SET_QUERY_LIMIT, queryEditor, queryLimit, }, ]; store.dispatch( actions.queryEditorSetQueryLimit(queryEditor, queryLimit), ); expect(store.getActions()).toEqual(expectedActions); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('queryEditorSetTemplateParams', () => { test('updates the tab state in the backend', () => { expect.assertions(1); const templateParams = '{"foo": "bar"}'; const store = mockStore({}); const expectedActions = [ { type: actions.QUERY_EDITOR_SET_TEMPLATE_PARAMS, queryEditor, templateParams, }, ]; store.dispatch( actions.queryEditorSetTemplateParams(queryEditor, templateParams), ); expect(store.getActions()).toEqual(expectedActions); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('addTable', () => { test('dispatches table state from unsaved change', () => { const tableName = 'table'; const catalogName = null; const schemaName = 'schema'; const expectedDbId = 473892; const store = mockStore({ ...initialState, sqlLab: { ...initialState.sqlLab, unsavedQueryEditor: { id: query.id, dbId: expectedDbId, }, }, }); const request = actions.addTable( query, tableName, catalogName, schemaName, ); request(store.dispatch, store.getState, undefined); expect(store.getActions()[0]).toEqual( expect.objectContaining({ table: expect.objectContaining({ name: tableName, catalog: catalogName, schema: schemaName, dbId: expectedDbId, }), }), ); }); test('uses tabViewId when available', () => { const tableName = 'table'; const catalogName = null; const schemaName = 'schema'; const expectedDbId = 473892; const tabViewId = '123'; const queryWithTabViewId = { ...query, tabViewId }; const store = mockStore({ ...initialState, sqlLab: { ...initialState.sqlLab, unsavedQueryEditor: { id: query.id, dbId: expectedDbId, }, }, }); const request = actions.addTable( queryWithTabViewId, tableName, catalogName, schemaName, ); request(store.dispatch, store.getState, undefined); expect(store.getActions()[0]).toEqual( expect.objectContaining({ table: expect.objectContaining({ name: tableName, catalog: catalogName, schema: schemaName, dbId: expectedDbId, queryEditorId: tabViewId, // Should use tabViewId, not id }), }), ); }); test('falls back to id when tabViewId is not available', () => { const tableName = 'table'; const catalogName = null; const schemaName = 'schema'; const expectedDbId = 473892; const queryWithoutTabViewId = { ...query, tabViewId: undefined }; const store = mockStore({ ...initialState, sqlLab: { ...initialState.sqlLab, unsavedQueryEditor: { id: query.id, dbId: expectedDbId, }, }, }); const request = actions.addTable( queryWithoutTabViewId, tableName, catalogName, schemaName, ); request(store.dispatch, store.getState, undefined); expect(store.getActions()[0]).toEqual( expect.objectContaining({ table: expect.objectContaining({ name: tableName, catalog: catalogName, schema: schemaName, dbId: expectedDbId, queryEditorId: query.id, // Should use id when tabViewId is not available }), }), ); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('runTablePreviewQuery', () => { const results = { data: mockBigNumber, query: { sqlEditorId: 'null', dbId: 1 }, query_id: 'efgh', }; const tableName = 'table'; const catalogName = null; const schemaName = 'schema'; const store = mockStore({ ...initialState, sqlLab: { ...initialState.sqlLab, databases: { 1: { disable_data_preview: false }, }, }, }); beforeEach(() => { fetchMock.removeRoute(runQueryEndpoint); fetchMock.post(runQueryEndpoint, JSON.stringify(results), { name: runQueryEndpoint, }); }); afterEach(() => { store.clearActions(); fetchMock.clearHistory(); }); test('updates and runs data preview query when configured', () => { expect.assertions(3); const expectedActionTypes = [ actions.MERGE_TABLE, // addTable (data preview) actions.START_QUERY, // runQuery (data preview) actions.QUERY_SUCCESS, // querySuccess ]; const request = actions.runTablePreviewQuery({ dbId: 1, name: tableName, catalog: catalogName, schema: schemaName, }); return request(store.dispatch, store.getState, undefined).then(() => { expect(store.getActions().map(a => a.type)).toEqual( expectedActionTypes, ); expect(fetchMock.callHistory.calls(runQueryEndpoint)).toHaveLength(1); // tab state is not updated, since the query is a data preview expect( fetchMock.callHistory.calls(updateTabStateEndpoint), ).toHaveLength(0); }); }); test('runs data preview query only', () => { const expectedActionTypes = [ actions.START_QUERY, // runQuery (data preview) actions.QUERY_SUCCESS, // querySuccess ]; const request = actions.runTablePreviewQuery( { dbId: 1, name: tableName, catalog: catalogName, schema: schemaName, }, true, ); return request(store.dispatch, store.getState, undefined).then(() => { expect(store.getActions().map(a => a.type)).toEqual( expectedActionTypes, ); expect(fetchMock.callHistory.calls(runQueryEndpoint)).toHaveLength(1); // tab state is not updated, since the query is a data preview expect( fetchMock.callHistory.calls(updateTabStateEndpoint), ).toHaveLength(0); }); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('expandTable', () => { test('updates the table schema state in the backend when initialized', () => { expect.assertions(2); const table = { id: 1, initialized: true }; const store = mockStore({}); const expectedActions = [ { type: actions.EXPAND_TABLE, table, }, ]; return store .dispatch(actions.expandTable(table as unknown as Table)) .then(() => { expect(store.getActions()).toEqual(expectedActions); const expandedCalls = fetchMock.callHistory .calls() .filter( call => call.url && call.url.includes('/tableschemaview/') && call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(1); }); }); test('does not call backend when table is not initialized', () => { expect.assertions(2); const table = { id: 'yVJPtuSackF', initialized: false }; const store = mockStore({}); const expectedActions = [ { type: actions.EXPAND_TABLE, table, }, ]; return store .dispatch(actions.expandTable(table as unknown as Table)) .then(() => { expect(store.getActions()).toEqual(expectedActions); // Check all POST calls to find the expanded endpoint const expandedCalls = fetchMock.callHistory .calls() .filter( call => call.url && call.url.includes('/tableschemaview/') && call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(0); }); }); test('does not call backend when initialized is undefined', () => { expect.assertions(2); const table = { id: 'yVJPtuSackF' }; const store = mockStore({}); const expectedActions = [ { type: actions.EXPAND_TABLE, table, }, ]; return store .dispatch(actions.expandTable(table as unknown as Table)) .then(() => { expect(store.getActions()).toEqual(expectedActions); // Check all POST calls to find the expanded endpoint const expandedCalls = fetchMock.callHistory .calls() .filter( call => call.url && call.url.includes('/tableschemaview/') && call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(0); }); }); test('does not call backend when feature flag is off', () => { expect.assertions(2); isFeatureEnabledMock.mockImplementation( (feature: string) => !(feature === 'SQLLAB_BACKEND_PERSISTENCE'), ); const table = { id: 1, initialized: true }; const store = mockStore({}); const expectedActions = [ { type: actions.EXPAND_TABLE, table, }, ]; return store .dispatch(actions.expandTable(table as unknown as Table)) .then(() => { expect(store.getActions()).toEqual(expectedActions); // Check all POST calls to find the expanded endpoint const expandedCalls = fetchMock.callHistory .calls() .filter( call => call.url && call.url.includes('/tableschemaview/') && call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(0); isFeatureEnabledMock.mockRestore(); }); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('collapseTable', () => { test('updates the table schema state in the backend when initialized', () => { expect.assertions(2); const table = { id: 1, initialized: true }; const store = mockStore({}); const expectedActions = [ { type: actions.COLLAPSE_TABLE, table, }, ]; return store .dispatch(actions.collapseTable(table as unknown as Table)) .then(() => { expect(store.getActions()).toEqual(expectedActions); const expandedCalls = fetchMock.callHistory .calls() .filter( call => call.url && call.url.includes('/tableschemaview/') && call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(1); }); }); test('does not call backend when table is not initialized', () => { expect.assertions(2); const table = { id: 'yVJPtuSackF', initialized: false }; const store = mockStore({}); const expectedActions = [ { type: actions.COLLAPSE_TABLE, table, }, ]; return store .dispatch(actions.collapseTable(table as unknown as Table)) .then(() => { expect(store.getActions()).toEqual(expectedActions); const expandedCalls = fetchMock.callHistory .calls() .filter( call => call.url && call.url.includes('/tableschemaview/') && call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(0); }); }); test('does not call backend when initialized is undefined', () => { expect.assertions(2); const table = { id: 'yVJPtuSackF' }; const store = mockStore({}); const expectedActions = [ { type: actions.COLLAPSE_TABLE, table, }, ]; return store .dispatch(actions.collapseTable(table as unknown as Table)) .then(() => { expect(store.getActions()).toEqual(expectedActions); const expandedCalls = fetchMock.callHistory .calls() .filter( call => call.url && call.url.includes('/tableschemaview/') && call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(0); }); }); test('does not call backend when feature flag is off', () => { expect.assertions(2); isFeatureEnabledMock.mockImplementation( (feature: string) => !(feature === 'SQLLAB_BACKEND_PERSISTENCE'), ); const table = { id: 1, initialized: true }; const store = mockStore({}); const expectedActions = [ { type: actions.COLLAPSE_TABLE, table, }, ]; return store .dispatch(actions.collapseTable(table as unknown as Table)) .then(() => { expect(store.getActions()).toEqual(expectedActions); const expandedCalls = fetchMock.callHistory .calls() .filter( call => call.url && call.url.includes('/tableschemaview/') && call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(0); isFeatureEnabledMock.mockRestore(); }); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('removeTables', () => { test('updates the table schema state in the backend', () => { expect.assertions(2); const table = { id: 1, initialized: true }; const store = mockStore({}); const expectedActions = [ { type: actions.REMOVE_TABLES, tables: [table], }, ]; return store .dispatch(actions.removeTables([table] as unknown as Table[])) .then(() => { expect(store.getActions()).toEqual(expectedActions); expect( fetchMock.callHistory.calls(updateTableSchemaEndpoint), ).toHaveLength(1); }); }); test('deletes multiple tables and updates the table schema state in the backend', () => { expect.assertions(2); const tables = [ { id: 1, initialized: true }, { id: 2, initialized: true }, ]; const store = mockStore({}); const expectedActions = [ { type: actions.REMOVE_TABLES, tables, }, ]; return store .dispatch(actions.removeTables(tables as unknown as Table[])) .then(() => { expect(store.getActions()).toEqual(expectedActions); expect( fetchMock.callHistory.calls(updateTableSchemaEndpoint), ).toHaveLength(2); }); }); test('only updates the initialized table schema state in the backend', () => { expect.assertions(2); const tables = [{ id: 1 }, { id: 2, initialized: true }]; const store = mockStore({}); const expectedActions = [ { type: actions.REMOVE_TABLES, tables, }, ]; return store .dispatch(actions.removeTables(tables as unknown as Table[])) .then(() => { expect(store.getActions()).toEqual(expectedActions); expect( fetchMock.callHistory.calls(updateTableSchemaEndpoint), ).toHaveLength(1); }); }); }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('syncQueryEditor', () => { test('updates the tab state in the backend', () => { expect.assertions(3); const results = { data: mockBigNumber, query: { sqlEditorId: 'null' }, query_id: 'efgh', }; fetchMock.removeRoute(runQueryEndpoint); fetchMock.post(runQueryEndpoint, JSON.stringify(results), { name: runQueryEndpoint, }); const oldQueryEditor = { ...queryEditor, inLocalStorage: true }; const tables = [ { id: 'one', dataPreviewQueryId: 'previewOne', queryEditorId: oldQueryEditor.id, inLocalStorage: true, }, { id: 'two', dataPreviewQueryId: 'previewTwo', queryEditorId: oldQueryEditor.id, inLocalStorage: true, }, ]; const queries = [ { ...query, id: 'previewOne', sqlEditorId: null, isDataPreview: true, }, { ...query, id: 'previewTwo', sqlEditorId: null, isDataPreview: true, }, { ...query, id: 'runningQuery', sqlEditorId: oldQueryEditor.id, state: 'running', }, { ...query, id: 'unrelatedQuery', sqlEditorId: 'other-editor', state: 'running', }, ]; const store = mockStore({ sqlLab: { queries, tables, }, }); const expectedActions = [ { type: actions.MIGRATE_QUERY_EDITOR, oldQueryEditor, // new qe has a different id newQueryEditor: { ...oldQueryEditor, tabViewId: '1', inLocalStorage: false, loaded: true, }, }, { type: actions.MIGRATE_TABLE, oldTable: tables[0], // new table has a different id and points to new query editor newTable: { ...tables[0], id: 1, queryEditorId: '1' }, }, { type: actions.MIGRATE_TABLE, oldTable: tables[1], // new table has a different id and points to new query editor newTable: { ...tables[1], id: 1, queryEditorId: '1' }, }, { type: actions.MIGRATE_QUERY, queryId: 'runningQuery', queryEditorId: '1', }, ]; return store .dispatch(actions.syncQueryEditor(oldQueryEditor)) .then(() => { expect(store.getActions()).toEqual(expectedActions); expect( fetchMock.callHistory.calls(updateTabStateEndpoint), ).toHaveLength(2); // query editor has 2 tables loaded in the schema viewer expect( fetchMock.callHistory.calls(updateTableSchemaEndpoint), ).toHaveLength(2); }); }); }); }); });