Files
superset2/superset-frontend/src/pages/FileHandler/index.test.tsx
2026-05-20 19:31:01 -07:00

424 lines
12 KiB
TypeScript

/**
* 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 { ComponentType } from 'react';
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { MemoryRouter, Route } from 'react-router-dom';
import FileHandler from './index';
const mockAddDangerToast = jest.fn();
const mockAddSuccessToast = jest.fn();
const mockHistoryPush = jest.fn();
jest.setTimeout(60000);
type ToastInjectedProps = {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
};
// Mock the withToasts HOC
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
default: (Component: ComponentType<ToastInjectedProps>) =>
function MockedWithToasts(props: Record<string, unknown>) {
return (
<Component
{...props}
addDangerToast={mockAddDangerToast}
addSuccessToast={mockAddSuccessToast}
/>
);
},
}));
interface UploadDataModalProps {
show: boolean;
onHide: () => void;
type: string;
allowedExtensions: string[];
fileListOverride?: File[];
}
// Mock the UploadDataModal
jest.mock('src/features/databases/UploadDataModel', () => ({
__esModule: true,
default: ({
show,
onHide,
type,
allowedExtensions,
fileListOverride,
}: UploadDataModalProps) => (
<div data-test="upload-modal">
<div data-test="modal-show">{show.toString()}</div>
<div data-test="modal-type">{type}</div>
<div data-test="modal-extensions">{allowedExtensions.join(',')}</div>
<div data-test="modal-file">{fileListOverride?.[0]?.name ?? ''}</div>
<button onClick={onHide} type="button">
Close
</button>
</div>
),
}));
// Mock react-router-dom's useHistory
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
push: mockHistoryPush,
}),
}));
// Mock the File API
type MockFileHandle = {
kind: 'file';
name: string;
getFile: () => Promise<File>;
isSameEntry: () => Promise<boolean>;
queryPermission: () => Promise<PermissionState>;
requestPermission: () => Promise<PermissionState>;
};
const createMockFileHandle = (fileName: string): MockFileHandle => ({
kind: 'file',
name: fileName,
getFile: async () => new File(['test'], fileName),
isSameEntry: async () => false,
queryPermission: async () => 'granted',
requestPermission: async () => 'granted',
});
type LaunchQueue = {
setConsumer: (
consumer: (params: { files?: MockFileHandle[] }) => void,
) => void;
};
const pendingTimerIds = new Set<ReturnType<typeof setTimeout>>();
const MAX_CONSUMER_POLL_ATTEMPTS = 50;
const consumerPromises: Promise<void>[] = [];
// Defer the consumer call to a macrotask so it doesn't fire synchronously inside
// the component's useEffect — calling it inline deadlocks Jest because the
// MessageChannel mock in jsDomWithFetchAPI forces React to schedule via setTimeout.
const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
let savedConsumer:
| ((params: { files?: MockFileHandle[] }) => void | Promise<void>)
| null = null;
(window as unknown as Window & { launchQueue: LaunchQueue }).launchQueue = {
setConsumer: (consumer: (params: { files?: MockFileHandle[] }) => void) => {
savedConsumer = consumer;
if (fileHandle) {
const id = setTimeout(() => {
pendingTimerIds.delete(id);
consumerPromises.push(
Promise.resolve(consumer({ files: [fileHandle] })).then(
() => undefined,
),
);
}, 0);
pendingTimerIds.add(id);
}
},
};
return {
triggerConsumer: async (params: { files?: MockFileHandle[] }) => {
// In slower CI runners, useEffect may not have registered the consumer yet.
// Wait briefly for it before triggering.
let attempts = 0;
while (!savedConsumer && attempts < MAX_CONSUMER_POLL_ATTEMPTS) {
// eslint-disable-next-line no-await-in-loop
await new Promise(resolve => {
setTimeout(resolve, 0);
});
attempts += 1;
}
if (!savedConsumer) {
throw new Error(
`LaunchQueue consumer was never registered after ${MAX_CONSUMER_POLL_ATTEMPTS} polling attempts`,
);
}
await savedConsumer(params);
},
};
};
beforeEach(() => {
jest.clearAllMocks();
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
.launchQueue;
});
afterEach(async () => {
pendingTimerIds.forEach(id => clearTimeout(id));
pendingTimerIds.clear();
if (consumerPromises.length > 0) {
const results = await Promise.allSettled(consumerPromises);
results.forEach(r => {
if (r.status === 'rejected') {
// eslint-disable-next-line no-console
console.warn('LaunchQueue consumer rejected:', r.reason);
}
});
consumerPromises.length = 0;
}
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
.launchQueue;
});
test('shows error when launchQueue is not supported', async () => {
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'File handling is not supported in this browser. Please use a modern browser like Chrome or Edge.',
);
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
});
});
test('redirects when no files are provided', async () => {
const { triggerConsumer } = setupLaunchQueue();
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
// Trigger the consumer with no files
await triggerConsumer({ files: [] });
await waitFor(() => {
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
});
});
// eslint-disable-next-line jest/no-disabled-tests
test.skip('handles CSV file correctly', async () => {
const fileHandle = createMockFileHandle('test.csv');
setupLaunchQueue(fileHandle);
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
const modal = await screen.findByTestId('upload-modal');
expect(modal).toBeInTheDocument();
expect(screen.getByTestId('modal-show')).toHaveTextContent('true');
expect(screen.getByTestId('modal-type')).toHaveTextContent('csv');
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('csv');
expect(screen.getByTestId('modal-file')).toHaveTextContent('test.csv');
});
test('handles Excel (.xls) file correctly', async () => {
const fileHandle = createMockFileHandle('test.xls');
setupLaunchQueue(fileHandle);
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
const modal = await screen.findByTestId('upload-modal');
expect(modal).toBeInTheDocument();
expect(screen.getByTestId('modal-type')).toHaveTextContent('excel');
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('xls,xlsx');
});
test('handles Excel (.xlsx) file correctly', async () => {
const fileHandle = createMockFileHandle('test.xlsx');
const { triggerConsumer } = setupLaunchQueue();
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
await triggerConsumer({ files: [fileHandle] });
const modal = await screen.findByTestId('upload-modal');
expect(modal).toBeInTheDocument();
expect(screen.getByTestId('modal-type')).toHaveTextContent('excel');
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('xls,xlsx');
}, 60000);
test('handles Parquet file correctly', async () => {
const fileHandle = createMockFileHandle('test.parquet');
setupLaunchQueue(fileHandle);
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
const modal = await screen.findByTestId('upload-modal');
expect(modal).toBeInTheDocument();
expect(screen.getByTestId('modal-type')).toHaveTextContent('columnar');
expect(screen.getByTestId('modal-extensions')).toHaveTextContent('parquet');
});
test('shows error for unsupported file type', async () => {
const { triggerConsumer } = setupLaunchQueue();
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
// Trigger with unsupported file
const fileHandle = createMockFileHandle('test.pdf');
await triggerConsumer({ files: [fileHandle] });
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'Unsupported file type. Please use CSV, Excel, or Columnar files.',
);
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
});
});
test('handles file with uppercase extension', async () => {
const fileHandle = createMockFileHandle('test.CSV');
setupLaunchQueue(fileHandle);
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
const modal = await screen.findByTestId('upload-modal');
expect(modal).toBeInTheDocument();
expect(screen.getByTestId('modal-type')).toHaveTextContent('csv');
});
test('handles errors during file processing', async () => {
const { triggerConsumer } = setupLaunchQueue();
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
// Trigger with a file handle that throws an error
const errorFileHandle: MockFileHandle = {
kind: 'file',
name: 'error.csv',
getFile: async () => {
throw new Error('File access denied');
},
isSameEntry: async () => false,
queryPermission: async () => 'granted',
requestPermission: async () => 'granted',
};
await triggerConsumer({ files: [errorFileHandle] });
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'Failed to open file. Please try again.',
);
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
});
});
test('modal close redirects to welcome page', async () => {
const fileHandle = createMockFileHandle('test.csv');
setupLaunchQueue(fileHandle);
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
const modal = await screen.findByTestId('upload-modal');
expect(modal).toBeInTheDocument();
// Click the close button in the mocked modal
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
await waitFor(() => {
expect(mockHistoryPush).toHaveBeenCalledWith('/superset/welcome/');
});
});
test('shows loading state while waiting for file', () => {
setupLaunchQueue();
render(
<MemoryRouter initialEntries={['/superset/file-handler']}>
<Route path="/superset/file-handler">
<FileHandler />
</Route>
</MemoryRouter>,
{ useRedux: true },
);
// Should show loading initially before file is processed
expect(screen.getByRole('status')).toBeInTheDocument();
});