blob: 01a41438bbabf429d07c5e6b87c34e5896d255c0 [file]
/**
* 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.
*/
/**
* Tests for SQL Lab immediate backend persistence functionality
*
* This file tests the addQueryEditorWithBackendSync function that
* immediately creates backend tabs when SqllabBackendPersistence is enabled,
* ensuring consistent state management and preventing race conditions
* between frontend tab creation and backend synchronization.
*
* Related to issue #34997: Prevents metadata update failures by ensuring
* tabs have valid backend IDs before users can modify them.
*/
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import { isFeatureEnabled } from '@superset-ui/core';
import * as actions from './sqlLab';
// Mock feature flags
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(),
}));
const mockStore = configureMockStore([thunk]);
describe('SQL Lab immediate backend persistence', () => {
beforeEach(() => {
fetchMock.reset();
});
afterEach(() => {
fetchMock.reset();
isFeatureEnabled.mockRestore();
});
describe('with backend persistence enabled', () => {
beforeEach(() => {
isFeatureEnabled.mockImplementation(
feature => feature === 'SQLLAB_BACKEND_PERSISTENCE',
);
});
test('creates backend tab immediately and returns tabViewId', async () => {
const store = mockStore({});
const queryEditor = { name: 'Test Tab', sql: 'SELECT 1' };
// Mock successful backend creation
fetchMock.post('glob:*/tabstateview/', { id: 42 });
const result = await store.dispatch(
actions.addQueryEditorWithBackendSync(queryEditor),
);
// Verify backend call was made
const postCalls = fetchMock
.calls()
.filter(
call =>
call[0].includes('/tabstateview/') && call[1].method === 'POST',
);
expect(postCalls).toHaveLength(1);
// Verify action was dispatched with backend ID
const addActions = store
.getActions()
.filter(action => action.type === 'ADD_QUERY_EDITOR');
expect(addActions).toHaveLength(1);
expect(addActions[0].queryEditor.tabViewId).toBe('42');
expect(addActions[0].queryEditor.inLocalStorage).toBe(false);
expect(addActions[0].queryEditor.loaded).toBe(true);
// Verify returned query editor
expect(result.tabViewId).toBe('42');
expect(result.inLocalStorage).toBe(false);
});
test('falls back to local storage on backend failure', async () => {
const store = mockStore({});
const queryEditor = { name: 'Test Tab', sql: 'SELECT 1' };
// Mock backend failure
fetchMock.post('glob:*/tabstateview/', { status: 500 });
const result = await store.dispatch(
actions.addQueryEditorWithBackendSync(queryEditor),
);
// Verify fallback action was dispatched
const actionsDispatched = store.getActions();
const addActions = actionsDispatched.filter(
action => action.type === 'ADD_QUERY_EDITOR',
);
const toastActions = actionsDispatched.filter(
action => action.type === 'ADD_TOAST',
);
expect(addActions).toHaveLength(1);
expect(addActions[0].queryEditor.tabViewId).toBeUndefined();
expect(addActions[0].queryEditor.inLocalStorage).toBe(true);
// Should have error toast
expect(toastActions).toHaveLength(1);
// Verify returned query editor (fallback)
expect(result.tabViewId).toBeUndefined();
expect(result.inLocalStorage).toBe(true);
});
test('SQL updates work immediately after tab creation', async () => {
const store = mockStore({
sqlLab: {
queryEditors: [],
unsavedQueryEditor: {},
},
});
// Mock backend tab creation
fetchMock.post('glob:*/tabstateview/', { id: 123 });
// Mock SQL update
fetchMock.put('glob:*/tabstateview/123', { status: 200 });
// Create tab with backend sync
const queryEditor = await store.dispatch(
actions.addQueryEditorWithBackendSync({
name: 'Test',
sql: 'SELECT 1',
}),
);
// Clear previous actions
store.clearActions();
// Update state for the SQL update call
const updatedStore = mockStore({
sqlLab: {
queryEditors: [queryEditor],
unsavedQueryEditor: {},
},
});
// Now SQL updates should work immediately
await updatedStore.dispatch(
actions.queryEditorSetAndSaveSql(queryEditor, 'SELECT 2'),
);
// Verify PUT call was made with correct integer ID
const putCalls = fetchMock
.calls()
.filter(
call =>
call[0].includes('/tabstateview/') && call[1].method === 'PUT',
);
expect(putCalls).toHaveLength(1);
expect(putCalls[0][0]).toContain('/tabstateview/123');
});
});
describe('with backend persistence disabled', () => {
beforeEach(() => {
isFeatureEnabled.mockImplementation(() => false);
});
test('creates local tab without backend call', async () => {
const store = mockStore({});
const queryEditor = { name: 'Test Tab', sql: 'SELECT 1' };
const result = await store.dispatch(
actions.addQueryEditorWithBackendSync(queryEditor),
);
// Verify no backend calls were made
const calls = fetchMock.calls();
expect(calls).toHaveLength(0);
// Verify local action was dispatched
const addActions = store
.getActions()
.filter(action => action.type === 'ADD_QUERY_EDITOR');
expect(addActions).toHaveLength(1);
expect(addActions[0].queryEditor.tabViewId).toBeUndefined();
expect(addActions[0].queryEditor.inLocalStorage).toBe(true);
// Verify returned query editor
expect(result.tabViewId).toBeUndefined();
expect(result.inLocalStorage).toBe(true);
});
});
describe('integration with addNewQueryEditor', () => {
test('addNewQueryEditor uses backend sync', async () => {
isFeatureEnabled.mockImplementation(
feature => feature === 'SQLLAB_BACKEND_PERSISTENCE',
);
const store = mockStore({
sqlLab: {
queryEditors: [],
unsavedQueryEditor: {},
tabHistory: [],
databases: {},
},
common: {
conf: {
SQLLAB_DEFAULT_DBID: 1,
DEFAULT_SQLLAB_LIMIT: 1000,
},
},
});
// Mock backend creation
fetchMock.post('glob:*/tabstateview/', { id: 999 });
await store.dispatch(actions.addNewQueryEditor());
// Verify backend call was made
const postCalls = fetchMock
.calls()
.filter(
call =>
call[0].includes('/tabstateview/') && call[1].method === 'POST',
);
expect(postCalls).toHaveLength(1);
// Verify resulting tab has backend ID
const addActions = store
.getActions()
.filter(action => action.type === 'ADD_QUERY_EDITOR');
expect(addActions).toHaveLength(1);
expect(addActions[0].queryEditor.tabViewId).toBe('999');
expect(addActions[0].queryEditor.inLocalStorage).toBe(false);
});
});
});