import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useCommitFlow } from './useCommitFlow' const mockInvokeFn = vi.fn() const mockTrackEvent = vi.fn() vi.mock('@tauri-apps/api/core', () => ({ invoke: (command: string, args: Record) => mockInvokeFn(command, args), })) vi.mock('../mock-tauri', () => ({ isTauri: () => false, mockInvoke: (command: string, args: Record) => mockInvokeFn(command, args), })) vi.mock('../lib/telemetry', () => ({ trackEvent: (event: string, properties?: Record) => mockTrackEvent(event, properties), })) describe('useCommitFlow', () => { let savePending: vi.Mock let loadModifiedFiles: vi.Mock let resolveRemoteStatus: vi.Mock let setToastMessage: vi.Mock let onPushRejected: vi.Mock beforeEach(() => { savePending = vi.fn().mockResolvedValue(undefined) loadModifiedFiles = vi.fn().mockResolvedValue(undefined) resolveRemoteStatus = vi.fn().mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: true }) setToastMessage = vi.fn() onPushRejected = vi.fn() mockTrackEvent.mockReset() mockInvokeFn.mockReset() mockInvokeFn.mockImplementation((command: string) => { if (command === 'git_commit') return Promise.resolve('[main abc1234] test commit') if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' }) if (command === 'get_modified_files') return Promise.resolve([{ path: '/vault/a.md', relativePath: 'a.md', status: 'modified' }]) throw new Error(`Unexpected command: ${command}`) }) }) function renderCommitFlow() { return renderHook(() => useCommitFlow({ savePending, loadModifiedFiles, resolveRemoteStatus, setToastMessage, onPushRejected, vaultPath: '/vault', })) } it('openCommitDialog saves pending, refreshes files, and sets local mode when no remote exists', async () => { resolveRemoteStatus.mockResolvedValueOnce({ branch: 'main', ahead: 0, behind: 0, hasRemote: false }) const { result } = renderCommitFlow() await act(async () => { await result.current.openCommitDialog() }) expect(savePending).toHaveBeenCalledTimes(1) expect(loadModifiedFiles).toHaveBeenCalledTimes(1) expect(resolveRemoteStatus).toHaveBeenCalledTimes(1) expect(result.current.showCommitDialog).toBe(true) expect(result.current.commitMode).toBe('local') }) it('handleCommitPush commits and pushes when a remote is configured', async () => { const { result } = renderCommitFlow() await act(async () => { await result.current.handleCommitPush('test message') }) expect(savePending).toHaveBeenCalled() expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'git_commit', { vaultPath: '/vault', message: 'test message' }) expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_push', { vaultPath: '/vault' }) expect(setToastMessage).toHaveBeenCalledWith('Committed and pushed') expect(loadModifiedFiles).toHaveBeenCalled() expect(resolveRemoteStatus).toHaveBeenCalledTimes(2) expect(mockTrackEvent).toHaveBeenCalledWith('commit_made', undefined) expect(result.current.showCommitDialog).toBe(false) }) it('runAutomaticCheckpoint saves pending first and uses the deterministic automatic message', async () => { const { result } = renderCommitFlow() await act(async () => { await result.current.runAutomaticCheckpoint({ savePendingBeforeCommit: true }) }) expect(savePending).toHaveBeenCalledTimes(1) expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'get_modified_files', { vaultPath: '/vault' }) expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_commit', { vaultPath: '/vault', message: 'Updated 1 note' }) expect(mockInvokeFn).toHaveBeenNthCalledWith(3, 'git_push', { vaultPath: '/vault' }) expect(setToastMessage).toHaveBeenCalledWith('Committed and pushed') }) it('runAutomaticCheckpoint retries push-only when local commits are already ahead', async () => { resolveRemoteStatus.mockResolvedValue({ branch: 'main', ahead: 2, behind: 0, hasRemote: true }) mockInvokeFn.mockImplementation((command: string) => { if (command === 'get_modified_files') return Promise.resolve([]) if (command === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' }) throw new Error(`Unexpected command: ${command}`) }) const { result } = renderCommitFlow() await act(async () => { await result.current.runAutomaticCheckpoint() }) expect(mockInvokeFn).toHaveBeenCalledTimes(2) expect(mockInvokeFn).toHaveBeenNthCalledWith(1, 'get_modified_files', { vaultPath: '/vault' }) expect(mockInvokeFn).toHaveBeenNthCalledWith(2, 'git_push', { vaultPath: '/vault' }) expect(setToastMessage).toHaveBeenCalledWith('Pushed committed changes') expect(mockTrackEvent).not.toHaveBeenCalled() }) it('runAutomaticCheckpoint reports when there is nothing to commit or push', async () => { mockInvokeFn.mockImplementation((command: string) => { if (command === 'get_modified_files') return Promise.resolve([]) throw new Error(`Unexpected command: ${command}`) }) const { result } = renderCommitFlow() await act(async () => { await result.current.runAutomaticCheckpoint() }) expect(setToastMessage).toHaveBeenCalledWith('Nothing to commit or push') expect(mockTrackEvent).not.toHaveBeenCalled() }) it('handleCommitPush commits locally and skips push when no remote is configured', async () => { resolveRemoteStatus.mockResolvedValue({ branch: 'main', ahead: 0, behind: 0, hasRemote: false }) mockInvokeFn.mockImplementation((command: string) => { if (command === 'git_commit') return Promise.resolve('[main abc1234] test message') throw new Error(`Unexpected command: ${command}`) }) const { result } = renderCommitFlow() await act(async () => { await result.current.handleCommitPush('test message') }) expect(mockInvokeFn).toHaveBeenCalledTimes(1) expect(mockInvokeFn).toHaveBeenCalledWith('git_commit', { vaultPath: '/vault', message: 'test message' }) expect(setToastMessage).toHaveBeenCalledWith('Committed locally (no remote configured)') expect(onPushRejected).not.toHaveBeenCalled() }) it('handleCommitPush calls onPushRejected when push is rejected', async () => { mockInvokeFn.mockImplementation((command: string) => { if (command === 'git_commit') return Promise.resolve('[main abc1234] test message') if (command === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected' }) throw new Error(`Unexpected command: ${command}`) }) const { result } = renderCommitFlow() await act(async () => { await result.current.handleCommitPush('test message') }) expect(onPushRejected).toHaveBeenCalledTimes(1) expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('push rejected')) }) it('handleCommitPush shows error toast on failure', async () => { mockInvokeFn.mockImplementation(() => Promise.reject(new Error('push failed'))) const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const { result } = renderCommitFlow() await act(async () => { await result.current.handleCommitPush('test') }) expect(setToastMessage).toHaveBeenCalledWith('Commit failed: push failed') consoleSpy.mockRestore() }) it('closeCommitDialog closes the dialog', async () => { const { result } = renderCommitFlow() await act(async () => { await result.current.openCommitDialog() }) expect(result.current.showCommitDialog).toBe(true) act(() => { result.current.closeCommitDialog() }) expect(result.current.showCommitDialog).toBe(false) }) })