Files
MyGoNavi/frontend/src/components/DataImportWorkbench.test.tsx
Syngnat 16a601e6a4 feat(data-import): 完善导入工作台与任务交互
- 表导入与数据库导入统一提供遇错停止和继续策略,并跟随主题变量

- 增加能力校验、高级解析选项、字节进度、停止状态与持久任务历史

- 防止重复启动和运行中面板卸载,完善六语言语句统计及回归测试
2026-08-08 20:51:59 +08:00

1351 lines
46 KiB
TypeScript

import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import DataImportWorkbench from './DataImportWorkbench';
import { DEFAULT_DATA_IMPORT_PREFERENCES } from './dataImportPreferences';
const mocks = vi.hoisted(() => ({
dataImportCapability: vi.fn(),
dbGetDatabases: vi.fn(),
dbGetTables: vi.fn(),
importData: vi.fn(),
selectSQLFileForExecution: vi.fn(),
messageError: vi.fn(),
messageSuccess: vi.fn(),
addTab: vi.fn(),
storeState: {
theme: 'light',
connections: [] as any[],
addTab: (...args: any[]) => mocks.addTab(...args),
},
}));
vi.mock('../store', () => ({
useStore: (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState),
}));
vi.mock('../../wailsjs/go/app/App', () => ({
DataImportCapability: mocks.dataImportCapability,
DBGetDatabases: mocks.dbGetDatabases,
DBGetTables: mocks.dbGetTables,
ImportData: mocks.importData,
SelectSQLFileForExecution: mocks.selectSQLFileForExecution,
}));
vi.mock('./DatabaseImportExecutionPanel', () => ({
default: (props: Record<string, unknown>) => {
const [runnerStatus, setRunnerStatus] = React.useState('idle');
return React.createElement(
'mock-database-import-execution-panel',
{
'data-database-import-execution-panel-mock': 'true',
'data-mock-runner-status': runnerStatus,
onMockRunnerStatusChange: setRunnerStatus,
...props,
},
);
},
}));
vi.mock('./ImportPreviewModal', () => ({
default: (props: Record<string, unknown>) => React.createElement(
'mock-import-preview',
{ 'data-import-preview-mock': 'true', ...props },
),
}));
vi.mock('./ImportJobHistoryPanel', () => ({
default: (props: Record<string, unknown>) => React.createElement(
'mock-import-job-history',
{ 'data-import-job-history-mock': 'true', ...props },
),
}));
vi.mock('antd', async () => {
const React = await import('react');
const Select = (props: Record<string, unknown>) => React.createElement('mock-select', props);
const Segmented = (props: Record<string, unknown>) => React.createElement('mock-segmented', props);
const Button = ({ children, ...props }: any) => <button {...props}>{children}</button>;
const Checkbox = ({ children, ...props }: any) => React.createElement('mock-checkbox', props, children);
const Alert = (props: Record<string, unknown>) => React.createElement('mock-alert', props);
const Empty = ({ description, ...props }: any) => React.createElement('mock-empty', props, description);
Empty.PRESENTED_IMAGE_SIMPLE = 'simple';
const Text = ({ children, ...props }: any) => <span {...props}>{children}</span>;
const Title = ({ children, ...props }: any) => <h2 {...props}>{children}</h2>;
return {
Alert,
Button,
Checkbox,
Empty,
Segmented,
Select,
Typography: { Text, Title },
message: {
error: mocks.messageError,
success: mocks.messageSuccess,
},
};
});
vi.mock('@ant-design/icons', () => ({
DatabaseOutlined: () => React.createElement('mock-icon', { 'data-icon': 'database' }),
FileAddOutlined: () => React.createElement('mock-icon', { 'data-icon': 'file-add' }),
ImportOutlined: () => React.createElement('mock-icon', { 'data-icon': 'import' }),
TableOutlined: () => React.createElement('mock-icon', { 'data-icon': 'table' }),
}));
const createTab = (overrides: Record<string, unknown> = {}) => ({
id: 'data-import-workbench',
title: 'Data import',
type: 'data-import',
connectionId: 'conn-1',
dbName: 'app',
tableName: 'users',
...overrides,
} as any);
class MemoryStorage implements Storage {
private readonly values = new Map<string, string>();
get length() { return this.values.size; }
clear() { this.values.clear(); }
getItem(key: string) { return this.values.get(key) ?? null; }
key(index: number) { return Array.from(this.values.keys())[index] ?? null; }
removeItem(key: string) { this.values.delete(key); }
setItem(key: string, value: string) { this.values.set(key, value); }
}
const createImportCapability = (
tableSupported = true,
tableReason = '',
sqlFileSupported = true,
sqlFileReason = '',
) => ({
databaseType: 'mysql',
tableImport: {
supported: tableSupported,
reason: tableReason,
requiresPinnedSession: false,
supportsTransactionalBatch: tableSupported,
supportsContinue: tableSupported,
supportedConflictPolicies: tableSupported ? ['stop', 'skip_duplicates', 'upsert'] : [],
supportedFormats: tableSupported ? ['csv', 'json', 'xlsx'] : [],
supportedEncodings: tableSupported ? ['utf-8'] : [],
supportedCompressions: [],
supportedClientDirectives: [],
},
sqlFileImport: {
supported: sqlFileSupported,
reason: sqlFileReason,
requiresPinnedSession: true,
supportsTransactionalBatch: sqlFileSupported,
supportsContinue: sqlFileSupported,
supportedConflictPolicies: [],
supportedFormats: sqlFileSupported ? ['sql'] : [],
supportedEncodings: sqlFileSupported ? ['utf-8', 'utf-16le', 'utf-16be'] : [],
supportedCompressions: sqlFileSupported ? ['gzip'] : [],
supportedClientDirectives: sqlFileSupported ? ['delimiter'] : [],
},
});
const renderWorkbench = async (overrides: Record<string, unknown> = {}) => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<DataImportWorkbench tab={createTab(overrides)} />);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
return renderer;
};
describe('DataImportWorkbench', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', new MemoryStorage());
mocks.storeState.theme = 'light';
mocks.storeState.connections = [
{
id: 'conn-1',
name: 'Primary MySQL',
config: {
type: 'mysql',
host: 'localhost',
port: 3306,
user: 'root',
database: 'app',
},
includeDatabases: ['app'],
},
{
id: 'redis-1',
name: 'Redis',
config: { type: 'redis', host: 'localhost', port: 6379 },
},
{
id: 'mongo-1',
name: 'MongoDB',
config: { type: 'mongodb', host: 'localhost', port: 27017 },
},
{
id: 'protected-1',
name: 'Protected PostgreSQL',
config: {
type: 'postgres',
host: 'localhost',
port: 5432,
protection: { restrictDataImport: true },
},
},
];
mocks.dbGetDatabases.mockReset();
mocks.dbGetDatabases.mockResolvedValue({
success: true,
data: [{ Database: 'app' }, { Database: 'hidden' }],
});
mocks.dbGetTables.mockReset();
mocks.dbGetTables.mockResolvedValue({
success: true,
data: [{ Tables_in_app: 'users' }, { Tables_in_app: 'orders' }],
});
mocks.importData.mockReset();
mocks.importData.mockResolvedValue({
success: true,
data: { filePath: '/tmp/users.csv' },
});
mocks.selectSQLFileForExecution.mockReset();
mocks.selectSQLFileForExecution.mockResolvedValue({
success: true,
data: { filePath: '/tmp/full-backup.sql', fileSizeMB: '1.25' },
});
mocks.messageError.mockReset();
mocks.messageSuccess.mockReset();
mocks.addTab.mockReset();
mocks.dataImportCapability.mockReset();
mocks.dataImportCapability.mockResolvedValue(createImportCapability());
});
it('persists independent table and database policies and passes table parser options to preview', async () => {
const renderer = await renderWorkbench();
await act(async () => {
renderer.root.findByProps({
'data-import-continue-on-error': 'true',
}).props.onChange({ target: { checked: true } });
renderer.root.findByProps({
'data-import-option-encoding': 'true',
}).props.onChange('gb18030');
renderer.root.findByProps({
'data-import-option-delimiter': 'true',
}).props.onChange('tab');
renderer.root.findByProps({
'data-import-option-header-row': 'true',
}).props.onChange({ target: { value: '3' } });
renderer.root.findByProps({
'data-import-option-null-token': 'true',
}).props.onChange({ target: { value: '\\N' } });
renderer.root.findByProps({
'data-import-option-empty-string-as-null': 'true',
}).props.onChange({ target: { checked: true } });
renderer.root.findByProps({
'data-import-option-sheet-name': 'true',
}).props.onChange({ target: { value: 'Sheet2' } });
renderer.root.findByProps({
'data-import-option-conflict-policy': 'true',
}).props.onChange('upsert');
await Promise.resolve();
});
await act(async () => {
renderer.root.findByProps({
'data-import-option-conflict-keys': 'true',
}).props.onChange({ target: { value: 'id, tenant_id, id' } });
await Promise.resolve();
});
await act(async () => {
renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-preview-mock': 'true',
}).props.importOptions).toMatchObject({
continueOnError: true,
encoding: 'gb18030',
delimiter: 'tab',
headerRow: 3,
nullToken: '\\N',
emptyStringAsNull: true,
sheetName: 'Sheet2',
conflictPolicy: 'upsert',
conflictKeyColumns: ['id', 'tenant_id'],
});
await act(async () => {
renderer.unmount();
});
const restored = await renderWorkbench();
expect(restored.root.findByProps({
'data-import-continue-on-error': 'true',
}).props.checked).toBe(true);
expect(restored.root.findByProps({
'data-import-option-encoding': 'true',
}).props.value).toBe('gb18030');
await act(async () => {
restored.root.findByProps({
'data-import-mode-selector': 'true',
}).props.onChange('database');
await Promise.resolve();
await Promise.resolve();
});
expect(restored.root.findByProps({
'data-import-continue-on-error': 'true',
}).props.checked).toBe(false);
expect(restored.root.findAllByProps({
'data-import-advanced-options': 'true',
})).toHaveLength(0);
});
it('keeps the controlled conflict key input aligned with normalized submitted columns', async () => {
const renderer = await renderWorkbench();
const overlongColumn = 'x'.repeat(300);
await act(async () => {
renderer.root.findByProps({
'data-import-option-conflict-policy': 'true',
}).props.onChange('upsert');
await Promise.resolve();
});
await act(async () => {
renderer.root.findByProps({
'data-import-option-conflict-keys': 'true',
}).props.onChange({ target: { value: ` id, ${overlongColumn}, ID ` } });
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-option-conflict-keys': 'true',
}).props.value).toBe(`id, ${'x'.repeat(255)}`);
await act(async () => {
renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-preview-mock': 'true',
}).props.importOptions.conflictKeyColumns).toEqual(['id', 'x'.repeat(255)]);
});
it('renders import job history without exposing a resume action', async () => {
const renderer = await renderWorkbench();
expect(renderer.root.findByProps({
'data-import-job-history-mock': 'true',
})).toBeDefined();
expect(renderer.root.findAllByProps({
'data-import-history-resume-action': true,
})).toHaveLength(0);
});
it('refreshes durable history when an import starts and when it finishes', async () => {
const renderer = await renderWorkbench();
expect(renderer.root.findByProps({
'data-import-job-history-mock': 'true',
}).props.refreshToken).toBe(0);
await act(async () => {
renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const preview = renderer.root.findByProps({ 'data-import-preview-mock': 'true' });
await act(async () => {
preview.props.onImportingChange(true);
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-job-history-mock': 'true',
}).props.refreshToken).toBe(1);
await act(async () => {
preview.props.onImportingChange(false);
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-job-history-mock': 'true',
}).props.refreshToken).toBe(2);
});
it('fails closed when a persisted conflict policy is unsupported by the backend capability', async () => {
globalThis.localStorage.setItem(
'gonavi:data-import-preferences:v1:table',
JSON.stringify({
...DEFAULT_DATA_IMPORT_PREFERENCES,
conflictPolicy: 'upsert',
conflictKeyColumns: ['id'],
}),
);
const capability = createImportCapability();
capability.tableImport.supportedConflictPolicies = ['stop'];
mocks.dataImportCapability.mockResolvedValue(capability);
const renderer = await renderWorkbench();
expect(renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.disabled).toBe(true);
expect(renderer.root.findByProps({
'data-import-conflict-policy-error': 'unsupported',
})).toBeDefined();
const policyOptions = renderer.root.findByProps({
'data-import-option-conflict-policy': 'true',
}).props.options;
expect(policyOptions.find((option: any) => option.value === 'upsert').disabled).toBe(true);
});
it('loads the backend capability for the selected connection before enabling file selection', async () => {
const renderer = await renderWorkbench();
expect(mocks.dataImportCapability).toHaveBeenCalledWith(
expect.objectContaining({ type: 'mysql' }),
);
expect(renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.disabled).toBe(false);
});
it('shows the file formats, encodings, compression and client directives supported by the backend', async () => {
const renderer = await renderWorkbench();
await act(async () => {
renderer.root.findByProps({
'data-import-mode-selector': 'true',
}).props.onChange('database');
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-capability-detail': 'formats',
}).props.children.join('')).toContain('sql');
expect(renderer.root.findByProps({
'data-import-capability-detail': 'encodings',
}).props.children.join('')).toContain('utf-16le');
expect(renderer.root.findByProps({
'data-import-capability-detail': 'compressions',
}).props.children.join('')).toContain('gzip');
expect(renderer.root.findByProps({
'data-import-capability-detail': 'directives',
}).props.children.join('')).toContain('delimiter');
});
it('blocks the current mode and shows the backend reason when it is unsupported', async () => {
mocks.dataImportCapability.mockResolvedValueOnce(
createImportCapability(false, 'table_import_runtime_unavailable'),
);
const renderer = await renderWorkbench();
expect(renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.disabled).toBe(true);
expect(renderer.root.findByProps({
'data-import-continue-on-error': 'true',
}).props.disabled).toBe(true);
expect(renderer.root.findByProps({
'data-import-capability-alert': 'true',
}).props['data-import-capability-reason']).toBe('table_import_runtime_unavailable');
});
it('fails closed and shows a recoverable reason when the capability RPC fails', async () => {
mocks.dataImportCapability.mockRejectedValueOnce(new Error('backend offline'));
const renderer = await renderWorkbench();
expect(renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.disabled).toBe(true);
expect(renderer.root.findByProps({
'data-import-capability-alert': 'true',
}).props['data-import-capability-reason']).toBe('rpc_failed');
});
it('retries a failed capability request while keeping import actions closed', async () => {
let resolveRetry!: (value: ReturnType<typeof createImportCapability>) => void;
mocks.dataImportCapability
.mockRejectedValueOnce(new Error('backend offline'))
.mockReturnValueOnce(new Promise((resolve) => {
resolveRetry = resolve;
}));
const renderer = await renderWorkbench();
const capabilityAlert = renderer.root.findByProps({
'data-import-capability-alert': 'true',
});
const retryAction = capabilityAlert.props.action;
expect(retryAction.props['data-import-capability-retry']).toBe('true');
expect(renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.disabled).toBe(true);
await act(async () => {
retryAction.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.dataImportCapability).toHaveBeenCalledTimes(2);
expect(renderer.root.findByProps({
'data-import-capability-alert': 'true',
}).props['data-import-capability-reason']).toBe('loading');
expect(renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.disabled).toBe(true);
await act(async () => {
resolveRetry(createImportCapability());
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findAllByProps({
'data-import-capability-alert': 'true',
})).toHaveLength(0);
expect(renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.disabled).toBe(false);
});
it('fails closed when the capability binding throws synchronously', async () => {
mocks.dataImportCapability.mockImplementationOnce(() => {
throw new Error('binding unavailable');
});
const renderer = await renderWorkbench();
expect(renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.disabled).toBe(true);
expect(renderer.root.findByProps({
'data-import-capability-alert': 'true',
}).props['data-import-capability-reason']).toBe('rpc_failed');
});
it('keeps file selection closed while the capability is loading', async () => {
mocks.dataImportCapability.mockReturnValueOnce(new Promise(() => {}));
const renderer = await renderWorkbench();
expect(renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.disabled).toBe(true);
expect(renderer.root.findByProps({
'data-import-capability-alert': 'true',
}).props['data-import-capability-reason']).toBe('loading');
});
it('ignores a stale capability response after the selected connection changes', async () => {
mocks.storeState.connections.push({
id: 'conn-2',
name: 'Analytics PostgreSQL',
config: {
type: 'postgres',
host: 'localhost',
port: 5432,
user: 'postgres',
database: 'app',
},
includeDatabases: ['app'],
});
let resolveFirst!: (value: unknown) => void;
mocks.dataImportCapability
.mockReturnValueOnce(new Promise((resolve) => {
resolveFirst = resolve;
}))
.mockResolvedValueOnce(
createImportCapability(false, 'table_import_runtime_unavailable'),
);
const renderer = await renderWorkbench();
await act(async () => {
renderer.root.findByProps({
'data-import-target-field': 'connection',
}).props.onChange('conn-2');
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-capability-alert': 'true',
}).props['data-import-capability-reason']).toBe('table_import_runtime_unavailable');
await act(async () => {
resolveFirst(createImportCapability());
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-target-field': 'connection',
}).props.value).toBe('conn-2');
expect(renderer.root.findByProps({
'data-import-capability-alert': 'true',
}).props['data-import-capability-reason']).toBe('table_import_runtime_unavailable');
});
it('does not pre-filter database imports with the SQL export capability heuristic', async () => {
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-capability-filter',
tableName: undefined,
});
expect(renderer.root.findByProps({
'data-import-target-field': 'connection',
}).props.options.map((option: any) => option.value)).toEqual([
'conn-1',
'redis-1',
'mongo-1',
]);
});
it('uses the SQL-file capability when database import mode is active', async () => {
mocks.dataImportCapability.mockResolvedValueOnce(
createImportCapability(true, '', false, 'pinned_session_unavailable'),
);
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-sql-capability',
tableName: undefined,
});
expect(renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.disabled).toBe(true);
expect(renderer.root.findByProps({
'data-import-capability-alert': 'true',
}).props['data-import-capability-reason']).toBe('pinned_session_unavailable');
});
it('uses the shared theme tokens for the workbench surfaces', async () => {
const renderer = await renderWorkbench();
const workbench = renderer.root.findByProps({
'data-data-import-workbench': 'true',
});
const header = renderer.root.findByType('header');
const target = renderer.root.findByProps({
'data-data-import-target-config': 'true',
});
const preview = renderer.root.findByProps({
'data-data-import-preview-panel': 'true',
});
expect(workbench.props.style.background).toContain('var(--gn-bg-panel-2');
expect(header.props.style.background).toContain('var(--gn-bg-panel');
expect(header.props.style.borderBottom).toContain('var(--gn-br-1');
expect(target.props.style.background).toContain('var(--gn-bg-panel');
expect(target.props.style.border).toContain('var(--gn-br-1');
expect(preview.props.style.background).toContain('var(--gn-bg-panel');
expect(preview.props.style.border).toContain('var(--gn-br-1');
});
it('shows the error policy in both import modes and carries it into execution', async () => {
const tableRenderer = await renderWorkbench();
const tableCheckbox = tableRenderer.root.findByProps({
'data-import-continue-on-error': 'true',
});
expect(tableCheckbox.props).toMatchObject({ checked: false, disabled: false });
await act(async () => {
tableCheckbox.props.onChange({ target: { checked: true } });
await Promise.resolve();
});
await act(async () => {
tableRenderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const preview = tableRenderer.root.findByProps({
'data-import-preview-mock': 'true',
});
expect(preview.props.continueOnError).toBe(true);
await act(async () => {
preview.props.onImportingChange(true);
await Promise.resolve();
});
expect(tableRenderer.root.findByProps({
'data-import-continue-on-error': 'true',
}).props.disabled).toBe(true);
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-1',
tableName: undefined,
});
const checkbox = renderer.root.findByProps({
'data-import-continue-on-error': 'true',
});
expect(checkbox.props).toMatchObject({ checked: false, disabled: false });
await act(async () => {
checkbox.props.onChange({ target: { checked: true } });
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-continue-on-error': 'true',
}).props.checked).toBe(true);
await act(async () => {
renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const executionPanel = renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
});
expect(executionPanel.props.continueOnError).toBe(true);
await act(async () => {
executionPanel.props.onRunningChange(true);
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-continue-on-error': 'true',
}).props.disabled).toBe(true);
});
it('filters non-relational and protected connections while loading the prefilled target', async () => {
const renderer = await renderWorkbench();
const connectionSelect = renderer.root.findByProps({
'data-import-target-field': 'connection',
});
const databaseSelect = renderer.root.findByProps({
'data-import-target-field': 'database',
});
const tableSelect = renderer.root.findByProps({
'data-import-target-field': 'table',
});
expect(connectionSelect.props.options.map((option: any) => option.value)).toEqual(['conn-1']);
expect(databaseSelect.props.options.map((option: any) => option.value)).toEqual(['app']);
expect(tableSelect.props.options.map((option: any) => option.value)).toEqual(['orders', 'users']);
expect(mocks.dbGetDatabases).toHaveBeenCalledTimes(1);
expect(mocks.dbGetTables).toHaveBeenCalledWith(expect.anything(), 'app');
});
it('applies database include and exclude patterns to import targets', async () => {
mocks.storeState.connections[0] = {
...mocks.storeState.connections[0],
includeDatabases: undefined,
includeDatabasePatterns: ['team_*'],
excludeDatabasePatterns: ['*_archive'],
};
mocks.dbGetDatabases.mockResolvedValue({
success: true,
data: [
{ Database: 'team_app' },
{ Database: 'team_archive' },
{ Database: 'other_app' },
],
});
const renderer = await renderWorkbench({ dbName: undefined, tableName: undefined });
const databaseSelect = renderer.root.findByProps({
'data-import-target-field': 'database',
});
expect(databaseSelect.props.options.map((option: any) => option.value)).toEqual(['team_app']);
});
it('invalidates a hidden import target while the filtered database list is reloading', async () => {
let resolveReload!: (result: any) => void;
mocks.dbGetDatabases
.mockResolvedValueOnce({ success: true, data: [{ Database: 'app' }] })
.mockReturnValueOnce(new Promise((resolve) => {
resolveReload = resolve;
}));
const renderer = await renderWorkbench();
mocks.storeState.connections = [{
...mocks.storeState.connections[0],
excludeDatabasePatterns: ['app'],
}];
await act(async () => {
renderer.update(<DataImportWorkbench tab={createTab()} />);
await Promise.resolve();
});
const selectFileButton = renderer.root.findByProps({
'data-import-select-file-action': 'true',
});
expect(selectFileButton.props.disabled).toBe(true);
expect(renderer.root.findByProps({
'data-import-target-field': 'database',
}).props.value).toBeUndefined();
await act(async () => {
selectFileButton.props.onClick();
await Promise.resolve();
});
expect(mocks.importData).not.toHaveBeenCalled();
await act(async () => {
resolveReload({ success: true, data: [{ Database: 'app' }] });
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-target-field': 'database',
}).props.options).toEqual([]);
});
it('keeps every target selector within the import target card', async () => {
const renderer = await renderWorkbench();
['connection', 'database', 'table'].forEach((field) => {
expect(renderer.root.findByProps({
'data-import-target-field': field,
}).props.style).toEqual({
width: '100%',
minWidth: 0,
maxWidth: '100%',
});
});
});
it('filters every SQL import protection from database mode connections', async () => {
const primaryConnection = mocks.storeState.connections[0];
mocks.storeState.connections = [
primaryConnection,
{
id: 'data-import-protected',
name: 'Data import protected',
config: {
...primaryConnection.config,
protection: { restrictDataImport: true },
},
},
{
id: 'structure-protected',
name: 'Structure protected',
config: {
...primaryConnection.config,
protection: { restrictStructureEdit: true },
},
},
{
id: 'script-protected',
name: 'Script protected',
config: {
...primaryConnection.config,
protection: { restrictScriptExecution: true },
},
},
{
id: 'redis-1',
name: 'Redis',
config: { type: 'redis', host: 'localhost', port: 6379 },
},
];
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-1',
tableName: undefined,
});
const connectionSelect = renderer.root.findByProps({
'data-import-target-field': 'connection',
});
expect(connectionSelect.props.options.map((option: any) => option.value)).toEqual(['conn-1', 'redis-1']);
expect(renderer.root.findAllByProps({ 'data-import-target-field': 'table' })).toHaveLength(0);
expect(mocks.dbGetTables).not.toHaveBeenCalled();
});
it('syncs the automatic connection fallback back to the stable workbench tab', async () => {
const renderer = await renderWorkbench({
connectionId: '',
dbName: undefined,
tableName: undefined,
});
expect(renderer.root.findByProps({
'data-import-target-field': 'connection',
}).props.value).toBe('conn-1');
expect(mocks.addTab).toHaveBeenCalledWith(expect.objectContaining({
id: 'data-import-workbench',
connectionId: 'conn-1',
dbName: undefined,
tableName: undefined,
dataImportRunning: false,
}));
});
it('selects a file for the target and embeds the shared preview workflow', async () => {
const renderer = await renderWorkbench();
const selectFileButton = renderer.root.findByProps({
'data-import-select-file-action': 'true',
});
await act(async () => {
selectFileButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.importData).toHaveBeenCalledWith(
expect.objectContaining({ type: 'mysql' }),
'app',
'users',
);
const preview = renderer.root.findByProps({ 'data-import-preview-mock': 'true' });
expect(preview.props).toMatchObject({
visible: true,
presentation: 'embedded',
filePath: '/tmp/users.csv',
connectionId: 'conn-1',
dbName: 'app',
tableName: 'users',
});
const connectionSelect = renderer.root.findByProps({
'data-import-target-field': 'connection',
});
expect(connectionSelect.props.disabled).toBe(true);
await act(async () => {
preview.props.onImportingChange(true);
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.disabled).toBe(true);
});
it('selects a SQL file without running it and renders the database execution panel', async () => {
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-1',
tableName: undefined,
});
const modeSelector = renderer.root.findByProps({
'data-import-mode-selector': 'true',
});
const databaseSelect = renderer.root.findByProps({
'data-import-target-field': 'database',
});
const selectFileButton = renderer.root.findByProps({
'data-import-select-file-action': 'true',
});
expect(modeSelector.props.value).toBe('database');
expect(databaseSelect.props.allowClear).toBe(true);
expect(renderer.root.findAllByProps({ 'data-import-target-field': 'table' })).toHaveLength(0);
expect(mocks.dbGetTables).not.toHaveBeenCalled();
await act(async () => {
selectFileButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.selectSQLFileForExecution).toHaveBeenCalledTimes(1);
expect(mocks.importData).not.toHaveBeenCalled();
expect(renderer.root.findAllByProps({ 'data-import-preview-mock': 'true' })).toHaveLength(0);
const executionPanel = renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
});
expect(executionPanel.props).toMatchObject({
dbName: 'app',
filePath: '/tmp/full-backup.sql',
fileSizeMB: '1.25',
darkMode: false,
});
expect(executionPanel.props.connectionConfig).toEqual(expect.objectContaining({ type: 'mysql' }));
});
it('resets the database execution state when a different SQL file is selected', async () => {
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-1',
tableName: undefined,
});
await act(async () => {
renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const firstExecutionPanel = renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
});
await act(async () => {
firstExecutionPanel.props.onMockRunnerStatusChange('error');
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
}).props['data-mock-runner-status']).toBe('error');
mocks.selectSQLFileForExecution.mockResolvedValueOnce({
success: true,
data: { filePath: '/tmp/replacement.sql', fileSizeMB: '2.5' },
});
await act(async () => {
renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const replacementExecutionPanel = renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
});
expect(replacementExecutionPanel.props).toMatchObject({
filePath: '/tmp/replacement.sql',
fileSizeMB: '2.5',
'data-mock-runner-status': 'idle',
});
});
it('allows selecting a database SQL file without a default database', async () => {
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-1',
tableName: undefined,
});
const databaseSelect = renderer.root.findByProps({
'data-import-target-field': 'database',
});
await act(async () => {
databaseSelect.props.onChange(undefined);
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-target-field': 'database',
}).props.value).toBeUndefined();
const selectFileButton = renderer.root.findByProps({
'data-import-select-file-action': 'true',
});
expect(selectFileButton.props.disabled).toBe(false);
await act(async () => {
selectFileButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
}).props.dbName).toBe('');
expect(mocks.dbGetTables).not.toHaveBeenCalled();
expect(mocks.importData).not.toHaveBeenCalled();
});
it('clears downstream target state when the database changes', async () => {
const renderer = await renderWorkbench();
const databaseSelect = renderer.root.findByProps({
'data-import-target-field': 'database',
});
mocks.dbGetTables.mockResolvedValueOnce({
success: true,
data: [{ Tables_in_analytics: 'events' }],
});
await act(async () => {
databaseSelect.props.onChange('analytics');
await Promise.resolve();
await Promise.resolve();
});
const tableSelect = renderer.root.findByProps({
'data-import-target-field': 'table',
});
const selectFileButton = renderer.root.findByProps({
'data-import-select-file-action': 'true',
});
expect(tableSelect.props.value).toBeUndefined();
expect(selectFileButton.props.disabled).toBe(true);
expect(mocks.dbGetTables).toHaveBeenLastCalledWith(expect.anything(), 'analytics');
expect(mocks.addTab).toHaveBeenLastCalledWith(expect.objectContaining({
id: 'data-import-workbench',
connectionId: 'conn-1',
dbName: 'analytics',
tableName: undefined,
}));
});
it('clears the selected table file and table target when switching to database mode', async () => {
const renderer = await renderWorkbench();
const selectFileButton = renderer.root.findByProps({
'data-import-select-file-action': 'true',
});
await act(async () => {
selectFileButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findAllByProps({ 'data-import-preview-mock': 'true' })).toHaveLength(1);
const modeSelector = renderer.root.findByProps({
'data-import-mode-selector': 'true',
});
expect(modeSelector.props.disabled).toBe(false);
await act(async () => {
modeSelector.props.onChange('database');
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-mode-selector': 'true',
}).props.value).toBe('database');
expect(renderer.root.findAllByProps({ 'data-import-target-field': 'table' })).toHaveLength(0);
expect(renderer.root.findAllByProps({ title: '/tmp/users.csv' })).toHaveLength(0);
expect(renderer.root.findAllByProps({ 'data-import-preview-mock': 'true' })).toHaveLength(0);
expect(renderer.root.findAllByProps({
'data-database-import-execution-panel-mock': 'true',
})).toHaveLength(0);
expect(mocks.addTab).toHaveBeenLastCalledWith(expect.objectContaining({
id: 'data-import-workbench',
dataImportMode: 'database',
tableName: undefined,
}));
});
it('ignores a pending table file selection result after switching modes', async () => {
let resolveTableFile!: (result: any) => void;
mocks.importData.mockReturnValueOnce(new Promise<any>((resolve) => {
resolveTableFile = resolve;
}));
const renderer = await renderWorkbench();
await act(async () => {
renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
});
await act(async () => {
renderer.root.findByProps({
'data-import-mode-selector': 'true',
}).props.onChange('database');
await Promise.resolve();
});
await act(async () => {
resolveTableFile({ success: true, data: { filePath: '/tmp/stale-users.csv' } });
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-mode-selector': 'true',
}).props.value).toBe('database');
expect(renderer.root.findAllByProps({ title: '/tmp/stale-users.csv' })).toHaveLength(0);
expect(renderer.root.findAllByProps({ 'data-import-preview-mock': 'true' })).toHaveLength(0);
expect(renderer.root.findAllByProps({
'data-database-import-execution-panel-mock': 'true',
})).toHaveLength(0);
});
it('does not report the native file-picker cancellation as an error', async () => {
mocks.importData.mockResolvedValueOnce({ success: false, message: '已取消' });
const renderer = await renderWorkbench();
const selectFileButton = renderer.root.findByProps({
'data-import-select-file-action': 'true',
});
await act(async () => {
selectFileButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(mocks.messageError).not.toHaveBeenCalled();
expect(renderer.root.findAllByProps({ 'data-import-preview-mock': 'true' })).toHaveLength(0);
});
it('resets a selected SQL file when the same target gets a new launch key', async () => {
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-1',
tableName: undefined,
});
await act(async () => {
renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findAllByProps({
'data-database-import-execution-panel-mock': 'true',
})).toHaveLength(1);
await act(async () => {
renderer.update(<DataImportWorkbench tab={createTab({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-2',
tableName: undefined,
})} />);
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findAllByProps({ title: '/tmp/full-backup.sql' })).toHaveLength(0);
expect(renderer.root.findAllByProps({
'data-database-import-execution-panel-mock': 'true',
})).toHaveLength(0);
expect(mocks.selectSQLFileForExecution).toHaveBeenCalledTimes(1);
});
it('does not replace a running database import target when the stable tab is reopened', async () => {
const renderer = await renderWorkbench({
dataImportMode: 'database',
dataImportLaunchKey: 'database-launch-1',
tableName: undefined,
});
await act(async () => {
renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const executionPanel = renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
});
await act(async () => {
executionPanel.props.onRunningChange(true);
await Promise.resolve();
});
await act(async () => {
renderer.update(<DataImportWorkbench tab={createTab({
dataImportMode: 'table',
dataImportLaunchKey: 'table-launch-2',
dbName: 'analytics',
tableName: 'events',
})} />);
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-import-mode-selector': 'true',
}).props).toMatchObject({ value: 'database', disabled: true });
expect(renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
}).props).toMatchObject({
dbName: 'app',
filePath: '/tmp/full-backup.sql',
});
expect(mocks.addTab).toHaveBeenCalledWith(expect.objectContaining({
id: 'data-import-workbench',
connectionId: 'conn-1',
dbName: 'app',
tableName: undefined,
dataImportMode: 'database',
dataImportRunning: true,
}));
});
it('keeps an active database import mounted while its connection record changes', async () => {
const tab = createTab({
dataImportMode: 'database',
dataImportLaunchKey: 'database-connection-refresh',
tableName: undefined,
});
const renderer = await renderWorkbench(tab);
await act(async () => {
renderer.root.findByProps({
'data-import-select-file-action': 'true',
}).props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const executionPanel = renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
});
await act(async () => {
executionPanel.props.onRunningChange(true);
await Promise.resolve();
});
const capabilityCallsBeforeRefresh = mocks.dataImportCapability.mock.calls.length;
const databaseCallsBeforeRefresh = mocks.dbGetDatabases.mock.calls.length;
mocks.dataImportCapability.mockImplementation(() => new Promise(() => undefined));
mocks.storeState.connections = mocks.storeState.connections.map((connection) => (
connection.id === 'conn-1'
? { ...connection, config: { ...connection.config, host: '127.0.0.1' } }
: connection
));
await act(async () => {
renderer.update(<DataImportWorkbench tab={tab} />);
await Promise.resolve();
await Promise.resolve();
});
expect(renderer.root.findByProps({
'data-database-import-execution-panel-mock': 'true',
}).props).toMatchObject({
dbName: 'app',
filePath: '/tmp/full-backup.sql',
});
expect(mocks.dataImportCapability).toHaveBeenCalledTimes(capabilityCallsBeforeRefresh);
expect(mocks.dbGetDatabases).toHaveBeenCalledTimes(databaseCallsBeforeRefresh);
});
it('does not replace an active import target when the stable tab is reopened', async () => {
const renderer = await renderWorkbench();
const selectFileButton = renderer.root.findByProps({
'data-import-select-file-action': 'true',
});
await act(async () => {
selectFileButton.props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const preview = renderer.root.findByProps({ 'data-import-preview-mock': 'true' });
await act(async () => {
preview.props.onImportingChange(true);
renderer.update(<DataImportWorkbench tab={createTab({ dbName: 'analytics', tableName: 'events' })} />);
await Promise.resolve();
});
expect(mocks.addTab).toHaveBeenCalledWith(expect.objectContaining({
id: 'data-import-workbench',
connectionId: 'conn-1',
dbName: 'app',
tableName: 'users',
dataImportRunning: true,
}));
const activePreview = renderer.root.findByProps({ 'data-import-preview-mock': 'true' });
expect(activePreview.props.dbName).toBe('app');
expect(activePreview.props.tableName).toBe('users');
expect(activePreview.props.filePath).toBe('/tmp/users.csv');
});
});