diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index 8e1d0e51..02783f62 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -34,7 +34,7 @@ function defaultMockInvoke(cmd: string, args?: Record) { if (cmd === 'get_file_diff') return Promise.resolve('--- a/note.md\n+++ b/note.md') if (cmd === 'get_file_diff_at_commit') return Promise.resolve(`diff for ${(args as Record)?.commitHash}`) if (cmd === 'git_commit') return Promise.resolve('committed') - if (cmd === 'git_push') return Promise.resolve('pushed') + if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' }) return Promise.resolve(null) } @@ -382,6 +382,49 @@ describe('useVaultLoader', () => { expect(response).toBe('Committed and pushed') }) + + it('returns actionable message when push is rejected', async () => { + mockInvokeFn.mockImplementation(((cmd: string) => { + if (cmd === 'list_vault') return Promise.resolve(mockEntries) + if (cmd === 'get_all_content') return Promise.resolve(mockContent) + if (cmd === 'get_modified_files') return Promise.resolve([]) + if (cmd === 'git_commit') return Promise.resolve('committed') + if (cmd === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' }) + return Promise.resolve(null) + }) as typeof defaultMockInvoke) + + const { result } = renderHook(() => useVaultLoader('/vault')) + await waitFor(() => { expect(result.current.entries).toHaveLength(1) }) + + let response = '' + await act(async () => { + response = await result.current.commitAndPush('test commit') + }) + + expect(response).toContain('Pull first') + expect(response).not.toBe('Committed and pushed') + }) + + it('returns network error message on network failure', async () => { + mockInvokeFn.mockImplementation(((cmd: string) => { + if (cmd === 'list_vault') return Promise.resolve(mockEntries) + if (cmd === 'get_all_content') return Promise.resolve(mockContent) + if (cmd === 'get_modified_files') return Promise.resolve([]) + if (cmd === 'git_commit') return Promise.resolve('committed') + if (cmd === 'git_push') return Promise.resolve({ status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' }) + return Promise.resolve(null) + }) as typeof defaultMockInvoke) + + const { result } = renderHook(() => useVaultLoader('/vault')) + await waitFor(() => { expect(result.current.entries).toHaveLength(1) }) + + let response = '' + await act(async () => { + response = await result.current.commitAndPush('test commit') + }) + + expect(response).toContain('network error') + }) }) describe('loadModifiedFiles', () => { diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index 3efe955c..b416dabb 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState, startTransition } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' -import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus } from '../types' +import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus, GitPushResult } from '../types' function tauriCall(command: string, tauriArgs: Record, mockArgs?: Record): Promise { return isTauri() ? invoke(command, tauriArgs) : mockInvoke(command, mockArgs ?? tauriArgs) @@ -18,16 +18,12 @@ async function loadVaultData(vaultPath: string) { async function commitWithPush(vaultPath: string, message: string): Promise { if (!isTauri()) { await mockInvoke('git_commit', { message }) - await mockInvoke('git_push', {}) - return 'Committed and pushed' + const pushResult = await mockInvoke('git_push', {}) + return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message } await invoke('git_commit', { vaultPath, message }) - try { - await invoke('git_push', { vaultPath }) - return 'Committed and pushed' - } catch { - return 'Committed (push failed)' - } + const pushResult = await invoke('git_push', { vaultPath }) + return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message } function useNewNoteTracker() { diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 0334b142..bdd21ab0 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -3,7 +3,7 @@ * Each handler simulates a Tauri backend command. */ -import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types' +import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types' import { MOCK_CONTENT } from './mock-content' import { MOCK_ENTRIES } from './mock-entries' @@ -172,7 +172,7 @@ export const mockHandlers: Record any> = { get_build_number: () => 'bDEV', get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }), git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }), - git_push: () => 'Everything up-to-date', + git_push: (): GitPushResult => ({ status: 'ok', message: 'Pushed to remote' }), get_vault_pulse: (args: { limit?: number }): PulseCommit[] => { const limit = args.limit ?? 30 const ts = Math.floor(Date.now() / 1000) diff --git a/src/types.ts b/src/types.ts index 96a999aa..d1c97b27 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,11 @@ export interface GitPullResult { conflictFiles: string[] } +export interface GitPushResult { + status: 'ok' | 'rejected' | 'auth_error' | 'network_error' | 'error' + message: string +} + export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict' export interface DeviceFlowStart {