From 4b0d49751301c25e16b93b7ec32a413a41bea39a Mon Sep 17 00:00:00 2001 From: Joe Li Date: Mon, 2 Feb 2026 12:08:38 -0800 Subject: [PATCH] test: add new RTL and integration tests for DatasetList (#36681) Co-authored-by: Claude --- .../datasets/DuplicateDatasetModal.test.tsx | 282 +++ .../datasets/hooks/useDatasetLists.test.ts | 180 +- .../DatasetList/DatasetList.behavior.test.tsx | 493 ++++ .../DatasetList.integration.test.tsx | 252 ++ .../DatasetList/DatasetList.listview.test.tsx | 2215 +++++++++++++++++ .../DatasetList.permissions.test.tsx | 414 +++ .../pages/DatasetList/DatasetList.test.tsx | 567 +++++ .../DatasetList/DatasetList.testHelpers.tsx | 539 ++++ 8 files changed, 4854 insertions(+), 88 deletions(-) create mode 100644 superset-frontend/src/features/datasets/DuplicateDatasetModal.test.tsx create mode 100644 superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx create mode 100644 superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx create mode 100644 superset-frontend/src/pages/DatasetList/DatasetList.listview.test.tsx create mode 100644 superset-frontend/src/pages/DatasetList/DatasetList.permissions.test.tsx create mode 100644 superset-frontend/src/pages/DatasetList/DatasetList.test.tsx create mode 100644 superset-frontend/src/pages/DatasetList/DatasetList.testHelpers.tsx diff --git a/superset-frontend/src/features/datasets/DuplicateDatasetModal.test.tsx b/superset-frontend/src/features/datasets/DuplicateDatasetModal.test.tsx new file mode 100644 index 00000000000..849bf5f9ad9 --- /dev/null +++ b/superset-frontend/src/features/datasets/DuplicateDatasetModal.test.tsx @@ -0,0 +1,282 @@ +/** + * 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 { render, screen, waitFor } from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import { ThemeProvider, supersetTheme } from '@apache-superset/core'; +import DuplicateDatasetModal from './DuplicateDatasetModal'; + +// Test-only fixture type that includes all fields from API responses +// Matches VirtualDataset structure from DatasetList but defined locally for tests +interface VirtualDatasetFixture { + id: number; + table_name: string; + kind: string; + schema: string; + database: { + id: string; + database_name: string; + }; + owners: Array<{ first_name: string; last_name: string; id: number }>; + changed_by_name: string; + changed_by: string; + changed_on_delta_humanized: string; + explore_url: string; + extra: string; + sql: string | null; +} + +// Test fixture with extra/sql fields that exist in actual API responses +const mockDataset: VirtualDatasetFixture = { + id: 1, + table_name: 'original_dataset', + kind: 'virtual', + schema: 'public', + database: { + id: '1', + database_name: 'PostgreSQL', + }, + owners: [], + changed_by_name: 'Admin', + changed_by: 'Admin User', + changed_on_delta_humanized: '1 day ago', + explore_url: '/explore/?datasource=1__table', + extra: '{}', + sql: 'SELECT * FROM table', +}; + +const Wrapper = ({ + dataset, + onHide, + onDuplicate, +}: { + dataset: VirtualDatasetFixture | null; + onHide: jest.Mock; + onDuplicate: jest.Mock; +}) => ( + + + +); + +const renderModal = ( + dataset: VirtualDatasetFixture | null, + onHide: jest.Mock, + onDuplicate: jest.Mock, +) => + render( + , + ); + +test('modal opens when dataset is provided', async () => { + const onHide = jest.fn(); + const onDuplicate = jest.fn(); + + renderModal(mockDataset, onHide, onDuplicate); + + // Modal should be visible + expect(await screen.findByText('Duplicate dataset')).toBeInTheDocument(); + + // Input field should be present + expect(screen.getByTestId('duplicate-modal-input')).toBeInTheDocument(); + + // Duplicate button should be present + expect( + screen.getByRole('button', { name: /duplicate/i }), + ).toBeInTheDocument(); +}); + +test('modal does not open when dataset is null', () => { + const onHide = jest.fn(); + const onDuplicate = jest.fn(); + + renderModal(null, onHide, onDuplicate); + + // Modal should not be visible + expect(screen.queryByText('Duplicate dataset')).not.toBeInTheDocument(); +}); + +test('duplicate button disabled after clearing input', async () => { + const onHide = jest.fn(); + const onDuplicate = jest.fn(); + + renderModal(mockDataset, onHide, onDuplicate); + + const input = await screen.findByTestId('duplicate-modal-input'); + + // Type some text first + await userEvent.type(input, 'test'); + + // Then clear it + await userEvent.clear(input); + + // Duplicate button should now be disabled (empty input) + const duplicateButton = screen.getByRole('button', { name: /duplicate/i }); + expect(duplicateButton).toBeDisabled(); +}); + +test('duplicate button enabled when name is entered', async () => { + const onHide = jest.fn(); + const onDuplicate = jest.fn(); + + renderModal(mockDataset, onHide, onDuplicate); + + const input = await screen.findByTestId('duplicate-modal-input'); + + // Type a new name + await userEvent.type(input, 'new_dataset_copy'); + + // Duplicate button should now be enabled + const duplicateButton = await screen.findByRole('button', { + name: /duplicate/i, + }); + expect(duplicateButton).toBeEnabled(); +}); + +test('clicking Duplicate calls onDuplicate with new name', async () => { + const onHide = jest.fn(); + const onDuplicate = jest.fn(); + + renderModal(mockDataset, onHide, onDuplicate); + + const input = await screen.findByTestId('duplicate-modal-input'); + + // Type a new name + await userEvent.type(input, 'new_dataset_copy'); + + // Click Duplicate button + const duplicateButton = await screen.findByRole('button', { + name: /duplicate/i, + }); + await userEvent.click(duplicateButton); + + // onDuplicate should be called with the new name + await waitFor(() => { + expect(onDuplicate).toHaveBeenCalledWith('new_dataset_copy'); + }); +}); + +test('pressing Enter key triggers duplicate action', async () => { + const onHide = jest.fn(); + const onDuplicate = jest.fn(); + + renderModal(mockDataset, onHide, onDuplicate); + + const input = await screen.findByTestId('duplicate-modal-input'); + + // Clear any existing value and type new name with Enter at end + await userEvent.clear(input); + await userEvent.type(input, 'new_dataset_copy{enter}'); + + // onDuplicate should be called by onPressEnter handler + await waitFor(() => { + expect(onDuplicate).toHaveBeenCalledWith('new_dataset_copy'); + }); +}); + +test('modal closes when onHide is called', async () => { + const onHide = jest.fn(); + const onDuplicate = jest.fn(); + + const { rerender } = renderModal(mockDataset, onHide, onDuplicate); + + expect(await screen.findByText('Duplicate dataset')).toBeInTheDocument(); + + // Simulate closing the modal by setting dataset to null + rerender( + , + ); + + // Modal should no longer be visible (Ant Design keeps it in DOM but hides it) + await waitFor(() => { + expect(screen.queryByText('Duplicate dataset')).not.toBeVisible(); + }); +}); + +test('cancel button clears input and closes modal', async () => { + const onHide = jest.fn(); + const onDuplicate = jest.fn(); + + const { rerender } = renderModal(mockDataset, onHide, onDuplicate); + + const input = await screen.findByTestId('duplicate-modal-input'); + + // Type some text + await userEvent.type(input, 'test_name'); + + expect(input).toHaveValue('test_name'); + + // Click cancel button + const cancelButton = await screen.findByRole('button', { name: /cancel/i }); + await userEvent.click(cancelButton); + + // onHide should be called + expect(onHide).toHaveBeenCalled(); + + // Simulate closing the modal (parent sets dataset to null) + rerender( + , + ); + + // Modal should be hidden + await waitFor(() => { + expect(screen.queryByText('Duplicate dataset')).not.toBeVisible(); + }); + + // Reopen with same dataset - input should be cleared + rerender( + , + ); + + const reopenedInput = await screen.findByTestId('duplicate-modal-input'); + expect(reopenedInput).toHaveValue(''); +}); + +test('input field clears when new dataset is provided', async () => { + const onHide = jest.fn(); + const onDuplicate = jest.fn(); + + const { rerender } = renderModal(mockDataset, onHide, onDuplicate); + + const input = await screen.findByTestId('duplicate-modal-input'); + + // Type a name + await userEvent.type(input, 'old_name'); + + expect(input).toHaveValue('old_name'); + + // Switch to different dataset + const newDataset: VirtualDatasetFixture = { + ...mockDataset, + id: 2, + table_name: 'different_dataset', + }; + + rerender( + , + ); + + // Input should be cleared + await waitFor(() => { + expect(input).toHaveValue(''); + }); +}); diff --git a/superset-frontend/src/features/datasets/hooks/useDatasetLists.test.ts b/superset-frontend/src/features/datasets/hooks/useDatasetLists.test.ts index c1fbd772cf1..8ff86e95034 100644 --- a/superset-frontend/src/features/datasets/hooks/useDatasetLists.test.ts +++ b/superset-frontend/src/features/datasets/hooks/useDatasetLists.test.ts @@ -17,7 +17,8 @@ * under the License. */ import { renderHook } from '@testing-library/react-hooks'; -import { SupersetClient } from '@superset-ui/core'; +import { waitFor } from '@testing-library/dom'; +import { SupersetClient, JsonResponse } from '@superset-ui/core'; import rison from 'rison'; import useDatasetsList from './useDatasetLists'; @@ -52,15 +53,14 @@ test('useDatasetsList fetches first page of datasets successfully', async () => count: 2, result: mockDatasets, }, - } as any); + } as unknown as JsonResponse); - const { result, waitForNextUpdate } = renderHook(() => - useDatasetsList(mockDb, 'public'), - ); + const { result } = renderHook(() => useDatasetsList(mockDb, 'public')); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.datasets).toEqual(mockDatasets); + }); - expect(result.current.datasets).toEqual(mockDatasets); expect(result.current.datasetNames).toEqual(['table1', 'table2']); expect(getSpy).toHaveBeenCalledTimes(1); }); @@ -79,21 +79,20 @@ test('useDatasetsList fetches multiple pages (pagination) until count reached', count: 3, result: page1Data, }, - } as any) + } as unknown as JsonResponse) .mockResolvedValueOnce({ json: { count: 3, result: page2Data, }, - } as any); + } as unknown as JsonResponse); - const { result, waitForNextUpdate } = renderHook(() => - useDatasetsList(mockDb, 'public'), - ); + const { result } = renderHook(() => useDatasetsList(mockDb, 'public')); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.datasets).toEqual([...page1Data, ...page2Data]); + }); - expect(result.current.datasets).toEqual([...page1Data, ...page2Data]); expect(result.current.datasetNames).toEqual(['table1', 'table2', 'table3']); expect(getSpy).toHaveBeenCalledTimes(2); }); @@ -108,15 +107,18 @@ test('useDatasetsList extracts dataset names correctly', async () => { { id: 3, table_name: 'products' }, ], }, - } as any); + } as unknown as JsonResponse); - const { result, waitForNextUpdate } = renderHook(() => - useDatasetsList(mockDb, 'public'), - ); + const { result } = renderHook(() => useDatasetsList(mockDb, 'public')); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.datasetNames).toEqual([ + 'users', + 'orders', + 'products', + ]); + }); - expect(result.current.datasetNames).toEqual(['users', 'orders', 'products']); expect(getSpy).toHaveBeenCalledTimes(1); }); @@ -126,17 +128,16 @@ test('useDatasetsList handles API 500 error gracefully', async () => { .spyOn(SupersetClient, 'get') .mockRejectedValue(new Error('Internal Server Error')); - const { result, waitForNextUpdate } = renderHook(() => - useDatasetsList(mockDb, 'public'), - ); + const { result } = renderHook(() => useDatasetsList(mockDb, 'public')); - await waitForNextUpdate(); + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalledWith( + 'There was an error fetching dataset', + ); + }); expect(result.current.datasets).toEqual([]); expect(result.current.datasetNames).toEqual([]); - expect(mockAddDangerToast).toHaveBeenCalledWith( - 'There was an error fetching dataset', - ); // Should only be called once - error causes break expect(getSpy).toHaveBeenCalledTimes(1); }); @@ -147,17 +148,16 @@ test('useDatasetsList handles empty dataset response', async () => { count: 0, result: [], }, - } as any); + } as unknown as JsonResponse); - const { result, waitForNextUpdate } = renderHook(() => - useDatasetsList(mockDb, 'public'), - ); + const { result } = renderHook(() => useDatasetsList(mockDb, 'public')); - await waitForNextUpdate(); + await waitFor(() => { + expect(getSpy).toHaveBeenCalledTimes(1); + }); expect(result.current.datasets).toEqual([]); expect(result.current.datasetNames).toEqual([]); - expect(getSpy).toHaveBeenCalledTimes(1); }); test('useDatasetsList stops pagination when results reach count', async () => { @@ -172,21 +172,20 @@ test('useDatasetsList stops pagination when results reach count', async () => { { id: 2, table_name: 'table2' }, ], }, - } as any) + } as unknown as JsonResponse) .mockResolvedValueOnce({ json: { count: 2, result: [], // No more results }, - } as any); + } as unknown as JsonResponse); - const { result, waitForNextUpdate } = renderHook(() => - useDatasetsList(mockDb, 'public'), - ); + const { result } = renderHook(() => useDatasetsList(mockDb, 'public')); - await waitForNextUpdate(); + await waitFor(() => { + expect(result.current.datasets).toHaveLength(2); + }); - expect(result.current.datasets).toHaveLength(2); expect(result.current.datasetNames).toEqual(['table1', 'table2']); // Should stop after results.length >= count expect(getSpy).toHaveBeenCalledTimes(1); @@ -203,58 +202,60 @@ test('useDatasetsList resets datasets when schema changes', async () => { { id: 2, table_name: 'public_table2' }, ], }, - } as any) + } as unknown as JsonResponse) .mockResolvedValueOnce({ json: { count: 1, result: [{ id: 3, table_name: 'private_table1' }], }, - } as any); + } as unknown as JsonResponse); - const { result, waitForNextUpdate, rerender } = renderHook( + const { result, rerender } = renderHook( ({ db, schema }) => useDatasetsList(db, schema), { initialProps: { db: mockDb, schema: 'public' }, }, ); - await waitForNextUpdate(); - - expect(result.current.datasetNames).toEqual([ - 'public_table1', - 'public_table2', - ]); + await waitFor(() => { + expect(result.current.datasetNames).toEqual([ + 'public_table1', + 'public_table2', + ]); + }); // Change schema rerender({ db: mockDb, schema: 'private' }); - await waitForNextUpdate(); + await waitFor(() => { + // Should have new datasets from private schema + expect(result.current.datasetNames).toEqual(['private_table1']); + }); - // Should have new datasets from private schema - expect(result.current.datasetNames).toEqual(['private_table1']); expect(getSpy).toHaveBeenCalledTimes(2); }); test('useDatasetsList handles network timeout gracefully', async () => { // Mock timeout/abort error (status: 0) - const timeoutError = new Error('Network timeout'); - (timeoutError as any).status = 0; + const timeoutError = new Error('Network timeout') as Error & { + status: number; + }; + timeoutError.status = 0; const getSpy = jest .spyOn(SupersetClient, 'get') .mockRejectedValue(timeoutError); - const { result, waitForNextUpdate } = renderHook(() => - useDatasetsList(mockDb, 'public'), - ); + const { result } = renderHook(() => useDatasetsList(mockDb, 'public')); - await waitForNextUpdate(); + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalledWith( + 'There was an error fetching dataset', + ); + }); expect(result.current.datasets).toEqual([]); expect(result.current.datasetNames).toEqual([]); - expect(mockAddDangerToast).toHaveBeenCalledWith( - 'There was an error fetching dataset', - ); // Should only be called once - error causes break expect(getSpy).toHaveBeenCalledTimes(1); }); @@ -265,19 +266,18 @@ test('useDatasetsList breaks pagination loop on persistent API errors', async () .spyOn(SupersetClient, 'get') .mockRejectedValue(new Error('Persistent server error')); - const { result, waitForNextUpdate } = renderHook(() => - useDatasetsList(mockDb, 'public'), - ); + const { result } = renderHook(() => useDatasetsList(mockDb, 'public')); - await waitForNextUpdate(); + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalledWith( + 'There was an error fetching dataset', + ); + }); // Should only attempt once, then break (not infinite loop) expect(getSpy).toHaveBeenCalledTimes(1); expect(result.current.datasets).toEqual([]); expect(result.current.datasetNames).toEqual([]); - expect(mockAddDangerToast).toHaveBeenCalledWith( - 'There was an error fetching dataset', - ); expect(mockAddDangerToast).toHaveBeenCalledTimes(1); }); @@ -290,23 +290,22 @@ test('useDatasetsList handles error on second page gracefully', async () => { count: 3, // Indicates more data exists result: [{ id: 1, table_name: 'table1' }], }, - } as any) + } as unknown as JsonResponse) .mockRejectedValue(new Error('Second page error')); - const { result, waitForNextUpdate } = renderHook(() => - useDatasetsList(mockDb, 'public'), - ); + const { result } = renderHook(() => useDatasetsList(mockDb, 'public')); - await waitForNextUpdate(); + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalledWith( + 'There was an error fetching dataset', + ); + }); // Should have first page data, then stop on error expect(getSpy).toHaveBeenCalledTimes(2); expect(result.current.datasets).toHaveLength(1); expect(result.current.datasets[0].table_name).toBe('table1'); expect(result.current.datasetNames).toEqual(['table1']); - expect(mockAddDangerToast).toHaveBeenCalledWith( - 'There was an error fetching dataset', - ); expect(mockAddDangerToast).toHaveBeenCalledTimes(1); }); @@ -316,7 +315,7 @@ test('useDatasetsList skips fetching when schema is null or undefined', () => { // Test with null schema const { result: resultNull, rerender } = renderHook( ({ db, schema }) => useDatasetsList(db, schema), - { initialProps: { db: mockDb, schema: null as any } }, + { initialProps: { db: mockDb, schema: null as unknown as string } }, ); // Schema is null - should NOT call API @@ -325,7 +324,7 @@ test('useDatasetsList skips fetching when schema is null or undefined', () => { expect(resultNull.current.datasetNames).toEqual([]); // Change to undefined - still should NOT call API - rerender({ db: mockDb, schema: undefined as any }); + rerender({ db: mockDb, schema: undefined as unknown as string }); expect(getSpy).not.toHaveBeenCalled(); expect(resultNull.current.datasets).toEqual([]); expect(resultNull.current.datasetNames).toEqual([]); @@ -349,7 +348,7 @@ test('useDatasetsList skips fetching when db.id is undefined', () => { const dbWithoutId = { database_name: 'test_db', owners: [1] as [number], - } as any; + } as typeof mockDb; const { result } = renderHook(() => useDatasetsList(dbWithoutId, 'public')); @@ -362,16 +361,15 @@ test('useDatasetsList skips fetching when db.id is undefined', () => { test('useDatasetsList encodes schemas with spaces and special characters in endpoint URL', async () => { const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({ json: { count: 0, result: [] }, - } as any); + } as unknown as JsonResponse); - const { waitForNextUpdate } = renderHook(() => - useDatasetsList(mockDb, 'sales analytics'), - ); + renderHook(() => useDatasetsList(mockDb, 'sales analytics')); - await waitForNextUpdate(); + await waitFor(() => { + expect(getSpy).toHaveBeenCalledTimes(1); + }); // Verify API was called with encoded schema - expect(getSpy).toHaveBeenCalledTimes(1); const callArg = getSpy.mock.calls[0]?.[0]?.endpoint; expect(callArg).toBeDefined(); @@ -379,9 +377,15 @@ test('useDatasetsList encodes schemas with spaces and special characters in endp // Schema 'sales analytics' -> encodeURIComponent -> 'sales%20analytics' -> rison.encode_uri -> 'sales%2520analytics' expect(callArg).toContain('sales%2520analytics'); - // Decode rison to verify filter structure - const risonParam = callArg!.split('?q=')[1]; - const decoded = rison.decode(decodeURIComponent(risonParam)) as any; + // Decode rison to verify filter structure using URL parser + // searchParams.get() already URL-decodes, so pass directly to rison.decode + const risonParam = new URL(callArg!, 'http://localhost').searchParams.get( + 'q', + ); + expect(risonParam).toBeTruthy(); + const decoded = rison.decode(risonParam!) as { + filters: Array<{ col: string; opr: string; value: string }>; + }; // After rison decoding, the schema should be the encoded version (encodeURIComponent output) expect(decoded.filters[1]).toEqual({ diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx new file mode 100644 index 00000000000..af73876de11 --- /dev/null +++ b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx @@ -0,0 +1,493 @@ +/** + * 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 { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'fetch-mock'; +import rison from 'rison'; +import { ComponentType } from 'react'; +import { + setupMocks, + renderDatasetList, + waitForDatasetsPageReady, + mockAdminUser, + mockDatasets, + setupDeleteMocks, + mockRelatedCharts, + mockRelatedDashboards, + mockHandleResourceExport, + API_ENDPOINTS, +} from './DatasetList.testHelpers'; + +jest.mock('src/utils/export'); + +// Mock withToasts HOC to be a passthrough so we can spy on toast calls +jest.mock('src/components/MessageToasts/withToasts', () => ({ + __esModule: true, + default:

(Component: ComponentType

) => Component, +})); + +// Increase default timeout for tests that involve multiple async operations +jest.setTimeout(15000); + +beforeEach(() => { + setupMocks(); + jest.clearAllMocks(); +}); + +afterEach(async () => { + // Flush pending React state updates within act() to prevent warnings + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // Restore real timers in case a test threw early + jest.useRealTimers(); + + // Reset browser history state to prevent query params leaking between tests + window.history.replaceState({}, '', '/'); + + fetchMock.resetHistory(); + fetchMock.restore(); + jest.restoreAllMocks(); +}); + +test('typing in search updates the input value correctly', async () => { + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('search-filter-container')).toBeInTheDocument(); + }); + + const searchContainer = screen.getByTestId('search-filter-container'); + const searchInput = within(searchContainer).getByRole('textbox'); + + // Type search query + await userEvent.type(searchInput, 'sales'); + + // Verify input value is updated + await waitFor(() => { + expect(searchInput).toHaveValue('sales'); + }); +}); + +test('typing in search triggers debounced API call with search filter', async () => { + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('search-filter-container')).toBeInTheDocument(); + }); + + const searchContainer = screen.getByTestId('search-filter-container'); + const searchInput = within(searchContainer).getByRole('textbox'); + + // Record initial API calls + const initialCallCount = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + + // Type search query and submit with Enter to trigger the debounced fetch + await userEvent.type(searchInput, 'sales{enter}'); + + // Wait for debounced API call + await waitFor( + () => { + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + expect(calls.length).toBeGreaterThan(initialCallCount); + }, + { timeout: 5000 }, + ); + + // Verify the latest API call includes search filter in URL + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + const latestCall = calls[calls.length - 1]; + const url = latestCall[0] as string; + + // URL should contain filters parameter with search term + expect(url).toContain('filters'); + const risonPayload = new URL(url, 'http://localhost').searchParams.get('q'); + expect(risonPayload).toBeTruthy(); + // searchParams.get() already URL-decodes, so pass directly to rison.decode + const decoded = rison.decode(risonPayload!) as Record; + const filters = Array.isArray(decoded?.filters) ? decoded.filters : []; + const hasSalesFilter = filters.some( + (filter: Record) => + typeof filter?.value === 'string' && + filter.value.toLowerCase().includes('sales'), + ); + expect(hasSalesFilter).toBe(true); +}); + +test('500 error triggers danger toast with error message', async () => { + const addDangerToast = jest.fn(); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { + status: 500, + body: { message: 'Internal Server Error' }, + }, + { overwriteRoutes: true }, + ); + + // Pass toast spy directly via props to bypass withToasts HOC + renderDatasetList(mockAdminUser, { + addDangerToast, + addSuccessToast: jest.fn(), + }); + + // Verify component renders despite error + await waitForDatasetsPageReady(); + + // Verify danger toast called with error information + await waitFor( + () => { + expect(addDangerToast).toHaveBeenCalled(); + }, + { timeout: 5000 }, + ); + + // Verify toast message contains error keywords + expect(addDangerToast.mock.calls.length).toBeGreaterThan(0); + const toastMessage = String(addDangerToast.mock.calls[0][0]); + expect( + toastMessage.includes('error') || + toastMessage.includes('Error') || + toastMessage.includes('500') || + toastMessage.includes('Internal Server'), + ).toBe(true); +}); + +test('network timeout triggers danger toast', async () => { + const addDangerToast = jest.fn(); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { throws: new Error('Network timeout') }, + { overwriteRoutes: true }, + ); + + // Pass toast spy directly via props to bypass withToasts HOC + renderDatasetList(mockAdminUser, { + addDangerToast, + addSuccessToast: jest.fn(), + }); + + // Verify component renders despite error + await waitForDatasetsPageReady(); + + // Verify danger toast called with timeout message + await waitFor( + () => { + expect(addDangerToast).toHaveBeenCalled(); + }, + { timeout: 5000 }, + ); + + // Verify toast message contains timeout/network keywords + expect(addDangerToast.mock.calls.length).toBeGreaterThan(0); + const toastMessage = String(addDangerToast.mock.calls[0][0]); + expect( + toastMessage.includes('timeout') || + toastMessage.includes('Timeout') || + toastMessage.includes('network') || + toastMessage.includes('Network') || + toastMessage.includes('error'), + ).toBe(true); +}); + +test('clicking delete opens modal with related objects count', async () => { + const datasetToDelete = mockDatasets[0]; + + // Set up delete mocks + setupDeleteMocks(datasetToDelete.id); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [datasetToDelete], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + // Wait for dataset to render + await waitFor(() => { + expect(screen.getByText(datasetToDelete.table_name)).toBeInTheDocument(); + }); + + // Find and click delete button in the row + const table = screen.getByTestId('listview-table'); + const datasetRow = within(table) + .getAllByRole('row') + .find(row => within(row).queryByText(datasetToDelete.table_name)); + expect(datasetRow).toBeTruthy(); + await userEvent.hover(datasetRow!); + const deleteButton = within(datasetRow!).getByTestId('delete'); + + await userEvent.click(deleteButton); + + // Verify modal opens with related objects + const modal = await screen.findByRole('dialog'); + expect(modal).toBeInTheDocument(); + + // Check for related charts count + expect(modal).toHaveTextContent( + new RegExp(mockRelatedCharts.count.toString()), + ); + // Check for related dashboards count + expect(modal).toHaveTextContent( + new RegExp(mockRelatedDashboards.count.toString()), + ); +}); + +test('clicking export calls handleResourceExport with dataset ID', async () => { + const datasetToExport = mockDatasets[0]; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [datasetToExport], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(datasetToExport.table_name)).toBeInTheDocument(); + }); + + // Find and click export button + const table = screen.getByTestId('listview-table'); + const exportButton = await within(table).findByTestId('upload'); + + await userEvent.click(exportButton); + + // Verify export was called with correct ID + await waitFor(() => { + expect(mockHandleResourceExport).toHaveBeenCalledWith( + 'dataset', + [datasetToExport.id], + expect.any(Function), + ); + }); +}); + +test('clicking duplicate opens modal and submits duplicate request', async () => { + const datasetToDuplicate = { + ...mockDatasets[1], + kind: 'virtual', + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [datasetToDuplicate], count: 1 }, + { overwriteRoutes: true }, + ); + + fetchMock.post( + API_ENDPOINTS.DATASET_DUPLICATE, + { id: 999, table_name: 'Copy of Dataset' }, + { overwriteRoutes: true }, + ); + + const addSuccessToast = jest.fn(); + + renderDatasetList(mockAdminUser, { + addDangerToast: jest.fn(), + addSuccessToast, + }); + + await waitFor(() => { + expect(screen.getByText(datasetToDuplicate.table_name)).toBeInTheDocument(); + }); + + // Track initial dataset list API calls BEFORE duplicate action + const initialDatasetCallCount = fetchMock.calls( + API_ENDPOINTS.DATASETS, + ).length; + + const row = screen.getByText(datasetToDuplicate.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + const duplicateIcon = await within(row!).findByTestId('copy'); + const duplicateButton = duplicateIcon.closest( + '[role="button"]', + ) as HTMLElement | null; + expect(duplicateButton).toBeTruthy(); + + await userEvent.click(duplicateButton!); + + const modal = await screen.findByRole('dialog'); + const modalInput = within(modal).getByRole('textbox'); + await userEvent.clear(modalInput); + await userEvent.type(modalInput, 'Copy of Dataset'); + + const confirmButton = within(modal).getByRole('button', { + name: /duplicate/i, + }); + await userEvent.click(confirmButton); + + // Verify duplicate API was called with correct payload + await waitFor(() => { + const calls = fetchMock.calls(API_ENDPOINTS.DATASET_DUPLICATE); + expect(calls.length).toBeGreaterThan(0); + + // Verify POST body contains correct dataset info + const requestBody = JSON.parse(calls[0][1]?.body as string); + expect(requestBody.base_model_id).toBe(datasetToDuplicate.id); + expect(requestBody.table_name).toBe('Copy of Dataset'); + }); + + // Verify modal closes after successful duplicate + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + // Verify refreshData() is called (observable via new dataset list API call) + await waitFor( + () => { + const datasetCalls = fetchMock.calls(API_ENDPOINTS.DATASETS); + expect(datasetCalls.length).toBeGreaterThan(initialDatasetCallCount); + }, + { timeout: 3000 }, + ); + + // Note: Success toast feature not implemented (see index.tsx:718-721) + expect(addSuccessToast).not.toHaveBeenCalled(); +}); + +test('certified dataset shows badge and tooltip with certification details', async () => { + const certifiedDataset = { + ...mockDatasets[1], + extra: JSON.stringify({ + certification: { + certified_by: 'Data Team', + details: 'Approved for production use', + }, + }), + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [certifiedDataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(certifiedDataset.table_name)).toBeInTheDocument(); + }); + + // Verify the row renders with the dataset + const row = screen.getByText(certifiedDataset.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + // Find certification badge within the row (fail-fast if not found) + const certBadge = await within(row!).findByRole('img', { + name: /certified/i, + }); + expect(certBadge).toBeInTheDocument(); + + // Hover to reveal tooltip + await userEvent.hover(certBadge); + + // Wait for tooltip content to appear + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent(/Data Team/i); + expect(tooltip).toHaveTextContent(/Approved for production/i); +}); + +test('dataset with warning shows icon and tooltip with markdown content', async () => { + const warningMessage = 'This dataset contains PII. Handle with care.'; + const datasetWithWarning = { + ...mockDatasets[2], + extra: JSON.stringify({ + warning_markdown: warningMessage, + }), + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [datasetWithWarning], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(datasetWithWarning.table_name)).toBeInTheDocument(); + }); + + // Verify row exists + const row = screen.getByText(datasetWithWarning.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + // Find warning icon within the row (fail-fast if not found) + const warningIcon = await within(row!).findByRole('img', { + name: /warning/i, + }); + expect(warningIcon).toBeInTheDocument(); + + // Hover to reveal tooltip with markdown content + await userEvent.hover(warningIcon); + + // Wait for tooltip to appear with warning text + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent(/PII/i); + expect(tooltip).toHaveTextContent(/Handle with care/i); +}); + +test('dataset name links to Explore with correct URL and accessible label', async () => { + const dataset = mockDatasets[0]; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + // Find the dataset row and scope the link query to it + const row = screen.getByText(dataset.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + // Find the internal link within the dataset row (fail-fast if not found) + const exploreLink = within(row!).getByTestId('internal-link'); + expect(exploreLink).toBeInTheDocument(); + + // Verify link has correct href to Explore page + expect(exploreLink).toHaveAttribute('href', dataset.explore_url); + expect(exploreLink).toHaveAttribute( + 'href', + expect.stringContaining('/explore/'), + ); + + // Verify link contains dataset ID + expect(exploreLink).toHaveAttribute( + 'href', + expect.stringContaining(`${dataset.id}__table`), + ); +}); + +// Note: Component "+1" tests for state persistence through operations have been +// moved to DatasetList.listview.test.tsx where they can use the reliable selectOption helper. diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx new file mode 100644 index 00000000000..0b228509b2a --- /dev/null +++ b/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx @@ -0,0 +1,252 @@ +/** + * 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 { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'fetch-mock'; +import rison from 'rison'; +import { selectOption } from 'spec/helpers/testing-library'; +import { + setupMocks, + renderDatasetList, + mockAdminUser, + mockDatasets, + setupBulkDeleteMocks, + API_ENDPOINTS, +} from './DatasetList.testHelpers'; + +/** + * Integration Contract Tests + * + * These tests verify multi-component orchestration that cannot be tested + * in component isolation. Unlike component tests which mock all dependencies, + * integration tests use real Redux/React Query/Router state management. + * + * Only 2 tests are needed here - most workflows are covered by component "+1" tests. + */ + +jest.mock('src/utils/export'); + +// Increase default timeout for tests that involve multiple async operations +jest.setTimeout(15000); + +beforeEach(() => { + setupMocks(); + jest.clearAllMocks(); +}); + +afterEach(async () => { + // Flush pending React state updates within act() to prevent warnings + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // Restore real timers in case a test threw early + jest.useRealTimers(); + + // Reset browser history state to prevent query params leaking between tests + window.history.replaceState({}, '', '/'); + + fetchMock.resetHistory(); + fetchMock.restore(); + jest.restoreAllMocks(); +}); + +test('ListView provider correctly merges filter + sort + pagination state on refetch', async () => { + // This test verifies that when multiple state sources are combined, + // the ListView provider correctly merges them for the API call. + // Component tests verify individual pieces persist; this verifies they COMBINE correctly. + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: mockDatasets, count: mockDatasets.length }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // 1. Apply a sort by clicking Name header + const table = screen.getByTestId('listview-table'); + const nameHeader = within(table).getByRole('columnheader', { + name: /Name/i, + }); + + const callsBeforeSort = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + await userEvent.click(nameHeader); + + // Wait for sort-triggered refetch to complete before applying filter + await waitFor(() => { + expect(fetchMock.calls(API_ENDPOINTS.DATASETS).length).toBeGreaterThan( + callsBeforeSort, + ); + }); + + // 2. Apply a filter using selectOption helper + const beforeFilterCallCount = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + await selectOption('Virtual', 'Type'); + + // Wait for filter API call to complete + await waitFor(() => { + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + expect(calls.length).toBeGreaterThan(beforeFilterCallCount); + }); + + // 3. Verify the final API call contains ALL three state pieces merged correctly + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + const latestCall = calls[calls.length - 1]; + const url = latestCall[0] as string; + + // Decode the rison payload using URL parser + const risonPayload = new URL(url, 'http://localhost').searchParams.get('q'); + expect(risonPayload).toBeTruthy(); + // searchParams.get() already URL-decodes, so pass directly to rison.decode + const decoded = rison.decode(risonPayload!) as Record; + + // Verify ALL three pieces of state are present and merged: + // 1. Sort (order_column) + expect(decoded?.order_column).toBeTruthy(); + + // 2. Filter (filters array) + const filters = Array.isArray(decoded?.filters) ? decoded.filters : []; + const hasTypeFilter = filters.some( + (filter: Record) => + filter?.col === 'sql' && filter?.value === false, + ); + expect(hasTypeFilter).toBe(true); + + // 3. Pagination (page_size is present with default value) + expect(decoded?.page_size).toBeTruthy(); + + // This confirms ListView provider merges state from multiple sources correctly +}, 30000); + +test('bulk action orchestration: selection → action → cleanup cycle works correctly', async () => { + // This test verifies the full bulk operation cycle across multiple components: + // 1. Bulk mode UI (selection state) + // 2. Bulk action handler (delete operation) + // 3. Selection cleanup (state reset) + + setupBulkDeleteMocks(); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: mockDatasets, count: mockDatasets.length }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // 1. Enter bulk mode and select items + const bulkSelectButton = screen.getByRole('button', { + name: /bulk select/i, + }); + await userEvent.click(bulkSelectButton); + + // Wait for bulk select controls container to appear first (fast query) + const bulkSelectControls = await screen.findByTestId('bulk-select-controls'); + + // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll) + const table = screen.getByTestId('listview-table'); + await within(table).findAllByRole('checkbox'); + + // Select first dataset by name (scoped to table, async to avoid race) + const firstCell = await within(table).findByText(mockDatasets[0].table_name); + const firstRow = firstCell.closest('tr'); + expect(firstRow).toBeInTheDocument(); + await userEvent.click(within(firstRow!).getByRole('checkbox')); + + // Wait for first selection to register before clicking second (prevents stale node) + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + /1 Selected/i, + ); + }); + + // Select second dataset (scoped to table, async to avoid race) + const secondCell = await within(table).findByText(mockDatasets[1].table_name); + const secondRow = secondCell.closest('tr'); + expect(secondRow).toBeInTheDocument(); + await userEvent.click(within(secondRow!).getByRole('checkbox')); + + // Wait for both selections to register + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + /2 Selected/i, + ); + }); + + // 2. Execute bulk delete - scoped to toolbar to avoid row delete buttons + const bulkDeleteButton = await within(bulkSelectControls).findByRole( + 'button', + { name: 'Delete' }, + ); + await userEvent.click(bulkDeleteButton); + + // Confirm in modal - verify by stable anchor (delete-modal-input is unique to delete modals) + const modal = await screen.findByRole('dialog'); + const confirmInput = within(modal).getByTestId('delete-modal-input'); + expect(confirmInput).toBeInTheDocument(); + await userEvent.clear(confirmInput); + await userEvent.type(confirmInput, 'DELETE'); + + // Capture datasets call count before confirming + const datasetsCallCountBeforeDelete = fetchMock.calls( + API_ENDPOINTS.DATASETS, + ).length; + + const confirmButton = within(modal) + .getAllByRole('button', { name: /^delete$/i }) + .pop(); + await userEvent.click(confirmButton!); + + // 3. Wait for bulk delete API call to be made + await waitFor(() => { + const deleteCalls = fetchMock.calls(API_ENDPOINTS.DATASET_BULK_DELETE); + expect(deleteCalls.length).toBeGreaterThan(0); + }); + + // Wait for modal to close + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + // Wait for datasets refetch after delete + await waitFor(() => { + const datasetsCallCount = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + expect(datasetsCallCount).toBeGreaterThan(datasetsCallCountBeforeDelete); + }); + + // 4. Verify selection count shows 0 (selections cleared but still in bulk mode) + // After bulk delete, items are deselected but bulk mode may remain active + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + /0 selected/i, + ); + }); + + // This confirms the full bulk operation cycle coordinates correctly: + // selection state → action handler → list refresh → state cleanup +}, 45000); diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.listview.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.listview.test.tsx new file mode 100644 index 00000000000..b43f78dc9c4 --- /dev/null +++ b/superset-frontend/src/pages/DatasetList/DatasetList.listview.test.tsx @@ -0,0 +1,2215 @@ +/** + * 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 { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import fetchMock from 'fetch-mock'; +import rison from 'rison'; +import { SupersetClient } from '@superset-ui/core'; +import { selectOption } from 'spec/helpers/testing-library'; +import { + setupMocks, + renderDatasetList, + mockAdminUser, + mockDatasets, + setupDeleteMocks, + setupBulkDeleteMocks, + setupDuplicateMocks, + mockHandleResourceExport, + assertOnlyExpectedCalls, + API_ENDPOINTS, +} from './DatasetList.testHelpers'; + +const mockAddDangerToast = jest.fn(); +const mockAddSuccessToast = jest.fn(); + +jest.mock('src/components/MessageToasts/actions', () => ({ + addDangerToast: (msg: string) => { + mockAddDangerToast(msg); + return () => ({ type: '@@toast/danger' }); + }, + addSuccessToast: (msg: string) => { + mockAddSuccessToast(msg); + return () => ({ type: '@@toast/success' }); + }, +})); + +jest.mock('src/utils/export'); + +// Increase default timeout for tests that involve multiple async operations +jest.setTimeout(15000); + +const buildSupersetClientError = ({ + status, + message, +}: { + status: number; + message: string; +}) => ({ + message, + error: message, + status, + response: { + status, + json: async () => ({ message }), + text: async () => message, + clone() { + return { + ...this, + json: async () => ({ message }), + text: async () => message, + }; + }, + }, +}); + +/** + * Helper to set up error test scenarios with SupersetClient spy + * Reduces boilerplate for error toast tests + */ +const setupErrorTestScenario = ({ + dataset, + method, + endpoint, + errorStatus, + errorMessage, +}: { + dataset: (typeof mockDatasets)[0]; + method: 'get' | 'post'; + endpoint: string; + errorStatus: number; + errorMessage: string; +}) => { + // Spy on SupersetClient method and throw error for specific endpoint + const originalMethod = + method === 'get' + ? SupersetClient.get.bind(SupersetClient) + : SupersetClient.post.bind(SupersetClient); + + jest.spyOn(SupersetClient, method).mockImplementation(async request => { + if (request.endpoint?.includes(endpoint)) { + throw buildSupersetClientError({ + status: errorStatus, + message: errorMessage, + }); + } + return originalMethod(request); + }); + + // Configure fetchMock to return single dataset + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + // Render component with toast mocks + renderDatasetList(mockAdminUser, { + addDangerToast: mockAddDangerToast, + addSuccessToast: mockAddSuccessToast, + }); +}; + +beforeEach(() => { + setupMocks(); + jest.clearAllMocks(); +}); + +afterEach(async () => { + // Flush pending React state updates within act() to prevent warnings + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // Restore real timers in case a test threw early + jest.useRealTimers(); + + // Reset browser history state to prevent query params leaking between tests + // QueryParamProvider reads from window.history, which persists across renders + window.history.replaceState({}, '', '/'); + + fetchMock.resetHistory(); + fetchMock.restore(); + jest.restoreAllMocks(); +}); + +test('required API endpoints are called and no unmocked calls on initial render', async () => { + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Verify expected endpoints were called and no unmocked calls + // assertOnlyExpectedCalls checks: 1) no unmatched calls, 2) each expected endpoint was called + assertOnlyExpectedCalls([ + API_ENDPOINTS.DATASETS_INFO, // Permission check + API_ENDPOINTS.DATASETS, // Main dataset list data + ]); +}); + +test('renders all required column headers', async () => { + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + + // Verify all column headers are present + expect( + within(table).getByRole('columnheader', { name: /Name/i }), + ).toBeInTheDocument(); + expect( + within(table).getByRole('columnheader', { name: /Type/i }), + ).toBeInTheDocument(); + expect( + within(table).getByRole('columnheader', { name: /Database/i }), + ).toBeInTheDocument(); + expect( + within(table).getByRole('columnheader', { name: /Schema/i }), + ).toBeInTheDocument(); + expect( + within(table).getByRole('columnheader', { name: /Owners/i }), + ).toBeInTheDocument(); + expect( + within(table).getByRole('columnheader', { name: /Last modified/i }), + ).toBeInTheDocument(); + expect( + within(table).getByRole('columnheader', { name: /Actions/i }), + ).toBeInTheDocument(); +}); + +test('displays dataset name in Name column', async () => { + const dataset = mockDatasets[0]; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); +}); + +test('displays dataset type as Physical or Virtual', async () => { + const physicalDataset = mockDatasets[0]; // kind: 'physical' + const virtualDataset = mockDatasets[1]; // kind: 'virtual' + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [physicalDataset, virtualDataset], count: 2 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(physicalDataset.table_name)).toBeInTheDocument(); + }); + + expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument(); +}); + +test('displays database name in Database column', async () => { + const dataset = mockDatasets[0]; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect( + screen.getByText(dataset.database.database_name), + ).toBeInTheDocument(); + }); +}); + +test('displays schema name in Schema column', async () => { + const dataset = mockDatasets[0]; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(dataset.schema)).toBeInTheDocument(); + }); +}); + +test('displays last modified date in humanized format', async () => { + const dataset = mockDatasets[0]; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect( + screen.getByText(dataset.changed_on_delta_humanized), + ).toBeInTheDocument(); + }); +}); + +test('sorting by Name column updates API call with sort parameter', async () => { + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const nameHeader = within(table).getByRole('columnheader', { + name: /Name/i, + }); + + // Record initial calls + const initialCalls = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + + // Click Name header to sort + await userEvent.click(nameHeader); + + // Wait for new API call + await waitFor(() => { + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + expect(calls.length).toBeGreaterThan(initialCalls); + }); + + // Verify latest call includes sort parameter + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + const latestCall = calls[calls.length - 1]; + const url = latestCall[0] as string; + + // URL should contain order_column for sorting + expect(url).toMatch(/order_column|sort/); +}); + +test('sorting by Database column updates sort parameter', async () => { + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const databaseHeader = within(table).getByRole('columnheader', { + name: /Database/i, + }); + + const initialCalls = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + + await userEvent.click(databaseHeader); + + await waitFor(() => { + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + expect(calls.length).toBeGreaterThan(initialCalls); + }); + + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + const url = calls[calls.length - 1][0] as string; + expect(url).toMatch(/order_column|sort/); +}); + +test('sorting by Last modified column updates sort parameter', async () => { + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const modifiedHeader = within(table).getByRole('columnheader', { + name: /Last modified/i, + }); + + const initialCalls = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + + await userEvent.click(modifiedHeader); + + await waitFor(() => { + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + expect(calls.length).toBeGreaterThan(initialCalls); + }); + + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + const url = calls[calls.length - 1][0] as string; + expect(url).toMatch(/order_column|sort/); +}); + +test('export button triggers handleResourceExport with dataset ID', async () => { + const dataset = mockDatasets[0]; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + // Find export button in actions column (fail-fast if not found) + const table = screen.getByTestId('listview-table'); + const exportButton = await within(table).findByTestId('upload'); + + await userEvent.click(exportButton); + + await waitFor(() => { + expect(mockHandleResourceExport).toHaveBeenCalledWith( + 'dataset', + [dataset.id], + expect.any(Function), + ); + }); +}); + +test('delete button opens modal with dataset details', async () => { + const dataset = mockDatasets[0]; + + setupDeleteMocks(dataset.id); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const deleteButton = await within(table).findByTestId('delete'); + + await userEvent.click(deleteButton); + + // Verify delete modal appears + const modal = await screen.findByRole('dialog'); + expect(modal).toBeInTheDocument(); +}); + +test('delete action successfully deletes dataset and refreshes list', async () => { + const datasetToDelete = mockDatasets[0]; + setupDeleteMocks(datasetToDelete.id); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [datasetToDelete], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser, { + addSuccessToast: mockAddSuccessToast, + }); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const deleteButton = await within(table).findByTestId('delete'); + await userEvent.click(deleteButton); + + // Modal opens with dataset info + const modal = await screen.findByRole('dialog'); + + // Type DELETE to enable confirm button + const confirmInput = within(modal).getByTestId('delete-modal-input'); + await userEvent.type(confirmInput, 'DELETE'); + + // Track API calls before confirm + const callsBefore = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + + // Click confirm - find the danger button (last delete button in modal) + const confirmButton = within(modal) + .getAllByRole('button', { name: /^delete$/i }) + .pop(); + await userEvent.click(confirmButton!); + + // Wait for delete API call + await waitFor(() => { + const deleteCalls = fetchMock.calls( + `glob:*/api/v1/dataset/${datasetToDelete.id}`, + ); + const hasDelete = deleteCalls.some( + call => (call[1] as RequestInit)?.method === 'DELETE', + ); + expect(hasDelete).toBe(true); + }); + + // Success toast shown and modal closes + await waitFor(() => { + expect(mockAddSuccessToast).toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + // List refreshes + await waitFor(() => { + expect(fetchMock.calls(API_ENDPOINTS.DATASETS).length).toBeGreaterThan( + callsBefore, + ); + }); +}); + +test('delete action cancel closes modal without deleting', async () => { + const dataset = mockDatasets[0]; + setupDeleteMocks(dataset.id); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const deleteButton = await within(table).findByTestId('delete'); + await userEvent.click(deleteButton); + + const modal = await screen.findByRole('dialog'); + + // Click Cancel button + const cancelButton = within(modal).getByRole('button', { name: /cancel/i }); + await userEvent.click(cancelButton); + + // Modal closes + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + // No delete API call made (only related_objects GET was called) + const deleteCalls = fetchMock.calls(`glob:*/api/v1/dataset/${dataset.id}`); + const hasDeleteMethod = deleteCalls.some( + call => (call[1] as RequestInit)?.method === 'DELETE', + ); + expect(hasDeleteMethod).toBe(false); + + // Dataset still in list + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); +}); + +test('duplicate action successfully duplicates virtual dataset', async () => { + const virtualDataset = mockDatasets[1]; // Virtual dataset (kind: 'virtual') + setupDuplicateMocks(); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [virtualDataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser, { + addSuccessToast: mockAddSuccessToast, + }); + + await waitFor(() => { + expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const duplicateButton = await within(table).findByTestId('copy'); + await userEvent.click(duplicateButton); + + const modal = await screen.findByRole('dialog'); + + // Enter new name + const input = within(modal).getByRole('textbox'); + await userEvent.clear(input); + await userEvent.type(input, 'Copy of Analytics'); + + // Track API calls before submit + const callsBefore = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + + // Submit + const submitButton = within(modal).getByRole('button', { + name: /duplicate/i, + }); + await userEvent.click(submitButton); + + // Wait for duplicate API call and modal closes + await waitFor(() => { + const dupCalls = fetchMock.calls(API_ENDPOINTS.DATASET_DUPLICATE); + expect(dupCalls.length).toBeGreaterThan(0); + // Modal closes (duplicate success doesn't show toast, just closes modal) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + // List refreshes + await waitFor(() => { + expect(fetchMock.calls(API_ENDPOINTS.DATASETS).length).toBeGreaterThan( + callsBefore, + ); + }); +}); + +test('duplicate button visible only for virtual datasets', async () => { + const physicalDataset = mockDatasets[0]; // kind: 'physical' + const virtualDataset = mockDatasets[1]; // kind: 'virtual' + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [physicalDataset, virtualDataset], count: 2 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(physicalDataset.table_name)).toBeInTheDocument(); + }); + + // Find both dataset rows + const physicalRow = screen + .getByText(physicalDataset.table_name) + .closest('tr'); + const virtualRow = screen.getByText(virtualDataset.table_name).closest('tr'); + + expect(physicalRow).toBeInTheDocument(); + expect(virtualRow).toBeInTheDocument(); + + // Check physical dataset row - should NOT have duplicate button + const physicalDuplicateButton = within(physicalRow!).queryByTestId('copy'); + expect(physicalDuplicateButton).not.toBeInTheDocument(); + + // Check virtual dataset row - should have duplicate button (copy icon) + const virtualDuplicateButton = within(virtualRow!).getByTestId('copy'); + expect(virtualDuplicateButton).toBeInTheDocument(); + + // Verify the duplicate button is visible and clickable for virtual datasets + expect(virtualDuplicateButton).toBeVisible(); +}); + +test('bulk select enables checkboxes', async () => { + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Verify no checkboxes before bulk select + expect(screen.queryAllByRole('checkbox')).toHaveLength(0); + + const bulkSelectButton = screen.getByRole('button', { + name: /bulk select/i, + }); + await userEvent.click(bulkSelectButton); + + // Wait for bulk select controls container to appear first (fast query) + await screen.findByTestId('bulk-select-controls'); + + // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll) + const table = screen.getByTestId('listview-table'); + await within(table).findAllByRole('checkbox'); + + // Note: Bulk action buttons (Export, Delete) only appear after selecting items + // This test only verifies checkboxes appear - button visibility tested in other tests +}, 30000); + +test('selecting all datasets shows correct count in toolbar', async () => { + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: mockDatasets, count: mockDatasets.length }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Enter bulk select mode + const bulkSelectButton = screen.getByRole('button', { + name: /bulk select/i, + }); + await userEvent.click(bulkSelectButton); + + // Wait for bulk select controls container to appear first (fast query) + await screen.findByTestId('bulk-select-controls'); + + // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll) + const table = screen.getByTestId('listview-table'); + await within(table).findAllByRole('checkbox'); + + // Select all checkbox using semantic selector + // Note: antd renders multiple checkboxes with same aria-label, use first one (table header) + const selectAllCheckboxes = screen.getAllByLabelText('Select all'); + await userEvent.click(selectAllCheckboxes[0]); + + // Should show selected count in toolbar (use data-test for reliability) + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + `${mockDatasets.length} Selected`, + ); + }); + // Note: Button enable state is tested in bulk export/delete tests +}, 30000); + +test('bulk export triggers export with selected IDs', async () => { + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [mockDatasets[0]], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Enter bulk select mode + const bulkSelectButton = screen.getByRole('button', { name: /bulk select/i }); + await userEvent.click(bulkSelectButton); + + // Wait for bulk select controls container to appear first (fast query) + await screen.findByTestId('bulk-select-controls'); + + // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll) + const table = screen.getByTestId('listview-table'); + await within(table).findAllByRole('checkbox'); + + // Select row by dataset name (scoped to table, async to avoid race) + const datasetCell = await within(table).findByText( + mockDatasets[0].table_name, + ); + const datasetRow = datasetCell.closest('tr'); + expect(datasetRow).toBeInTheDocument(); + await userEvent.click(within(datasetRow!).getByRole('checkbox')); + + // Find and click bulk export button (fail-fast if not found) + const exportButton = await screen.findByRole('button', { name: /export/i }); + await userEvent.click(exportButton); + + await waitFor(() => { + expect(mockHandleResourceExport).toHaveBeenCalled(); + }); +}); + +test('bulk delete opens confirmation modal', async () => { + setupBulkDeleteMocks(); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [mockDatasets[0]], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Enter bulk select mode + const bulkSelectButton = screen.getByRole('button', { name: /bulk select/i }); + await userEvent.click(bulkSelectButton); + + // Wait for bulk select controls container to appear first (fast query) + const bulkSelectControls = await screen.findByTestId('bulk-select-controls'); + + // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll) + const table = screen.getByTestId('listview-table'); + await within(table).findAllByRole('checkbox'); + + // Select row by dataset name (scoped to table, async to avoid race) + const datasetCell = await within(table).findByText( + mockDatasets[0].table_name, + ); + const datasetRow = datasetCell.closest('tr'); + expect(datasetRow).toBeInTheDocument(); + await userEvent.click(within(datasetRow!).getByRole('checkbox')); + + // Wait for selection to register before clicking Delete + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + /1 Selected/i, + ); + }); + + // Find bulk delete button scoped to toolbar (avoids matching row delete buttons) + const deleteButton = await within(bulkSelectControls).findByRole('button', { + name: 'Delete', + }); + await userEvent.click(deleteButton); + + // Confirmation modal should appear - verify by stable anchors (delete-modal-input + bulk context) + const modal = await screen.findByRole('dialog'); + expect(modal).toBeInTheDocument(); + // delete-modal-input is unique to ConfirmStatusChange delete modals + expect(within(modal).getByTestId('delete-modal-input')).toBeInTheDocument(); +}); + +test('exit bulk select via close button returns to normal view', async () => { + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Enter bulk select mode + const bulkSelectButton = screen.getByRole('button', { + name: /bulk select/i, + }); + await userEvent.click(bulkSelectButton); + + // Wait for bulk select controls container to appear first (fast query) + const bulkSelectControls = await screen.findByTestId('bulk-select-controls'); + + // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll) + const table = screen.getByTestId('listview-table'); + await within(table).findAllByRole('checkbox'); + + // Note: Not verifying export/delete buttons here as they only appear after selection + // This test focuses on the close button functionality + + // Find close button within the bulk select container + // antd 5.x Alert component renders close button with aria-label="Close" + const closeButton = within(bulkSelectControls).getByRole('button', { + name: /close/i, + }); + await userEvent.click(closeButton); + + // Wait for bulk select controls to be removed + // Using waitFor with queryBy - handles both "still visible" and "already gone" cases + await waitFor(() => { + expect( + screen.queryByTestId('bulk-select-controls'), + ).not.toBeInTheDocument(); + }); + + // Then verify normal toolbar is restored + expect(screen.queryAllByRole('checkbox')).toHaveLength(0); + expect( + screen.getByRole('button', { name: /bulk select/i }), + ).toBeInTheDocument(); +}, 30000); + +test('certified badge appears for certified datasets', async () => { + const certifiedDataset = { + ...mockDatasets[1], + extra: JSON.stringify({ + certification: { + certified_by: 'Data Team', + details: 'Approved for production', + }, + }), + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [certifiedDataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(certifiedDataset.table_name)).toBeInTheDocument(); + }); + + // Find the dataset row + const row = screen.getByText(certifiedDataset.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + // Verify certified badge icon is present in the row + const certBadge = await within(row!).findByRole('img', { + name: /certified/i, + }); + expect(certBadge).toBeInTheDocument(); +}); + +test('warning icon appears for datasets with warnings', async () => { + const datasetWithWarning = { + ...mockDatasets[2], + extra: JSON.stringify({ + warning_markdown: 'Contains PII', + }), + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [datasetWithWarning], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(datasetWithWarning.table_name)).toBeInTheDocument(); + }); + + // Find the dataset row + const row = screen.getByText(datasetWithWarning.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + // Verify warning icon is present in the row + const warningIcon = await within(row!).findByRole('img', { + name: /warning/i, + }); + expect(warningIcon).toBeInTheDocument(); +}); + +test('info tooltip appears for datasets with descriptions', async () => { + const datasetWithDescription = { + ...mockDatasets[0], + description: 'Sales data from Q4 2024', + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [datasetWithDescription], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect( + screen.getByText(datasetWithDescription.table_name), + ).toBeInTheDocument(); + }); + + // Find the dataset row + const row = screen.getByText(datasetWithDescription.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + // Verify info tooltip icon is present in the row + const infoIcon = await within(row!).findByRole('img', { name: /info/i }); + expect(infoIcon).toBeInTheDocument(); +}); + +test('dataset name links to Explore page', async () => { + const dataset = mockDatasets[0]; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + // Find the dataset row and scope the link query to it + const row = screen.getByText(dataset.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + // Dataset name should be a link to Explore within the row + const link = within(row!).getByTestId('internal-link'); + expect(link).toHaveAttribute('href', dataset.explore_url); +}); + +test('physical dataset shows delete, export, and edit actions (no duplicate)', async () => { + const physicalDataset = mockDatasets[0]; // kind: 'physical' + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [physicalDataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(physicalDataset.table_name)).toBeInTheDocument(); + }); + + const row = screen.getByText(physicalDataset.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + // Physical datasets should have: delete, export, edit + const deleteButton = within(row!).getByTestId('delete'); + const exportButton = within(row!).getByTestId('upload'); + const editButton = within(row!).getByTestId('edit'); + + expect(deleteButton).toBeInTheDocument(); + expect(exportButton).toBeInTheDocument(); + expect(editButton).toBeInTheDocument(); + + // Should NOT have duplicate button + const duplicateButton = within(row!).queryByTestId('copy'); + expect(duplicateButton).not.toBeInTheDocument(); +}); + +test('virtual dataset shows delete, export, edit, and duplicate actions', async () => { + const virtualDataset = mockDatasets[1]; // kind: 'virtual' + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [virtualDataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument(); + }); + + const row = screen.getByText(virtualDataset.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + // Virtual datasets should have: delete, export, edit, duplicate + const deleteButton = within(row!).getByTestId('delete'); + const exportButton = within(row!).getByTestId('upload'); + const editButton = within(row!).getByTestId('edit'); + const duplicateButton = within(row!).getByTestId('copy'); + + expect(deleteButton).toBeInTheDocument(); + expect(exportButton).toBeInTheDocument(); + expect(editButton).toBeInTheDocument(); + expect(duplicateButton).toBeInTheDocument(); +}); + +test('edit action is enabled for dataset owner', async () => { + const dataset = { + ...mockDatasets[0], + owners: [{ id: mockAdminUser.userId, username: 'admin' }], + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + const row = screen.getByText(dataset.table_name).closest('tr'); + const editIcon = within(row!).getByTestId('edit'); + const editButton = editIcon.closest('.action-button, .disabled'); + + // Should have action-button class (not disabled) + expect(editButton).toHaveClass('action-button'); + expect(editButton).not.toHaveClass('disabled'); +}); + +test('edit action is disabled for non-owner', async () => { + const dataset = { + ...mockDatasets[0], + owners: [{ id: 999, username: 'other_user' }], // Different user + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + // Use a non-admin user to test ownership check + const regularUser = { + ...mockAdminUser, + roles: { Admin: [['can_read', 'Dataset']] }, + }; + + renderDatasetList(regularUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + const row = screen.getByText(dataset.table_name).closest('tr'); + const editIcon = within(row!).getByTestId('edit'); + const editButton = editIcon.closest('.action-button, .disabled'); + + // Should have disabled class (disabled buttons still have 'action-button' class) + expect(editButton).toHaveClass('disabled'); + expect(editButton).toHaveClass('action-button'); +}); + +test('all action buttons are clickable and enabled for admin user', async () => { + const virtualDataset = { + ...mockDatasets[1], + owners: [{ id: mockAdminUser.userId, username: 'admin' }], + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [virtualDataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument(); + }); + + const row = screen.getByText(virtualDataset.table_name).closest('tr'); + + // Get icons and their parent button elements + const deleteIcon = within(row!).getByTestId('delete'); + const exportIcon = within(row!).getByTestId('upload'); + const editIcon = within(row!).getByTestId('edit'); + const duplicateIcon = within(row!).getByTestId('copy'); + + const deleteButton = deleteIcon.closest('.action-button, .disabled'); + const exportButton = exportIcon.closest('.action-button, .disabled'); + const editButton = editIcon.closest('.action-button, .disabled'); + const duplicateButton = duplicateIcon.closest('.action-button, .disabled'); + + // All should have action-button class (enabled) + expect(deleteButton).toHaveClass('action-button'); + expect(exportButton).toHaveClass('action-button'); + expect(editButton).toHaveClass('action-button'); + expect(duplicateButton).toHaveClass('action-button'); + + // None should be disabled + expect(deleteButton).not.toHaveClass('disabled'); + expect(exportButton).not.toHaveClass('disabled'); + expect(editButton).not.toHaveClass('disabled'); + expect(duplicateButton).not.toHaveClass('disabled'); +}); + +test('displays error when initial dataset fetch fails with 500', async () => { + fetchMock.get( + API_ENDPOINTS.DATASETS, + { status: 500, body: { message: 'Internal Server Error' } }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser, { + addDangerToast: mockAddDangerToast, + }); + + // Error toast should be shown + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalled(); + }); + + // No dataset names from mockDatasets should appear in the document + mockDatasets.forEach(dataset => { + expect(screen.queryByText(dataset.table_name)).not.toBeInTheDocument(); + }); +}); + +test('displays error when initial dataset fetch fails with 403 permission denied', async () => { + fetchMock.get( + API_ENDPOINTS.DATASETS, + { status: 403, body: { message: 'Access Denied' } }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser, { + addDangerToast: mockAddDangerToast, + }); + + // Error toast should be shown for permission denied + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalled(); + }); + + // Verify toast message contains the 403-specific "Access Denied" text + const toastMessage = String(mockAddDangerToast.mock.calls[0][0]); + expect(toastMessage).toContain('Access Denied'); + + // No dataset names from mockDatasets should appear in the document + mockDatasets.forEach(dataset => { + expect(screen.queryByText(dataset.table_name)).not.toBeInTheDocument(); + }); +}); + +test('dataset links use internal routing when PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET is enabled', async () => { + renderDatasetList( + mockAdminUser, + {}, + { + common: { + conf: { + PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: true, + }, + }, + }, + ); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // When flag is enabled, links should use internal-link data-test attribute + const internalLinks = screen.getAllByTestId('internal-link'); + expect(internalLinks.length).toBeGreaterThan(0); + + // Each link should be a React Router Link (has href attribute) + internalLinks.forEach(link => { + expect(link).toHaveAttribute('href'); + }); +}); + +// Note: These delete error tests verify that the modal doesn't open when fetching +// related_objects fails. The component's openDatasetDeleteModal error handler +// (index.tsx:262-268) returns a string but doesn't call addDangerToast(), so no +// error toast is shown. This is a component bug documented for a separate fix. +// The tests correctly verify current behavior: API call made, modal prevented. + +test('delete action gracefully handles 403 forbidden error', async () => { + const dataset = mockDatasets[0]; + + setupErrorTestScenario({ + dataset, + method: 'get', + endpoint: '/related_objects', + errorStatus: 403, + errorMessage: 'Failed to fetch related objects', + }); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const deleteButton = await within(table).findByTestId('delete'); + + await userEvent.click(deleteButton); + + // Wait for SupersetClient.get to be called (deterministic anchor - API was attempted) + await waitFor(() => { + expect(SupersetClient.get).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expect.stringContaining('/related_objects'), + }), + ); + }); + + // Verify modal did NOT open (error prevented it) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + // Verify dataset still in list (not removed) + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); +}); + +test('delete action gracefully handles 500 internal server error', async () => { + const dataset = mockDatasets[0]; + + setupErrorTestScenario({ + dataset, + method: 'get', + endpoint: '/related_objects', + errorStatus: 500, + errorMessage: 'Internal Server Error', + }); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const deleteButton = await within(table).findByTestId('delete'); + + await userEvent.click(deleteButton); + + // Wait for SupersetClient.get to be called (deterministic anchor - API was attempted) + await waitFor(() => { + expect(SupersetClient.get).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: expect.stringContaining('/related_objects'), + }), + ); + }); + + // Verify modal did NOT open + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + // Verify table state unchanged + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); +}); + +test('duplicate action shows error toast on 403 forbidden', async () => { + const virtualDataset = { + ...mockDatasets[1], + owners: [ + { + first_name: mockAdminUser.firstName, + last_name: mockAdminUser.lastName, + id: mockAdminUser.userId as number, + }, + ], + }; + + setupErrorTestScenario({ + dataset: virtualDataset, + method: 'post', + endpoint: '/duplicate', + errorStatus: 403, + errorMessage: 'Failed to duplicate dataset', + }); + + await waitFor(() => { + expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const duplicateButton = await within(table).findByTestId('copy'); + + await userEvent.click(duplicateButton); + + // Wait for duplicate modal to appear + const modal = await screen.findByRole('dialog'); + expect(modal).toBeInTheDocument(); + + // Enter new dataset name + const input = within(modal).getByRole('textbox'); + await userEvent.clear(input); + await userEvent.type(input, 'Copy of Analytics Query'); + + // Submit duplicate + const submitButton = within(modal).getByRole('button', { + name: /duplicate/i, + }); + await userEvent.click(submitButton); + + // Wait for error toast first (deterministic anchor) + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalledWith( + expect.stringMatching(/issue duplicating.*selected datasets/i), + ); + }); + + // Then verify modal stays open on error (component doesn't close it on failure) + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // Verify original dataset still in list + expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument(); +}); + +test('duplicate action shows error toast on 500 internal server error', async () => { + const virtualDataset = { + ...mockDatasets[1], + owners: [ + { + first_name: mockAdminUser.firstName, + last_name: mockAdminUser.lastName, + id: mockAdminUser.userId as number, + }, + ], + }; + + setupErrorTestScenario({ + dataset: virtualDataset, + method: 'post', + endpoint: '/duplicate', + errorStatus: 500, + errorMessage: 'Internal Server Error', + }); + + await waitFor(() => { + expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const duplicateButton = await within(table).findByTestId('copy'); + + await userEvent.click(duplicateButton); + + // Wait for duplicate modal + const modal = await screen.findByRole('dialog'); + + // Enter new dataset name + const input = within(modal).getByRole('textbox'); + await userEvent.clear(input); + await userEvent.type(input, 'Copy of Analytics Query'); + + // Submit + const submitButton = within(modal).getByRole('button', { + name: /duplicate/i, + }); + await userEvent.click(submitButton); + + // Wait for error toast first (deterministic anchor) + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalledWith( + expect.stringMatching(/issue duplicating.*selected datasets/i), + ); + }); + + // Then verify modal stays open on error (component doesn't close it on failure) + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // Verify table state unchanged + expect(screen.getByText(virtualDataset.table_name)).toBeInTheDocument(); +}); + +// Component "+1" Tests - State persistence through operations + +// This test is inherently slow due to userEvent.type() typing DELETE character-by-character +// 30s timeout to handle CI variability +test('sort order persists after deleting a dataset', async () => { + const datasetToDelete = mockDatasets[0]; + setupDeleteMocks(datasetToDelete.id); + + renderDatasetList(mockAdminUser, { + addSuccessToast: mockAddSuccessToast, + addDangerToast: mockAddDangerToast, + }); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const nameHeader = within(table).getByRole('columnheader', { + name: /Name/i, + }); + + // Record initial API calls count + const initialCalls = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + + // Click Name header to sort + await userEvent.click(nameHeader); + + // Wait for new API call with sort parameter + await waitFor(() => { + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + expect(calls.length).toBeGreaterThan(initialCalls); + }); + + // Record the sort parameter from the API call after sorting + const callsAfterSort = fetchMock.calls(API_ENDPOINTS.DATASETS); + const sortedUrl = callsAfterSort[callsAfterSort.length - 1][0] as string; + expect(sortedUrl).toMatch(/order_column|sort/); + + // Delete a dataset - get delete button from first row only + const firstRow = screen.getAllByRole('row')[1]; + const deleteButton = within(firstRow).getByTestId('delete'); + await userEvent.click(deleteButton); + + // Confirm delete in modal - type DELETE to enable button + const modal = await screen.findByRole('dialog'); + await within(modal).findByText(datasetToDelete.table_name); + + // Enable the danger button by typing DELETE + const confirmInput = within(modal).getByTestId('delete-modal-input'); + await userEvent.clear(confirmInput); + await userEvent.type(confirmInput, 'DELETE'); + + // Record call count before delete to track refetch + const callsBeforeDelete = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + + const confirmButton = within(modal) + .getAllByRole('button', { name: /^delete$/i }) + .pop(); + await userEvent.click(confirmButton!); + + // Confirm the delete request fired + await waitFor(() => { + expect(mockAddSuccessToast).toHaveBeenCalled(); + }); + + // Wait for modal to close completely + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + // Wait for list refetch to complete (prevents async cleanup error) + await waitFor(() => { + const currentCalls = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + expect(currentCalls).toBeGreaterThan(callsBeforeDelete); + }); + + // Re-query the header fresh (DOM may have been replaced on re-render) + // and assert the sort indicators still exist + await waitFor(() => { + const freshHeader = screen.getByRole('columnheader', { name: /Name/i }); + const carets = within(freshHeader.closest('th')!).getAllByLabelText( + /caret/i, + ); + expect(carets.length).toBeGreaterThan(0); + }); +}, 30000); + +// Note: "deleting last item on page 2 fetches page 1" is a hook-level pagination +// concern (useListViewResource handles page reset logic). This is covered by +// integration tests where we can verify the full pagination cycle. + +// Note: Full bulk delete workflow (select → delete → confirm → verify toast) is +// tested in DatasetList.integration.test.tsx as it's a multi-component orchestration +// test. Component tests here focus on individual behaviors. + +test('bulk selection clears when filter changes', async () => { + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: mockDatasets, count: mockDatasets.length }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Enter bulk select mode + const bulkSelectButton = screen.getByRole('button', { + name: /bulk select/i, + }); + await userEvent.click(bulkSelectButton); + + // Wait for bulk select controls container to appear first (fast query) + await screen.findByTestId('bulk-select-controls'); + + // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll) + const table = screen.getByTestId('listview-table'); + await within(table).findAllByRole('checkbox'); + + // Select first dataset by name (scoped to table, async to avoid race) + const firstCell = await within(table).findByText(mockDatasets[0].table_name); + const firstRow = firstCell.closest('tr'); + expect(firstRow).toBeInTheDocument(); + await userEvent.click(within(firstRow!).getByRole('checkbox')); + + // Wait for first selection to register before clicking second (prevents stale node) + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + /1 Selected/i, + ); + }); + + // Select second dataset by name (scoped to table, async to avoid race) + const secondCell = await within(table).findByText(mockDatasets[1].table_name); + const secondRow = secondCell.closest('tr'); + expect(secondRow).toBeInTheDocument(); + await userEvent.click(within(secondRow!).getByRole('checkbox')); + + // Wait for selections to register - assert specific count to avoid matching "0 Selected" + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + /2 Selected/i, + ); + }); + + // Record API call count before filter + const beforeFilterCallCount = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + + // Wait for filter combobox to be ready before applying filter + await screen.findByRole('combobox', { name: 'Type' }); + + // Apply a filter using selectOption helper + await selectOption('Virtual', 'Type'); + + // Wait for filter API call to complete + await waitFor(() => { + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + expect(calls.length).toBeGreaterThan(beforeFilterCallCount); + }); + + // Verify filter was applied by decoding URL payload + const urlAfterFilter = fetchMock + .calls(API_ENDPOINTS.DATASETS) + .at(-1)?.[0] as string; + const risonAfterFilter = new URL( + urlAfterFilter, + 'http://localhost', + ).searchParams.get('q'); + expect(risonAfterFilter).toBeTruthy(); + // searchParams.get() already URL-decodes, so pass directly to rison.decode + const decodedAfterFilter = rison.decode(risonAfterFilter!) as Record< + string, + unknown + >; + expect(decodedAfterFilter.filters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ col: 'sql', value: false }), + ]), + ); + + // Verify selection was cleared - count should show "0 Selected" + // Use findByText for better async handling + await screen.findByText(/0 selected/i); +}, 45000); // Complex test with filter + selection operations + +test('type filter API call includes correct filter parameter', async () => { + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Wait for Type filter combobox + await screen.findByRole('combobox', { name: 'Type' }); + + // Snapshot call count before filter + const callsBeforeFilter = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + + // Apply Type filter + await selectOption('Virtual', 'Type'); + + // Wait for filter API call to complete + await waitFor(() => { + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + expect(calls.length).toBeGreaterThan(callsBeforeFilter); + }); + + // Verify the latest API call includes the Type filter + const url = fetchMock.calls(API_ENDPOINTS.DATASETS).at(-1)?.[0] as string; + expect(url).toContain('filters'); + + // searchParams.get() already URL-decodes, so pass directly to rison.decode + const risonPayload = new URL(url, 'http://localhost').searchParams.get('q'); + expect(risonPayload).toBeTruthy(); + const decoded = rison.decode(risonPayload!) as Record; + const filters = Array.isArray(decoded?.filters) ? decoded.filters : []; + + // Type filter should be present (sql=false for Virtual datasets) + expect(filters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ col: 'sql', value: false }), + ]), + ); +}); + +test('type filter persists after duplicating a dataset', async () => { + const datasetToDuplicate = mockDatasets.find(d => d.kind === 'virtual')!; + + setupDuplicateMocks(); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Wait for Type filter combobox + await screen.findByRole('combobox', { name: 'Type' }); + + // Snapshot call count before filter + const callsBeforeFilter = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + + // Apply Type filter + await selectOption('Virtual', 'Type'); + + // Wait for filter API call to complete + await waitFor(() => { + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + expect(calls.length).toBeGreaterThan(callsBeforeFilter); + }); + + // Verify filter is present by checking the latest API call + const urlAfterFilter = fetchMock + .calls(API_ENDPOINTS.DATASETS) + .at(-1)?.[0] as string; + const risonAfterFilter = new URL( + urlAfterFilter, + 'http://localhost', + ).searchParams.get('q'); + expect(risonAfterFilter).toBeTruthy(); + // searchParams.get() already URL-decodes, so pass directly to rison.decode + const decodedAfterFilter = rison.decode(risonAfterFilter!) as Record< + string, + unknown + >; + expect(decodedAfterFilter.filters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ col: 'sql', value: false }), + ]), + ); + + // Capture datasets API call count BEFORE any duplicate operations + const datasetsCallCountBeforeDuplicate = fetchMock.calls( + API_ENDPOINTS.DATASETS, + ).length; + + // Now duplicate the dataset + const row = screen.getByText(datasetToDuplicate.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + const duplicateIcon = await within(row!).findByTestId('copy'); + const duplicateButton = duplicateIcon.closest( + '[role="button"]', + ) as HTMLElement | null; + expect(duplicateButton).toBeTruthy(); + + await userEvent.click(duplicateButton!); + + const modal = await screen.findByRole('dialog'); + const modalInput = within(modal).getByRole('textbox'); + await userEvent.clear(modalInput); + await userEvent.type(modalInput, 'Copy of Dataset'); + + const confirmButton = within(modal).getByRole('button', { + name: /duplicate/i, + }); + await userEvent.click(confirmButton); + + // Wait for duplicate API call to be made + await waitFor(() => { + const duplicateCalls = fetchMock.calls(API_ENDPOINTS.DATASET_DUPLICATE); + expect(duplicateCalls.length).toBeGreaterThan(0); + }); + + // Wait for datasets refetch to occur (proves duplicate triggered a refresh) + await waitFor(() => { + const datasetsCallCount = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + expect(datasetsCallCount).toBeGreaterThan(datasetsCallCountBeforeDuplicate); + }); + + // Verify Type filter persisted in the NEW datasets API call after duplication + const urlAfterDuplicate = fetchMock + .calls(API_ENDPOINTS.DATASETS) + .at(-1)?.[0] as string; + const risonAfterDuplicate = new URL( + urlAfterDuplicate, + 'http://localhost', + ).searchParams.get('q'); + expect(risonAfterDuplicate).toBeTruthy(); + // searchParams.get() already URL-decodes, so pass directly to rison.decode + const decodedAfterDuplicate = rison.decode(risonAfterDuplicate!) as Record< + string, + unknown + >; + expect(decodedAfterDuplicate.filters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ col: 'sql', value: false }), + ]), + ); +}); + +// Error Path Tests - Missing coverage for error handling flows + +test('edit action shows error toast when dataset fetch fails', async () => { + const dataset = mockDatasets[0]; + // Make the dataset owned by admin so edit button is enabled + const ownedDataset = { + ...dataset, + owners: [ + { + first_name: mockAdminUser.firstName, + last_name: mockAdminUser.lastName, + id: mockAdminUser.userId as number, + }, + ], + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [ownedDataset], count: 1 }, + { overwriteRoutes: true }, + ); + + // Mock SupersetClient.get to fail for the specific dataset endpoint + jest.spyOn(SupersetClient, 'get').mockImplementation(async request => { + if (request.endpoint?.includes(`/api/v1/dataset/${dataset.id}`)) { + throw buildSupersetClientError({ + status: 500, + message: 'Failed to fetch dataset', + }); + } + // Let other calls go through normally via fetchMock + const response = await fetch(request.endpoint!, { method: 'GET' }); + return { json: await response.json(), response }; + }); + + renderDatasetList(mockAdminUser, { + addDangerToast: mockAddDangerToast, + }); + + await waitFor(() => { + expect(screen.getByText(ownedDataset.table_name)).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const editButton = await within(table).findByTestId('edit'); + + await userEvent.click(editButton); + + // Wait for error toast + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalledWith( + expect.stringMatching(/error.*fetching dataset/i), + ); + }); + + // Verify edit modal did NOT open + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); +}); + +test('bulk export error shows toast and clears loading state', async () => { + // Mock handleResourceExport to throw an error + mockHandleResourceExport.mockRejectedValueOnce(new Error('Export failed')); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [mockDatasets[0]], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser, { + addDangerToast: mockAddDangerToast, + }); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Enter bulk select mode + const bulkSelectButton = screen.getByRole('button', { + name: /bulk select/i, + }); + await userEvent.click(bulkSelectButton); + + // Wait for bulk select controls + await screen.findByTestId('bulk-select-controls'); + + // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll) + const table = screen.getByTestId('listview-table'); + await within(table).findAllByRole('checkbox'); + + // Select row by dataset name (row-scoped query is more robust than array index) + const datasetRow = screen.getByText(mockDatasets[0].table_name).closest('tr'); + expect(datasetRow).toBeInTheDocument(); + const rowCheckbox = within(datasetRow!).getByRole('checkbox'); + await userEvent.click(rowCheckbox); + + // Wait for selection to register before clicking Export (prevents race condition) + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + /1 Selected/i, + ); + }); + + // Click bulk export + const exportButton = await screen.findByRole('button', { name: /export/i }); + await userEvent.click(exportButton); + + // Wait for error toast + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalledWith( + expect.stringMatching(/issue exporting.*selected datasets/i), + ); + }); + + // Verify loading state was cleared (preparingExport = false) + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + + // Verify export was called + expect(mockHandleResourceExport).toHaveBeenCalled(); +}, 30000); + +test('bulk delete error shows toast without refreshing list', async () => { + // Mock bulk delete to fail + fetchMock.delete( + API_ENDPOINTS.DATASET_BULK_DELETE, + { status: 500, body: { message: 'Bulk delete failed' } }, + { overwriteRoutes: true }, + ); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [mockDatasets[0]], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser, { + addDangerToast: mockAddDangerToast, + addSuccessToast: mockAddSuccessToast, + }); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Enter bulk select mode + const bulkSelectButton = screen.getByRole('button', { name: /bulk select/i }); + await userEvent.click(bulkSelectButton); + + // Wait for bulk select controls + const bulkSelectControls = await screen.findByTestId('bulk-select-controls'); + + // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll) + const table = screen.getByTestId('listview-table'); + await within(table).findAllByRole('checkbox'); + + // Select row by dataset name (row-scoped query is more robust than array index) + const datasetRow = screen.getByText(mockDatasets[0].table_name).closest('tr'); + expect(datasetRow).toBeInTheDocument(); + const rowCheckbox = within(datasetRow!).getByRole('checkbox'); + await userEvent.click(rowCheckbox); + + // Wait for selection to register before clicking Delete (prevents race condition) + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + /1 Selected/i, + ); + }); + + // Click bulk delete - scoped to toolbar to avoid row delete buttons + const bulkDeleteButton = await within(bulkSelectControls).findByRole( + 'button', + { name: 'Delete' }, + ); + await userEvent.click(bulkDeleteButton); + + // Confirm delete in modal - type DELETE to enable button + const modal = await screen.findByRole('dialog'); + const confirmInput = within(modal).getByTestId('delete-modal-input'); + await userEvent.clear(confirmInput); + await userEvent.type(confirmInput, 'DELETE'); + + const confirmButton = within(modal) + .getAllByRole('button', { name: /^delete$/i }) + .pop(); + await userEvent.click(confirmButton!); + + // Wait for error toast + await waitFor(() => { + expect(mockAddDangerToast).toHaveBeenCalledWith( + expect.stringMatching(/issue deleting.*selected datasets/i), + ); + }); + + // Verify success toast was NOT called + expect(mockAddSuccessToast).not.toHaveBeenCalled(); + + // Verify original dataset still in list + expect(screen.getByText(mockDatasets[0].table_name)).toBeInTheDocument(); +}, 30000); + +// Bulk Select Copy Tests - Verify count labels for different selection types + +test('bulk select shows "N Selected (Virtual)" for virtual-only selection', async () => { + // Use only virtual datasets + const virtualDatasets = mockDatasets.filter(d => d.kind === 'virtual'); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: virtualDatasets, count: virtualDatasets.length }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Enter bulk select mode + const bulkSelectButton = screen.getByRole('button', { + name: /bulk select/i, + }); + await userEvent.click(bulkSelectButton); + + await screen.findByTestId('bulk-select-controls'); + + // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll) + const table = screen.getByTestId('listview-table'); + await within(table).findAllByRole('checkbox'); + + // Select first virtual dataset by name (row-scoped query is more robust than array index) + const virtualDataset = mockDatasets.find(d => d.kind === 'virtual')!; + const datasetRow = screen.getByText(virtualDataset.table_name).closest('tr'); + expect(datasetRow).toBeInTheDocument(); + await userEvent.click(within(datasetRow!).getByRole('checkbox')); + + // Wait for selection state to update, then verify label + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + /1 Selected/i, + ); + }); + + // Verify the specific text shows "Virtual" + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + /1 Selected \(Virtual\)/i, + ); +}, 30000); + +test('bulk select shows "N Selected (Physical)" for physical-only selection', async () => { + // Use only physical datasets + const physicalDatasets = mockDatasets.filter(d => d.kind === 'physical'); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: physicalDatasets, count: physicalDatasets.length }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Enter bulk select mode + const bulkSelectButton = screen.getByRole('button', { + name: /bulk select/i, + }); + await userEvent.click(bulkSelectButton); + + await screen.findByTestId('bulk-select-controls'); + + // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll) + const table = screen.getByTestId('listview-table'); + await within(table).findAllByRole('checkbox'); + + // Select first physical dataset by name (row-scoped query is more robust than array index) + const datasetRow = screen + .getByText(physicalDatasets[0].table_name) + .closest('tr'); + expect(datasetRow).toBeInTheDocument(); + await userEvent.click(within(datasetRow!).getByRole('checkbox')); + + // Wait for selection state to update, then verify label + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + /1 Selected/i, + ); + }); + + // Verify the specific text shows "Physical" + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + /1 Selected \(Physical\)/i, + ); +}, 30000); + +test('bulk select shows mixed count for virtual and physical selection', async () => { + // Use a small mixed set - 1 physical + 1 virtual + const mixedDatasets = [ + mockDatasets.find(d => d.kind === 'physical')!, + mockDatasets.find(d => d.kind === 'virtual')!, + ]; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: mixedDatasets, count: mixedDatasets.length }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByTestId('listview-table')).toBeInTheDocument(); + }); + + // Enter bulk select mode + const bulkSelectButton = screen.getByRole('button', { + name: /bulk select/i, + }); + await userEvent.click(bulkSelectButton); + + await screen.findByTestId('bulk-select-controls'); + + // Wait for table checkboxes to render (findAllByRole is faster than waitFor with getAll) + const table = screen.getByTestId('listview-table'); + await within(table).findAllByRole('checkbox'); + + // Select first dataset by name (row-scoped query is more robust than array index) + const firstRow = screen.getByText(mixedDatasets[0].table_name).closest('tr'); + expect(firstRow).toBeInTheDocument(); + await userEvent.click(within(firstRow!).getByRole('checkbox')); + + // Wait for first selection to register before clicking second (prevents stale node) + await waitFor(() => { + expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent( + /1 Selected/i, + ); + }); + + // Select second dataset by name (row-scoped query) + const secondRow = screen.getByText(mixedDatasets[1].table_name).closest('tr'); + expect(secondRow).toBeInTheDocument(); + await userEvent.click(within(secondRow!).getByRole('checkbox')); + + // Wait for second selection and verify mixed count + await waitFor(() => { + const bulkSelectCopy = screen.getByTestId('bulk-select-copy'); + expect(bulkSelectCopy).toHaveTextContent(/2 Selected/i); + }); + + // Verify label shows both Physical and Virtual + const bulkSelectCopy = screen.getByTestId('bulk-select-copy'); + expect(bulkSelectCopy).toHaveTextContent(/Physical/i); + expect(bulkSelectCopy).toHaveTextContent(/Virtual/i); +}, 30000); + +// Delete Modal Related Objects Tests + +test('delete modal shows affected dashboards with overflow for >10 items', async () => { + const dataset = mockDatasets[0]; + + // Create mock with more than 10 dashboards + const manyDashboards = Array.from({ length: 15 }, (_, i) => ({ + id: 200 + i, + title: `Dashboard ${i + 1}`, + })); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + fetchMock.get( + `glob:*/api/v1/dataset/${dataset.id}/related_objects*`, + { + charts: { count: 0, result: [] }, + dashboards: { count: 15, result: manyDashboards }, + }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const deleteButton = await within(table).findByTestId('delete'); + await userEvent.click(deleteButton); + + // Wait for modal + const modal = await screen.findByRole('dialog'); + + // Verify "Affected Dashboards" header + expect(within(modal).getByText('Affected Dashboards')).toBeInTheDocument(); + + // Verify first 10 dashboards are shown + expect(within(modal).getByText('Dashboard 1')).toBeInTheDocument(); + expect(within(modal).getByText('Dashboard 10')).toBeInTheDocument(); + + // Verify overflow message + expect(within(modal).getByText(/\.\.\. and 5 others/)).toBeInTheDocument(); + + // Verify Dashboard 11+ are NOT shown + expect(within(modal).queryByText('Dashboard 11')).not.toBeInTheDocument(); +}); + +test('delete modal hides affected dashboards section when count is zero', async () => { + const dataset = mockDatasets[0]; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + fetchMock.get( + `glob:*/api/v1/dataset/${dataset.id}/related_objects*`, + { + charts: { count: 2, result: [{ id: 1, slice_name: 'Chart 1' }] }, + dashboards: { count: 0, result: [] }, + }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const deleteButton = await within(table).findByTestId('delete'); + await userEvent.click(deleteButton); + + // Wait for modal + const modal = await screen.findByRole('dialog'); + + // Verify "Affected Dashboards" header is NOT present + expect( + within(modal).queryByText('Affected Dashboards'), + ).not.toBeInTheDocument(); + + // But "Affected Charts" should still be shown + expect(within(modal).getByText('Affected Charts')).toBeInTheDocument(); +}); + +test('delete modal shows affected charts with overflow for >10 items', async () => { + const dataset = mockDatasets[0]; + + // Create mock with more than 10 charts + const manyCharts = Array.from({ length: 12 }, (_, i) => ({ + id: 100 + i, + slice_name: `Chart ${i + 1}`, + })); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + fetchMock.get( + `glob:*/api/v1/dataset/${dataset.id}/related_objects*`, + { + charts: { count: 12, result: manyCharts }, + dashboards: { count: 0, result: [] }, + }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + const table = screen.getByTestId('listview-table'); + const deleteButton = await within(table).findByTestId('delete'); + await userEvent.click(deleteButton); + + // Wait for modal + const modal = await screen.findByRole('dialog'); + + // Verify "Affected Charts" header + expect(within(modal).getByText('Affected Charts')).toBeInTheDocument(); + + // Verify first 10 charts are shown + expect(within(modal).getByText('Chart 1')).toBeInTheDocument(); + expect(within(modal).getByText('Chart 10')).toBeInTheDocument(); + + // Verify overflow message + expect(within(modal).getByText(/\.\.\. and 2 others/)).toBeInTheDocument(); + + // Verify Chart 11+ are NOT shown + expect(within(modal).queryByText('Chart 11')).not.toBeInTheDocument(); +}); diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.permissions.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.permissions.test.tsx new file mode 100644 index 00000000000..a997ff9a551 --- /dev/null +++ b/superset-frontend/src/pages/DatasetList/DatasetList.permissions.test.tsx @@ -0,0 +1,414 @@ +/** + * 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 { act, screen, waitFor, within } from '@testing-library/react'; +import fetchMock from 'fetch-mock'; +import { + setupMocks, + setupApiPermissions, + renderDatasetList, + mockAdminUser, + mockReadOnlyUser, + mockWriteUser, + mockExportOnlyUser, + mockDatasets, + API_ENDPOINTS, +} from './DatasetList.testHelpers'; + +// Increase default timeout for tests that involve multiple async operations +// CI parallel load can cause timeouts with default 15s +jest.setTimeout(30000); + +beforeEach(() => { + setupMocks(); + jest.clearAllMocks(); +}); + +afterEach(async () => { + // Flush pending React state updates within act() to prevent warnings + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // Restore real timers in case a test threw early + jest.useRealTimers(); + + // Reset browser history to prevent query param leakage + window.history.replaceState({}, '', '/'); + + fetchMock.resetHistory(); + fetchMock.restore(); + jest.restoreAllMocks(); +}); + +test('admin users see all UI elements', async () => { + // Setup API with full admin permissions + setupApiPermissions(['can_read', 'can_write', 'can_export', 'can_duplicate']); + + renderDatasetList(mockAdminUser); + + expect(await screen.findByText('Datasets')).toBeInTheDocument(); + + // Admin should see create button + expect( + screen.getByRole('button', { name: /(?:plus\s*)?Dataset$/i }), + ).toBeInTheDocument(); + + // Admin should see import button + // Note: Using testId - import button lacks accessible text content + // TODO: Add aria-label or text to import button + expect(screen.getByTestId('import-button')).toBeInTheDocument(); + + // Admin should see bulk select button + expect( + screen.getByRole('button', { name: /bulk select/i }), + ).toBeInTheDocument(); + + // Admin should see actions column - wait for table first, then check column + const table = await screen.findByTestId('listview-table'); + await waitFor(() => { + expect( + within(table).getByRole('columnheader', { name: /Actions/i }), + ).toBeInTheDocument(); + }); +}, 45000); + +test('read-only users cannot see Actions column', async () => { + // Setup API with read-only permissions + setupApiPermissions(['can_read']); + + renderDatasetList(mockReadOnlyUser); + + await waitFor(() => { + expect(screen.getByText('Datasets')).toBeInTheDocument(); + }); + + await waitFor(() => { + const table = screen.getByTestId('listview-table'); + // Actions column should not be present + expect(within(table).queryByText(/Actions/i)).not.toBeInTheDocument(); + }); +}); + +test('read-only users cannot see bulk select button', async () => { + // Setup API with read-only permissions + setupApiPermissions(['can_read']); + + renderDatasetList(mockReadOnlyUser); + + await waitFor(() => { + expect(screen.getByText('Datasets')).toBeInTheDocument(); + }); + + // Bulk select should not be visible + expect( + screen.queryByRole('button', { name: /bulk select/i }), + ).not.toBeInTheDocument(); +}); + +test('read-only users cannot see Create/Import buttons', async () => { + // Setup API with read-only permissions + setupApiPermissions(['can_read']); + + renderDatasetList(mockReadOnlyUser); + + await waitFor(() => { + expect(screen.getByText('Datasets')).toBeInTheDocument(); + }); + + // Create button should not be visible + expect( + screen.queryByRole('button', { name: /(?:plus\s*)?Dataset$/i }), + ).not.toBeInTheDocument(); + + // Import button should not be visible + // Note: Using testId - import button lacks accessible text content + // TODO: Add aria-label or text to import button + expect(screen.queryByTestId('import-button')).not.toBeInTheDocument(); +}); + +test('write users see Actions column', async () => { + // Setup API with write permissions + setupApiPermissions(['can_read', 'can_write', 'can_export']); + + renderDatasetList(mockWriteUser); + + await waitFor(() => { + expect(screen.getByText('Datasets')).toBeInTheDocument(); + }); + + await waitFor(() => { + const table = screen.getByTestId('listview-table'); + expect( + within(table).getByRole('columnheader', { name: /Actions/i }), + ).toBeInTheDocument(); + }); +}); + +test('write users see bulk select button', async () => { + // Setup API with write permissions + setupApiPermissions(['can_read', 'can_write', 'can_export']); + + renderDatasetList(mockWriteUser); + + await waitFor(() => { + expect(screen.getByText('Datasets')).toBeInTheDocument(); + }); + + expect( + screen.getByRole('button', { name: /bulk select/i }), + ).toBeInTheDocument(); +}); + +test('write users see Create/Import buttons', async () => { + // Setup API with write permissions + setupApiPermissions(['can_read', 'can_write', 'can_export']); + + renderDatasetList(mockWriteUser); + + await waitFor(() => { + expect(screen.getByText('Datasets')).toBeInTheDocument(); + }); + + // Create button should be visible + expect( + screen.getByRole('button', { name: /(?:plus\s*)?Dataset$/i }), + ).toBeInTheDocument(); + + // Import button should be visible + // Note: Using testId - import button lacks accessible text content + // TODO: Add aria-label or text to import button + expect(screen.getByTestId('import-button')).toBeInTheDocument(); +}); + +test('export-only users see bulk select (for export only)', async () => { + // Setup API with export-only permissions + setupApiPermissions(['can_read', 'can_export']); + + renderDatasetList(mockExportOnlyUser); + + await waitFor(() => { + expect(screen.getByText('Datasets')).toBeInTheDocument(); + }); + + // Export users should see bulk select for export functionality + expect( + screen.getByRole('button', { name: /bulk select/i }), + ).toBeInTheDocument(); +}); + +test('export-only users cannot see Create/Import buttons', async () => { + // Setup API with export-only permissions + setupApiPermissions(['can_read', 'can_export']); + + renderDatasetList(mockExportOnlyUser); + + await waitFor(() => { + expect(screen.getByText('Datasets')).toBeInTheDocument(); + }); + + // Create and Import should not be visible for export-only users + expect( + screen.queryByRole('button', { name: /(?:plus\s*)?Dataset$/i }), + ).not.toBeInTheDocument(); + // Note: Using testId - import button lacks accessible text content + // TODO: Add aria-label or text to import button + expect(screen.queryByTestId('import-button')).not.toBeInTheDocument(); +}); + +test('action buttons respect user permissions', async () => { + // Setup API with full admin permissions + setupApiPermissions(['can_read', 'can_write', 'can_export', 'can_duplicate']); + + const dataset = mockDatasets[0]; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + // Admin should see action buttons in the row + const row = screen.getByText(dataset.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + // Verify specific action buttons are present + const deleteButton = within(row!).queryByTestId('delete'); + const exportButton = within(row!).queryByTestId('upload'); + + expect(deleteButton).toBeInTheDocument(); + expect(exportButton).toBeInTheDocument(); +}); + +test('read-only user sees no delete or duplicate buttons in row', async () => { + // Setup API with read-only permissions + setupApiPermissions(['can_read']); + + const dataset = mockDatasets[0]; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockReadOnlyUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + // Find the dataset row + const row = screen.getByText(dataset.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + // Verify no delete button in the row + const deleteButton = within(row!).queryByTestId('delete'); + expect(deleteButton).not.toBeInTheDocument(); + + // Verify no duplicate button (Actions column should not exist) + const duplicateButton = within(row!).queryByTestId('copy'); + expect(duplicateButton).not.toBeInTheDocument(); + + // Verify no edit button + const editButton = within(row!).queryByTestId('edit'); + expect(editButton).not.toBeInTheDocument(); +}); + +test('write user sees edit, delete, and export actions', async () => { + // Setup API with write permissions (includes delete) + // Note: can_write grants both edit and delete permissions in DatasetList + setupApiPermissions(['can_read', 'can_write', 'can_export']); + + const dataset = { + ...mockDatasets[0], + owners: [{ id: mockWriteUser.userId, username: 'writeuser' }], + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockWriteUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + const row = screen.getByText(dataset.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + // Should have delete button (can_write includes delete) + const deleteButton = within(row!).getByTestId('delete'); + expect(deleteButton).toBeInTheDocument(); + + // Should have export button + const exportButton = within(row!).getByTestId('upload'); + expect(exportButton).toBeInTheDocument(); + + // Should have edit button (user is owner) + const editButton = within(row!).getByTestId('edit'); + expect(editButton).toBeInTheDocument(); + + // Should NOT have duplicate button (no can_duplicate permission) + const duplicateButton = within(row!).queryByTestId('copy'); + expect(duplicateButton).not.toBeInTheDocument(); +}); + +test('export-only user has no Actions column (no write/duplicate permissions)', async () => { + // Setup API with export-only permissions + // Note: Export action alone doesn't render Actions column - it's in toolbar/bulk select + setupApiPermissions(['can_read', 'can_export']); + + const dataset = mockDatasets[0]; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockExportOnlyUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + const row = screen.getByText(dataset.table_name).closest('tr'); + expect(row).toBeInTheDocument(); + + // Actions column is hidden when user only has export permission + // (export is available via bulk select toolbar, not row actions) + const deleteButton = within(row!).queryByTestId('delete'); + expect(deleteButton).not.toBeInTheDocument(); + + const editButton = within(row!).queryByTestId('edit'); + expect(editButton).not.toBeInTheDocument(); + + const duplicateButton = within(row!).queryByTestId('copy'); + expect(duplicateButton).not.toBeInTheDocument(); + + const exportButton = within(row!).queryByTestId('upload'); + expect(exportButton).not.toBeInTheDocument(); +}); + +test('user with can_duplicate sees duplicate button only for virtual datasets', async () => { + // Setup API with duplicate permission + setupApiPermissions(['can_read', 'can_duplicate']); + + const physicalDataset = mockDatasets[0]; // kind: 'physical' + const virtualDataset = mockDatasets[1]; // kind: 'virtual' + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [physicalDataset, virtualDataset], count: 2 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(physicalDataset.table_name)).toBeInTheDocument(); + }); + + // Check physical dataset row + const physicalRow = screen + .getByText(physicalDataset.table_name) + .closest('tr'); + expect(physicalRow).toBeInTheDocument(); + + // Physical dataset should NOT have duplicate button + const physicalDuplicateButton = within(physicalRow!).queryByTestId('copy'); + expect(physicalDuplicateButton).not.toBeInTheDocument(); + + // Check virtual dataset row + const virtualRow = screen.getByText(virtualDataset.table_name).closest('tr'); + expect(virtualRow).toBeInTheDocument(); + + // Virtual dataset SHOULD have duplicate button + const virtualDuplicateButton = within(virtualRow!).getByTestId('copy'); + expect(virtualDuplicateButton).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx new file mode 100644 index 00000000000..51ad4cbab7a --- /dev/null +++ b/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx @@ -0,0 +1,567 @@ +/** + * 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 { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import rison from 'rison'; +import fetchMock from 'fetch-mock'; +import { + setupMocks, + renderDatasetList, + waitForDatasetsPageReady, + mockAdminUser, + mockReadOnlyUser, + mockExportOnlyUser, + mockDatasets, + mockApiError403, + API_ENDPOINTS, + RisonFilter, +} from './DatasetList.testHelpers'; + +// Increase default timeout for tests that involve multiple async operations +jest.setTimeout(15000); + +beforeEach(() => { + setupMocks(); +}); + +afterEach(async () => { + // Flush pending React state updates within act() to prevent warnings + // and "document global undefined" errors from async operations + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + // Restore real timers in case a test using fake timers threw early + jest.useRealTimers(); + + // Reset browser history state to prevent query params leaking between tests + window.history.replaceState({}, '', '/'); + + fetchMock.resetHistory(); + fetchMock.restore(); + jest.restoreAllMocks(); +}); + +test('renders page with "Datasets" title', async () => { + renderDatasetList(mockAdminUser); + + const title = await screen.findByText('Datasets'); + expect(title).toBeInTheDocument(); +}); + +test('shows loading state during initial data fetch', () => { + // Use fake timers to avoid leaving real timers running after test + jest.useFakeTimers(); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + new Promise(resolve => + setTimeout(() => resolve({ result: [], count: 0 }), 10000), + ), + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + expect(screen.getByRole('status')).toBeInTheDocument(); + + jest.useRealTimers(); +}); + +test('maintains component structure during loading', () => { + // Use fake timers to avoid leaving real timers running after test + jest.useFakeTimers(); + + fetchMock.get( + API_ENDPOINTS.DATASETS, + new Promise(resolve => + setTimeout(() => resolve({ result: [], count: 0 }), 10000), + ), + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + expect(screen.getByText('Datasets')).toBeInTheDocument(); + expect(screen.getByRole('status')).toBeInTheDocument(); + + jest.useRealTimers(); +}); + +test('"New Dataset" button exists (when canCreate=true)', async () => { + renderDatasetList(mockAdminUser); + + // Button has plus icon and "Dataset" text. Pattern handles both: + // - "plus Dataset" (if icon contributes to accessible name) + // - "Dataset" (if icon is aria-hidden) + // The $ anchor prevents matching future "Import Dataset" button. + expect( + await screen.findByRole('button', { name: /(?:plus\s*)?Dataset$/i }), + ).toBeInTheDocument(); +}); + +test('"New Dataset" button hidden (when canCreate=false)', async () => { + renderDatasetList(mockReadOnlyUser); + + await waitFor(() => { + expect( + screen.queryByRole('button', { name: /(?:plus\s*)?Dataset$/i }), + ).not.toBeInTheDocument(); + }); +}); + +test('"Import" button exists (when canCreate=true)', async () => { + renderDatasetList(mockAdminUser); + + // Note: Using testId - import button lacks accessible text content + // TODO: Add aria-label or text to import button + expect(await screen.findByTestId('import-button')).toBeInTheDocument(); +}); + +test('"Import" button opens import modal', async () => { + renderDatasetList(mockAdminUser); + + // Note: Using testId - import button lacks accessible text content + // TODO: Add aria-label or text to import button + const importButton = await screen.findByTestId('import-button'); + expect(importButton).toBeInTheDocument(); + + await userEvent.click(importButton); + + // Modal should appear with title - using semantic query here + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Import dataset')).toBeInTheDocument(); +}); + +test('"Bulk select" button exists (when canDelete || canExport)', async () => { + renderDatasetList(mockAdminUser); + + expect( + await screen.findByRole('button', { name: /bulk select/i }), + ).toBeInTheDocument(); +}); + +test('"Bulk select" button exists for export-only users', async () => { + renderDatasetList(mockExportOnlyUser); + + // Wait for table to render first (deterministic readiness check) + await screen.findByTestId('listview-table'); + + expect( + await screen.findByRole('button', { name: /bulk select/i }), + ).toBeInTheDocument(); +}); + +test('"Bulk select" button hidden for read-only users', async () => { + renderDatasetList(mockReadOnlyUser); + + await waitFor(() => { + expect( + screen.queryByRole('button', { name: /bulk select/i }), + ).not.toBeInTheDocument(); + }); +}); + +test('renders Name search filter', async () => { + renderDatasetList(mockAdminUser); + + // Note: Using testId - search input lacks accessible label + // TODO: Add aria-label to search input + expect( + await screen.findByTestId('search-filter-container'), + ).toBeInTheDocument(); +}); + +test('renders Type filter (Virtual/Physical dropdown)', async () => { + renderDatasetList(mockAdminUser); + + // Filter dropdowns should be present + const filters = await screen.findAllByRole('combobox'); + expect(filters.length).toBeGreaterThan(0); +}); + +test('handles datasets with missing fields and renders gracefully', async () => { + const datasetWithMissingFields = { + id: 999, + table_name: 'Incomplete Dataset', + kind: 'physical', + schema: null, + database: { + id: '1', + database_name: 'PostgreSQL', + }, + owners: [], + changed_by_name: 'Unknown', + changed_by: null, + changed_on_delta_humanized: 'Unknown', + explore_url: '/explore/?datasource=999__table', + extra: JSON.stringify({}), + sql: null, + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [datasetWithMissingFields], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText('Incomplete Dataset')).toBeInTheDocument(); + }); + + // Verify empty owners renders without crashing (no FacePile) + const table = screen.getByRole('table'); + expect(table).toBeInTheDocument(); + + // Verify the row exists even with missing data + const datasetRow = screen.getByText('Incomplete Dataset').closest('tr'); + expect(datasetRow).toBeInTheDocument(); + + // Verify no certification badge or warning icon (extra is empty) + expect( + screen.queryByRole('img', { name: /certified/i }), + ).not.toBeInTheDocument(); +}); + +test('handles empty results (shows empty state)', async () => { + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [], count: 0 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + // Datasets heading should still be present + expect(await screen.findByText('Datasets')).toBeInTheDocument(); +}); + +test('makes correct initial API call on load', async () => { + renderDatasetList(mockAdminUser); + + await waitFor(() => { + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + expect(calls.length).toBeGreaterThan(0); + }); +}); + +test('API call includes correct page size', async () => { + renderDatasetList(mockAdminUser); + + await waitFor(() => { + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + expect(calls.length).toBeGreaterThan(0); + const url = calls[0][0] as string; + expect(url).toContain('page_size'); + }); +}); + +test('typing in name filter updates input value and triggers API with decoded search filter', async () => { + renderDatasetList(mockAdminUser); + + const searchContainer = await screen.findByTestId('search-filter-container'); + const searchInput = within(searchContainer).getByRole('textbox'); + + // Record initial API calls + const initialCallCount = fetchMock.calls(API_ENDPOINTS.DATASETS).length; + + // Type in search box and press Enter to trigger search + await userEvent.type(searchInput, 'sales{enter}'); + + // Verify input value updated + await waitFor(() => { + expect(searchInput).toHaveValue('sales'); + }); + + // Wait for API call after Enter key press + await waitFor( + () => { + const calls = fetchMock.calls(API_ENDPOINTS.DATASETS); + expect(calls.length).toBeGreaterThan(initialCallCount); + + // Get latest API call + const url = calls[calls.length - 1][0] as string; + + // Verify URL contains search filter + expect(url).toContain('filters'); + + // Extract and decode rison query param using URL parser + const queryString = new URL(url, 'http://localhost').searchParams.get( + 'q', + ); + expect(queryString).toBeTruthy(); + + // searchParams.get() already URL-decodes, so pass directly to rison.decode + const decoded = rison.decode(queryString!) as Record; + + // Verify filter structure contains table_name search + expect(decoded.filters).toBeDefined(); + expect(Array.isArray(decoded.filters)).toBe(true); + + // Check for sales filter in the filters array + const filters = decoded.filters as RisonFilter[]; + const hasSalesFilter = filters.some( + (filter: RisonFilter) => + filter.col === 'table_name' && + filter.opr === 'ct' && + typeof filter.value === 'string' && + filter.value.toLowerCase().includes('sales'), + ); + expect(hasSalesFilter).toBe(true); + }, + { timeout: 5000 }, + ); +}); + +test('toggling bulk select mode shows checkboxes', async () => { + renderDatasetList(mockAdminUser); + + const bulkSelectButton = await screen.findByRole('button', { + name: /bulk select/i, + }); + + await userEvent.click(bulkSelectButton); + + await waitFor(() => { + // When bulk select is active, checkboxes should appear + const checkboxes = screen.queryAllByRole('checkbox'); + expect(checkboxes.length).toBeGreaterThan(0); + }); +}, 30000); + +test('handles 500 error on initial load without crashing', async () => { + fetchMock.get( + API_ENDPOINTS.DATASETS, + { throws: new Error('Internal Server Error') }, + { + overwriteRoutes: true, + }, + ); + + renderDatasetList(mockAdminUser, { + addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), + }); + + // Component should still render without crashing + const title = await screen.findByText('Datasets'); + expect(title).toBeInTheDocument(); +}); + +test('handles 403 error on _info endpoint and disables create actions', async () => { + const addDangerToast = jest.fn(); + + fetchMock.get(API_ENDPOINTS.DATASETS_INFO, mockApiError403, { + overwriteRoutes: true, + }); + + renderDatasetList(mockAdminUser, { + addDangerToast, + addSuccessToast: jest.fn(), + }); + + await waitForDatasetsPageReady(); + + // Verify bulk actions are disabled/hidden when permissions fail + await waitFor(() => { + const bulkSelectButton = screen.queryByRole('button', { + name: /bulk select/i, + }); + // Bulk select should not appear without proper permissions + expect(bulkSelectButton).not.toBeInTheDocument(); + }); +}); + +test('handles network timeout without crashing', async () => { + fetchMock.get( + API_ENDPOINTS.DATASETS, + { throws: new Error('Network timeout') }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser, { + addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), + }); + + // Component should not crash + const title = await screen.findByText('Datasets'); + expect(title).toBeInTheDocument(); +}); + +test('component requires explicit mocks for all API endpoints', async () => { + // Use standard mocks + setupMocks(); + + // Clear call history to start fresh + fetchMock.resetHistory(); + + // Render component with standard setup + renderDatasetList(mockAdminUser); + + // Wait for initial data load + await waitForDatasetsPageReady(); + + // Verify that critical endpoints were called and had mocks available + const newDatasetsCalls = fetchMock.calls(API_ENDPOINTS.DATASETS); + const newInfoCalls = fetchMock.calls(API_ENDPOINTS.DATASETS_INFO); + + // These should have been called during render + expect(newDatasetsCalls.length).toBeGreaterThan(0); + expect(newInfoCalls.length).toBeGreaterThan(0); + + // Verify no unmatched calls (all endpoints were mocked) + const unmatchedCalls = fetchMock.calls(false); // false = unmatched only + expect(unmatchedCalls.length).toBe(0); +}); + +test('selecting Database filter triggers API call with database relation filter', async () => { + renderDatasetList(mockAdminUser); + + await waitForDatasetsPageReady(); + + const filtersContainers = screen.getAllByRole('combobox'); + expect(filtersContainers.length).toBeGreaterThan(0); +}); + +test('renders datasets with certification data', async () => { + const certifiedDataset = { + ...mockDatasets[1], // mockDatasets[1] has certification + extra: JSON.stringify({ + certification: { + certified_by: 'Data Team', + details: 'Approved for production', + }, + }), + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [certifiedDataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(certifiedDataset.table_name)).toBeInTheDocument(); + }); + + // Verify the dataset row renders successfully + const datasetRow = screen + .getByText(certifiedDataset.table_name) + .closest('tr'); + expect(datasetRow).toBeInTheDocument(); +}); + +test('displays datasets with warning_markdown', async () => { + const warningText = 'This dataset contains PII. Handle with care.'; + const datasetWithWarning = { + ...mockDatasets[2], // mockDatasets[2] has warning + extra: JSON.stringify({ + warning_markdown: warningText, + }), + }; + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [datasetWithWarning], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(datasetWithWarning.table_name)).toBeInTheDocument(); + }); + + // Verify the dataset row exists + const datasetRow = screen + .getByText(datasetWithWarning.table_name) + .closest('tr'); + expect(datasetRow).toBeInTheDocument(); +}); + +test('displays dataset with multiple owners', async () => { + const datasetWithOwners = mockDatasets[1]; // Has 2 owners: Jane Smith, Bob Jones + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [datasetWithOwners], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(datasetWithOwners.table_name)).toBeInTheDocument(); + }); + + // Verify row exists with the dataset + const datasetRow = screen + .getByText(datasetWithOwners.table_name) + .closest('tr'); + expect(datasetRow).toBeInTheDocument(); +}); + +test('displays ModifiedInfo with humanized date', async () => { + const datasetWithModified = mockDatasets[0]; // changed_by_name: 'John Doe', changed_on: '1 day ago' + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [datasetWithModified], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect( + screen.getByText(datasetWithModified.table_name), + ).toBeInTheDocument(); + }); + + // Verify humanized date appears (ModifiedInfo component renders it) + expect( + screen.getByText(datasetWithModified.changed_on_delta_humanized), + ).toBeInTheDocument(); +}); + +test('dataset name links to Explore with correct explore_url', async () => { + const dataset = mockDatasets[0]; // explore_url: '/explore/?datasource=1__table' + + fetchMock.get( + API_ENDPOINTS.DATASETS, + { result: [dataset], count: 1 }, + { overwriteRoutes: true }, + ); + + renderDatasetList(mockAdminUser); + + await waitFor(() => { + expect(screen.getByText(dataset.table_name)).toBeInTheDocument(); + }); + + // Find the dataset name link (should be a link role) + const exploreLink = screen.getByRole('link', { name: dataset.table_name }); + expect(exploreLink).toBeInTheDocument(); + expect(exploreLink).toHaveAttribute('href', dataset.explore_url); +}); diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.testHelpers.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.testHelpers.tsx new file mode 100644 index 00000000000..844ad8f9163 --- /dev/null +++ b/superset-frontend/src/pages/DatasetList/DatasetList.testHelpers.tsx @@ -0,0 +1,539 @@ +/** + * 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. + */ +// eslint-disable-next-line import/no-extraneous-dependencies +import fetchMock from 'fetch-mock'; +import { render, screen } from 'spec/helpers/testing-library'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { configureStore } from '@reduxjs/toolkit'; +import { QueryParamProvider } from 'use-query-params'; +import DatasetList from 'src/pages/DatasetList'; +import handleResourceExport from 'src/utils/export'; + +export const mockHandleResourceExport = + handleResourceExport as jest.MockedFunction; + +// Type definitions for test helpers +export interface UserState { + userId: string | number; + firstName: string; + lastName: string; + [key: string]: unknown; // Allow additional properties like roles +} + +export interface RisonFilter { + col: string; + opr: string; + value: string | number | boolean; +} + +// Test-only dataset type that matches the VirtualDataset interface from index.tsx +// Includes extra/sql fields that exist in actual API responses +export interface DatasetFixture { + id: number; + table_name: string; + kind: string; + schema: string; + database: { + id: string; + database_name: string; + }; + owners: Array<{ first_name: string; last_name: string; id: number }>; + changed_by_name: string; + changed_by: { + first_name: string; + last_name: string; + id: number; + }; + changed_on_delta_humanized: string; + explore_url: string; + extra: string; // JSON-serialized metadata (always present in API) + sql: string | null; // SQL query for virtual datasets + description?: string; // Optional description field +} + +interface StoreState { + user?: UserState; + common?: { + conf?: { + SUPERSET_WEBSERVER_TIMEOUT?: number; + PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET?: boolean; + }; + }; + datasets?: { + datasetList?: typeof mockDatasets; + }; +} + +interface DatasetListPropsOverrides { + addDangerToast?: (msg: string) => void; + addSuccessToast?: (msg: string) => void; + user?: UserState; +} + +export const mockDatasets: DatasetFixture[] = [ + { + id: 1, + table_name: 'public.sales_data', + kind: 'physical', + schema: 'public', + database: { + id: '1', + database_name: 'PostgreSQL', + }, + owners: [{ first_name: 'John', last_name: 'Doe', id: 1 }], + changed_by_name: 'John Doe', + changed_by: { + first_name: 'John', + last_name: 'Doe', + id: 1, + }, + changed_on_delta_humanized: '1 day ago', + explore_url: '/explore/?datasource=1__table', + extra: JSON.stringify({}), + sql: null, + }, + { + id: 2, + table_name: 'Analytics Query', + kind: 'virtual', + schema: 'analytics', + database: { + id: '2', + database_name: 'MySQL', + }, + owners: [ + { first_name: 'Jane', last_name: 'Smith', id: 2 }, + { first_name: 'Bob', last_name: 'Jones', id: 3 }, + ], + changed_by_name: 'Jane Smith', + changed_by: { + first_name: 'Jane', + last_name: 'Smith', + id: 2, + }, + changed_on_delta_humanized: '2 hours ago', + explore_url: '/explore/?datasource=2__table', + extra: JSON.stringify({ + certification: { + certified_by: 'Data Team', + details: 'Approved for production use', + }, + }), + sql: 'SELECT * FROM analytics_table WHERE date >= current_date - 30', + }, + { + id: 3, + table_name: 'Customer Metrics', + kind: 'virtual', + schema: 'metrics', + database: { + id: '1', + database_name: 'PostgreSQL', + }, + owners: [], + changed_by_name: 'System', + changed_by: { + first_name: 'System', + last_name: 'User', + id: 999, + }, + changed_on_delta_humanized: '5 days ago', + explore_url: '/explore/?datasource=3__table', + extra: JSON.stringify({ + warning_markdown: 'This dataset contains PII. Handle with care.', + }), + sql: 'SELECT customer_id, COUNT(*) FROM orders GROUP BY customer_id', + }, + { + id: 4, + table_name: 'public.product_catalog', + kind: 'physical', + schema: 'public', + database: { + id: '3', + database_name: 'Redshift', + }, + owners: [{ first_name: 'Alice', last_name: 'Johnson', id: 4 }], + changed_by_name: 'Alice Johnson', + changed_by: { + first_name: 'Alice', + last_name: 'Johnson', + id: 4, + }, + changed_on_delta_humanized: '3 weeks ago', + explore_url: '/explore/?datasource=4__table', + extra: JSON.stringify({ + certification: { + certified_by: 'QA Team', + details: 'Verified data quality', + }, + warning_markdown: 'Data refreshed weekly on Sundays', + }), + sql: null, + }, + { + id: 5, + table_name: 'Quarterly Report', + kind: 'virtual', + schema: 'reports', + database: { + id: '2', + database_name: 'MySQL', + }, + owners: [ + { first_name: 'Charlie', last_name: 'Brown', id: 5 }, + { first_name: 'David', last_name: 'Lee', id: 6 }, + { first_name: 'Eve', last_name: 'Taylor', id: 7 }, + { first_name: 'Frank', last_name: 'Wilson', id: 8 }, + ], + changed_by_name: 'Charlie Brown', + changed_by: { + first_name: 'Charlie', + last_name: 'Brown', + id: 5, + }, + changed_on_delta_humanized: '1 month ago', + explore_url: '/explore/?datasource=5__table', + extra: JSON.stringify({}), + sql: 'SELECT quarter, SUM(revenue) FROM sales GROUP BY quarter', + }, +]; + +// Mock users with various permission levels +export const mockAdminUser = { + userId: 1, + firstName: 'Admin', + lastName: 'User', + roles: { + Admin: [ + ['can_read', 'Dataset'], + ['can_write', 'Dataset'], + ['can_export', 'Dataset'], + ['can_duplicate', 'Dataset'], + ], + }, +}; + +export const mockOwnerUser = { + userId: 1, + firstName: 'John', + lastName: 'Doe', + roles: { + Alpha: [ + ['can_read', 'Dataset'], + ['can_write', 'Dataset'], + ['can_export', 'Dataset'], + ['can_duplicate', 'Dataset'], + ], + }, +}; + +export const mockReadOnlyUser = { + userId: 10, + firstName: 'Read', + lastName: 'Only', + roles: { + Gamma: [['can_read', 'Dataset']], + }, +}; + +export const mockExportOnlyUser = { + userId: 11, + firstName: 'Export', + lastName: 'User', + roles: { + Gamma: [ + ['can_read', 'Dataset'], + ['can_export', 'Dataset'], + ], + }, +}; + +export const mockWriteUser = { + userId: 9, + firstName: 'Write', + lastName: 'User', + roles: { + Alpha: [ + ['can_read', 'Dataset'], + ['can_write', 'Dataset'], + ['can_export', 'Dataset'], + ], + }, +}; + +// Mock related objects for delete modal +export const mockRelatedCharts = { + count: 3, + result: [ + { id: 101, slice_name: 'Sales Chart' }, + { id: 102, slice_name: 'Revenue Chart' }, + { id: 103, slice_name: 'Analytics Chart' }, + ], +}; + +export const mockRelatedDashboards = { + count: 2, + result: [ + { id: 201, title: 'Executive Dashboard' }, + { id: 202, title: 'Sales Dashboard' }, + ], +}; + +// Mock API error responses +export const mockApiError500 = { + status: 500, + body: { message: 'Internal Server Error' }, +}; + +export const mockApiError403 = { + status: 403, + body: { message: 'Forbidden' }, +}; + +export const mockApiError404 = { + status: 404, + body: { message: 'Not Found' }, +}; + +// API endpoint constants +export const API_ENDPOINTS = { + DATASETS_INFO: 'glob:*/api/v1/dataset/_info*', + DATASETS: 'glob:*/api/v1/dataset/?*', + DATASET_GET: 'glob:*/api/v1/dataset/[0-9]*', + DATASET_RELATED_OBJECTS: 'glob:*/api/v1/dataset/*/related_objects*', + DATASET_DELETE: 'glob:*/api/v1/dataset/[0-9]*', + DATASET_BULK_DELETE: 'glob:*/api/v1/dataset/?q=*', // Matches DELETE /api/v1/dataset/?q=... + DATASET_DUPLICATE: 'glob:*/api/v1/dataset/duplicate*', + DATASET_FAVORITE_STATUS: 'glob:*/api/v1/dataset/favorite_status*', + DATASET_RELATED_DATABASE: 'glob:*/api/v1/dataset/related/database*', + DATASET_RELATED_SCHEMA: 'glob:*/api/v1/dataset/distinct/schema*', + DATASET_RELATED_OWNERS: 'glob:*/api/v1/dataset/related/owners*', + DATASET_RELATED_CHANGED_BY: 'glob:*/api/v1/dataset/related/changed_by*', +}; + +// Setup API permissions mock (for permission-based testing) +export const setupApiPermissions = (permissions: string[]) => { + fetchMock.get( + API_ENDPOINTS.DATASETS_INFO, + { permissions }, + { overwriteRoutes: true }, + ); +}; + +// Store utilities +export const createMockStore = (initialState: Partial = {}) => + configureStore({ + reducer: { + user: (state = initialState.user || {}) => state, + common: (state = initialState.common || {}) => state, + datasets: (state = initialState.datasets || {}) => state, + }, + preloadedState: initialState, + middleware: getDefaultMiddleware => + getDefaultMiddleware({ + serializableCheck: false, + immutableCheck: false, + }), + }); + +export const createDefaultStoreState = (user: UserState): StoreState => ({ + user, + common: { + conf: { + SUPERSET_WEBSERVER_TIMEOUT: 60000, + PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: false, + }, + }, + datasets: { + datasetList: mockDatasets, + }, +}); + +export const renderDatasetList = ( + user: UserState, + props: Partial = {}, + storeState: Partial = {}, +) => { + const defaultStoreState = createDefaultStoreState(user); + const storeStateWithUser = { + ...defaultStoreState, + user, + ...storeState, + }; + + const store = createMockStore(storeStateWithUser); + + return render( + + + + + + + , + ); +}; + +/** + * Helper to wait for the DatasetList page to be ready + * Waits for the "Datasets" heading to appear, indicating initial render is complete + */ +export const waitForDatasetsPageReady = async () => { + await screen.findByText('Datasets'); +}; + +// Helper functions for specific operations +export const setupDeleteMocks = (datasetId: number) => { + fetchMock.get( + `glob:*/api/v1/dataset/${datasetId}/related_objects*`, + { + charts: mockRelatedCharts, + dashboards: mockRelatedDashboards, + }, + { overwriteRoutes: true }, + ); + + fetchMock.delete( + `glob:*/api/v1/dataset/${datasetId}`, + { message: 'Dataset deleted successfully' }, + { overwriteRoutes: true }, + ); +}; + +export const setupDuplicateMocks = () => { + fetchMock.post( + API_ENDPOINTS.DATASET_DUPLICATE, + { id: 999, table_name: 'Copy of Dataset' }, + { overwriteRoutes: true }, + ); +}; + +export const setupBulkDeleteMocks = () => { + fetchMock.delete( + API_ENDPOINTS.DATASET_BULK_DELETE, + { message: '3 datasets deleted successfully' }, + { overwriteRoutes: true }, + ); +}; + +// Setup error mocks for negative flow testing +export const setupDeleteErrorMocks = ( + datasetId: number, + statusCode: number, +) => { + fetchMock.get( + `glob:*/api/v1/dataset/${datasetId}/related_objects*`, + { + status: statusCode, + body: { message: 'Failed to fetch related objects' }, + }, + { overwriteRoutes: true }, + ); +}; + +export const setupDuplicateErrorMocks = (statusCode: number) => { + fetchMock.post( + API_ENDPOINTS.DATASET_DUPLICATE, + { + status: statusCode, + body: { message: 'Failed to duplicate dataset' }, + }, + { overwriteRoutes: true }, + ); +}; + +/** + * Helper function to verify only expected API calls were made + * Replaces global fail-fast fetchMock.catch() with test-specific assertions + * + * @param expectedEndpoints - Array of endpoint glob patterns that should have been called + * @throws If any unmocked endpoints were called or expected endpoints weren't called + */ +export const assertOnlyExpectedCalls = (expectedEndpoints: string[]) => { + const allCalls = fetchMock.calls(true); // Get all calls including unmatched + const unmatchedCalls = allCalls.filter(call => call.isUnmatched); + + if (unmatchedCalls.length > 0) { + const unmatchedUrls = unmatchedCalls.map(call => call[0]); + throw new Error( + `Unmocked endpoints called: ${unmatchedUrls.join(', ')}. ` + + 'Add explicit mocks in setupMocks() or test setup.', + ); + } + + // Verify expected endpoints were called + expectedEndpoints.forEach(endpoint => { + const calls = fetchMock.calls(endpoint); + if (calls.length === 0) { + throw new Error( + `Expected endpoint not called: ${endpoint}. ` + + 'Check if component logic changed or mock is incorrectly configured.', + ); + } + }); +}; + +// MSW setup using fetch-mock (following ChartList pattern) +export const setupMocks = () => { + fetchMock.reset(); + + fetchMock.get(API_ENDPOINTS.DATASETS_INFO, { + permissions: ['can_read', 'can_write', 'can_export', 'can_duplicate'], + }); + + fetchMock.get(API_ENDPOINTS.DATASETS, { + result: mockDatasets, + count: mockDatasets.length, + }); + + fetchMock.get(API_ENDPOINTS.DATASET_FAVORITE_STATUS, { + result: [], + }); + + fetchMock.get(API_ENDPOINTS.DATASET_RELATED_DATABASE, { + result: [ + { value: 1, text: 'PostgreSQL' }, + { value: 2, text: 'MySQL' }, + { value: 3, text: 'Redshift' }, + ], + count: 3, + }); + + fetchMock.get(API_ENDPOINTS.DATASET_RELATED_SCHEMA, { + result: [ + { value: 'public', text: 'public' }, + { value: 'analytics', text: 'analytics' }, + { value: 'metrics', text: 'metrics' }, + { value: 'reports', text: 'reports' }, + ], + count: 4, + }); + + fetchMock.get(API_ENDPOINTS.DATASET_RELATED_OWNERS, { + result: [], + count: 0, + }); + + fetchMock.get(API_ENDPOINTS.DATASET_RELATED_CHANGED_BY, { + result: [], + count: 0, + }); +};