From 05731552851a31f7dc692e7f4e8821c3a6d6d5dc Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 12 Jun 2026 07:45:32 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(sql-editor):=20=E9=98=B2?= =?UTF-8?q?=E6=AD=A2=E4=BA=8B=E5=8A=A1=E9=87=8D=E5=A4=8D=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E8=AF=AF=E6=8A=A5=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...useSqlEditorTransactionController.test.tsx | 123 ++++++++++++++++++ .../useSqlEditorTransactionController.ts | 11 +- 2 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/useSqlEditorTransactionController.test.tsx diff --git a/frontend/src/components/useSqlEditorTransactionController.test.tsx b/frontend/src/components/useSqlEditorTransactionController.test.tsx new file mode 100644 index 0000000..53166de --- /dev/null +++ b/frontend/src/components/useSqlEditorTransactionController.test.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useSqlEditorTransactionController } from './useSqlEditorTransactionController'; +import type { PendingSqlEditorTransaction } from './QueryEditorTransactionToolbar'; + +const storeState = vi.hoisted(() => ({ + setSqlEditorPendingTransaction: vi.fn(), +})); + +const backendApp = vi.hoisted(() => ({ + DBCommitTransaction: vi.fn(), + DBRollbackTransaction: vi.fn(), +})); + +const messageApi = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), +})); + +vi.mock('../store', () => ({ + useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), +})); + +vi.mock('../../wailsjs/go/app/App', () => backendApp); + +vi.mock('antd', () => ({ + message: messageApi, +})); + +const createPendingTransaction = (overrides: Partial = {}): PendingSqlEditorTransaction => ({ + id: 'tx-1', + commitMode: 'manual', + autoCommitDelayMs: 0, + createdAt: Date.now(), + statementCount: 1, + ...overrides, +}); + +describe('useSqlEditorTransactionController', () => { + let controller: ReturnType | null = null; + let renderer: ReactTestRenderer | null = null; + + const renderController = () => { + const Harness = () => { + controller = useSqlEditorTransactionController({ tabId: 'tab-1' }); + return null; + }; + + act(() => { + renderer = create(); + }); + }; + + beforeEach(() => { + controller = null; + renderer = null; + storeState.setSqlEditorPendingTransaction.mockReset(); + backendApp.DBCommitTransaction.mockReset(); + backendApp.DBRollbackTransaction.mockReset(); + messageApi.error.mockReset(); + messageApi.success.mockReset(); + backendApp.DBCommitTransaction.mockResolvedValue({ success: true, message: '事务已提交' }); + backendApp.DBRollbackTransaction.mockResolvedValue({ success: true, message: '事务已回滚' }); + }); + + afterEach(() => { + act(() => { + renderer?.unmount(); + }); + }); + + it('ignores duplicate finish requests for the same pending transaction', async () => { + renderController(); + + await act(async () => { + controller?.activatePendingSqlTransaction(createPendingTransaction()); + }); + + await act(async () => { + const first = controller?.finishPendingSqlTransaction('commit', 'manual'); + const second = controller?.finishPendingSqlTransaction('commit', 'manual'); + await Promise.all([first, second]); + }); + + expect(backendApp.DBCommitTransaction).toHaveBeenCalledTimes(1); + expect(backendApp.DBCommitTransaction).toHaveBeenCalledWith('tx-1'); + expect(backendApp.DBRollbackTransaction).not.toHaveBeenCalled(); + expect(messageApi.success).toHaveBeenCalledWith('SQL 事务已提交'); + }); + + it('does not rollback a transaction while its auto commit is in flight', async () => { + let resolveCommit!: (value: { success: boolean; message: string }) => void; + backendApp.DBCommitTransaction.mockReturnValue(new Promise((resolve) => { + resolveCommit = resolve; + })); + renderController(); + + await act(async () => { + controller?.activatePendingSqlTransaction(createPendingTransaction({ + commitMode: 'auto', + autoCommitDelayMs: 0, + })); + }); + + const finishPromise = controller?.finishPendingSqlTransaction('commit', 'auto'); + act(() => { + renderer?.unmount(); + renderer = null; + }); + + expect(backendApp.DBRollbackTransaction).not.toHaveBeenCalled(); + expect(backendApp.DBCommitTransaction).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveCommit({ success: true, message: '事务已提交' }); + await finishPromise; + }); + + expect(messageApi.success).toHaveBeenCalledWith('SQL 事务已自动提交'); + }); +}); diff --git a/frontend/src/components/useSqlEditorTransactionController.ts b/frontend/src/components/useSqlEditorTransactionController.ts index 0ac92e2..4ba0f00 100644 --- a/frontend/src/components/useSqlEditorTransactionController.ts +++ b/frontend/src/components/useSqlEditorTransactionController.ts @@ -19,6 +19,7 @@ export const useSqlEditorTransactionController = ({ const setSqlEditorPendingTransaction = useStore(state => state.setSqlEditorPendingTransaction); const [pendingSqlTransaction, setPendingSqlTransaction] = useState(null); const pendingSqlTransactionRef = useRef(null); + const finishingTransactionIdsRef = useRef>(new Set()); const autoCommitTimerRef = useRef | null>(null); const autoCommitCountdownRef = useRef | null>(null); const [autoCommitRemainingSeconds, setAutoCommitRemainingSeconds] = useState(null); @@ -50,13 +51,17 @@ export const useSqlEditorTransactionController = ({ if (!transaction || (transactionId && transaction.id !== transactionId)) { return; } + if (finishingTransactionIdsRef.current.has(transaction.id)) { + return; + } clearAutoCommitTimer(); + finishingTransactionIdsRef.current.add(transaction.id); + updatePendingSqlTransaction(null); try { const res = action === 'commit' ? await DBCommitTransaction(transaction.id) : await DBRollbackTransaction(transaction.id); if (res?.success) { - updatePendingSqlTransaction(null); if (action === 'commit') { message.success(source === 'auto' ? 'SQL 事务已自动提交' : 'SQL 事务已提交'); } else { @@ -64,13 +69,13 @@ export const useSqlEditorTransactionController = ({ } return; } - updatePendingSqlTransaction(null); const fallback = action === 'commit' ? '提交失败' : '回滚失败'; message.error(`${source === 'auto' ? '自动提交失败' : fallback}: ${formatSqlExecutionError(res?.message || '未知错误')}`); } catch (err: any) { - updatePendingSqlTransaction(null); const fallback = action === 'commit' ? '提交失败' : '回滚失败'; message.error(`${source === 'auto' ? '自动提交失败' : fallback}: ${formatSqlExecutionError(err?.message || err || '未知错误')}`); + } finally { + finishingTransactionIdsRef.current.delete(transaction.id); } }, [clearAutoCommitTimer, updatePendingSqlTransaction]);