mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
✨ feat(data-transfer): 完善数据库 SQL 备份与恢复
- 数据导入工作台新增数据库模式,支持 SQL 文件选择、目标库、进度、取消与重试 - 新增受连接保护约束的 ImportDatabaseSQL 接口并复用流式执行链路 - SQL 导出支持切换数据库上下文,兼顾原库备份和跨库导入 - 调整侧栏与表概览入口,备份先进入工作台确认配置再执行 - 同步 Wails 绑定、Web 运行时限制、六语文案和回归测试
This commit is contained in:
219
frontend/src/components/DatabaseImportExecutionPanel.test.tsx
Normal file
219
frontend/src/components/DatabaseImportExecutionPanel.test.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SQLFileExecutionState } from './useSQLFileExecutionRunner';
|
||||
import DatabaseImportExecutionPanel from './DatabaseImportExecutionPanel';
|
||||
|
||||
const createRunnerState = (
|
||||
overrides: Partial<SQLFileExecutionState> = {},
|
||||
): SQLFileExecutionState => ({
|
||||
jobId: '',
|
||||
title: '',
|
||||
filePath: '',
|
||||
fileSizeMB: '',
|
||||
startedAt: 0,
|
||||
finishedAt: 0,
|
||||
status: 'idle',
|
||||
stage: '',
|
||||
executed: 0,
|
||||
failed: 0,
|
||||
total: 0,
|
||||
percent: 0,
|
||||
currentSQL: '',
|
||||
message: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
importDatabaseSQL: vi.fn(),
|
||||
cancelSQLFileExecution: vi.fn(),
|
||||
run: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
state: null as SQLFileExecutionState | null,
|
||||
isRunning: false,
|
||||
lastRunOptions: null as null | {
|
||||
run: (jobId: string) => Promise<any>;
|
||||
cancel?: (jobId: string) => void | Promise<void>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../wailsjs/go/app/App', () => ({
|
||||
ImportDatabaseSQL: mocks.importDatabaseSQL,
|
||||
CancelSQLFileExecution: mocks.cancelSQLFileExecution,
|
||||
}));
|
||||
|
||||
vi.mock('./useSQLFileExecutionRunner', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./useSQLFileExecutionRunner')>();
|
||||
return {
|
||||
...actual,
|
||||
useSQLFileExecutionRunner: () => ({
|
||||
state: mocks.state,
|
||||
reset: mocks.reset,
|
||||
cancelExecution: mocks.cancel,
|
||||
runSQLFileExecutionWithProgress: mocks.run,
|
||||
isRunning: mocks.isRunning,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('antd', async () => {
|
||||
const React = await import('react');
|
||||
const Alert = (props: Record<string, unknown>) => React.createElement('mock-alert', props);
|
||||
const Button = ({ children, ...props }: any) => <button {...props}>{children}</button>;
|
||||
const Progress = (props: Record<string, unknown>) => React.createElement('mock-progress', props);
|
||||
const Paragraph = ({ children, ...props }: any) => <p {...props}>{children}</p>;
|
||||
const Text = ({ children, ...props }: any) => <span {...props}>{children}</span>;
|
||||
const Title = ({ children, ...props }: any) => <h3 {...props}>{children}</h3>;
|
||||
return {
|
||||
Alert,
|
||||
Button,
|
||||
Progress,
|
||||
Typography: { Paragraph, Text, Title },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@ant-design/icons', () => ({
|
||||
PlayCircleOutlined: () => React.createElement('mock-icon', { name: 'play' }),
|
||||
ReloadOutlined: () => React.createElement('mock-icon', { name: 'reload' }),
|
||||
StopOutlined: () => React.createElement('mock-icon', { name: 'stop' }),
|
||||
}));
|
||||
|
||||
const renderPanel = async (onRunningChange = vi.fn()) => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<DatabaseImportExecutionPanel
|
||||
connectionConfig={{ type: 'mysql', host: 'localhost', port: 3306 }}
|
||||
dbName="app"
|
||||
filePath="/tmp/database.sql"
|
||||
fileSizeMB="12.5"
|
||||
darkMode={false}
|
||||
onRunningChange={onRunningChange}
|
||||
/>,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
return renderer;
|
||||
};
|
||||
|
||||
describe('DatabaseImportExecutionPanel', () => {
|
||||
beforeEach(() => {
|
||||
mocks.state = createRunnerState();
|
||||
mocks.isRunning = false;
|
||||
mocks.lastRunOptions = null;
|
||||
mocks.importDatabaseSQL.mockReset();
|
||||
mocks.cancelSQLFileExecution.mockReset();
|
||||
mocks.reset.mockReset();
|
||||
mocks.run.mockReset();
|
||||
mocks.cancel.mockReset();
|
||||
mocks.run.mockImplementation(async (options: any) => {
|
||||
mocks.lastRunOptions = options;
|
||||
return options.run('database-import-job-1');
|
||||
});
|
||||
mocks.cancel.mockImplementation(async () => {
|
||||
await mocks.lastRunOptions?.cancel?.('database-import-job-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('waits for an explicit start action and reports the full RPC lifetime as running', async () => {
|
||||
let resolveImport!: (value: { success: boolean; message: string }) => void;
|
||||
mocks.importDatabaseSQL.mockReturnValue(new Promise((resolve) => {
|
||||
resolveImport = resolve;
|
||||
}));
|
||||
const onRunningChange = vi.fn();
|
||||
const renderer = await renderPanel(onRunningChange);
|
||||
|
||||
expect(mocks.importDatabaseSQL).not.toHaveBeenCalled();
|
||||
const startButton = renderer.root.findByProps({
|
||||
'data-database-import-start-action': 'true',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
startButton.props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.importDatabaseSQL).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'mysql' }),
|
||||
'app',
|
||||
'/tmp/database.sql',
|
||||
'database-import-job-1',
|
||||
);
|
||||
expect(onRunningChange).toHaveBeenLastCalledWith(true);
|
||||
expect(renderer.root.findAllByProps({
|
||||
'data-database-import-cancel-action': 'true',
|
||||
}).length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
resolveImport({ success: true, message: 'done' });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(onRunningChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('cancels the active SQL import with the runner job id', async () => {
|
||||
let resolveImport!: (value: { success: boolean; message: string }) => void;
|
||||
mocks.importDatabaseSQL.mockReturnValue(new Promise((resolve) => {
|
||||
resolveImport = resolve;
|
||||
}));
|
||||
mocks.cancelSQLFileExecution.mockResolvedValue({ success: true });
|
||||
const renderer = await renderPanel();
|
||||
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-database-import-start-action': 'true',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
renderer.root.findByProps({
|
||||
'data-database-import-cancel-action': 'true',
|
||||
}).props.onClick();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.cancelSQLFileExecution).toHaveBeenCalledWith('database-import-job-1');
|
||||
|
||||
await act(async () => {
|
||||
resolveImport({ success: false, message: 'cancelled' });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['done', 'success', 'completed'],
|
||||
['error', 'error', 'failed'],
|
||||
['cancelled', 'warning', 'cancelled'],
|
||||
] as const)('renders %s progress and result state', async (status, alertType, message) => {
|
||||
mocks.state = createRunnerState({
|
||||
jobId: 'database-import-job-1',
|
||||
status,
|
||||
stage: status,
|
||||
filePath: '/tmp/database.sql',
|
||||
executed: 24,
|
||||
failed: status === 'error' ? 1 : 0,
|
||||
total: 25,
|
||||
percent: status === 'done' ? 100 : 96,
|
||||
currentSQL: 'CREATE TABLE demo(id INT)',
|
||||
message,
|
||||
});
|
||||
const renderer = await renderPanel();
|
||||
|
||||
expect(renderer.root.findByProps({
|
||||
'data-database-import-progress': 'true',
|
||||
}).props.percent).toBe(status === 'done' ? 100 : 96);
|
||||
expect(renderer.root.findByProps({
|
||||
'data-database-import-result': 'true',
|
||||
}).props).toMatchObject({
|
||||
type: alertType,
|
||||
message,
|
||||
});
|
||||
expect(renderer.root.findAllByProps({
|
||||
'data-database-import-current-sql': 'true',
|
||||
})).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user