import { describe, it, expect, beforeEach, vi } from 'vitest'; vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() }, dialog: {}, shell: {} } })); vi.mock('../../src/main/services/GitClient.js'); vi.mock('node:fs/promises', () => ({ mkdir: vi.fn(async () => undefined) })); import electron from 'electron'; import { mkdir } from 'node:fs/promises'; import { GitClient } from '../../src/main/services/GitClient.js'; import { registerSettingsApi } from '../../src/main/ipc/settingsApi.js'; import type { SettingsIpcDeps } from '../../src/main/ipc/settingsApi.js'; function getHandler(channel: string): (...args: unknown[]) => unknown { const handle = (electron.ipcMain as unknown as { handle: ReturnType }).handle; const call = handle.mock.calls.find((c) => c[0] === channel); if (!call) throw new Error(`channel ${channel} not registered`); return call[1] as (...args: unknown[]) => unknown; } function makeDeps() { const gitInstance = { isRepo: vi.fn(async () => false), hasRemote: vi.fn(async () => false), run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })) }; (GitClient as unknown as ReturnType).mockImplementation(function () { return gitInstance; }); const syncSvc = { getSyncDir: vi.fn(() => '/tmp/sync'), listConflicts: vi.fn(() => [] as { path: string; localText: string; remoteText: string }[]), resolveConflict: vi.fn(async () => ({ ok: true as const })), getLastStatus: vi.fn(() => ({ lastAt: null as string | null, lastResult: null as { ok: boolean } | null })) }; const settings = { getSyncRepoUrl: vi.fn(async () => 'git@host:u/r.git'), setSyncRepoUrl: vi.fn(async () => {}), isAutoSyncEnabled: vi.fn(async () => false), getSyncIntervalMin: vi.fn(async () => 30), getAll: vi.fn(async () => ({})), setAiEnabled: vi.fn(async () => {}), setOnboardingCompleted: vi.fn(async () => {}), isAiEnabled: vi.fn(async () => true) }; const deps: Partial = { backup: { runDaily: vi.fn(async () => ({ snapshotted: false })) } as never, exportSvc: {} as never, importSvc: {} as never, syncSvc: syncSvc as never, telemetry: { exportTo: vi.fn(async () => ({ eventCount: 0 })) } as never, settings: settings as never, getInboxWindow: () => null }; return { gitInstance, syncSvc, settings, deps }; } describe('sync IPC channels', () => { beforeEach(() => { (electron.ipcMain as unknown as { handle: ReturnType }).handle.mockClear(); vi.clearAllMocks(); }); it('5 sync channels registered', () => { const { deps } = makeDeps(); registerSettingsApi(deps as SettingsIpcDeps); const handle = (electron.ipcMain as unknown as { handle: ReturnType }).handle; const channels = handle.mock.calls.map((c) => c[0]); expect(channels).toContain('settings:configure-sync'); expect(channels).toContain('settings:test-sync-connection'); expect(channels).toContain('sync:list-conflicts'); expect(channels).toContain('sync:resolve-conflict'); expect(channels).toContain('sync:get-status'); }); describe('settings:configure-sync', () => { it('null URL → setSyncRepoUrl(null), no git init', async () => { const { deps, settings, gitInstance } = makeDeps(); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('settings:configure-sync'); const r = await h({}, null); expect(settings.setSyncRepoUrl).toHaveBeenCalledWith(null); expect(gitInstance.run).not.toHaveBeenCalled(); expect(r).toEqual({ ok: true }); }); it('empty string URL → treated as null', async () => { const { deps, settings, gitInstance } = makeDeps(); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('settings:configure-sync'); const r = await h({}, ' '); expect(settings.setSyncRepoUrl).toHaveBeenCalledWith(null); expect(gitInstance.run).not.toHaveBeenCalled(); expect(r).toEqual({ ok: true }); }); it('valid URL → isRepo=false → git init + remote add', async () => { const { deps, gitInstance } = makeDeps(); gitInstance.isRepo.mockResolvedValue(false); gitInstance.hasRemote.mockResolvedValue(false); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('settings:configure-sync'); const r = await h({}, 'git@github.com:user/repo.git'); expect(gitInstance.run).toHaveBeenCalledWith(['init']); expect(gitInstance.run).toHaveBeenCalledWith(['remote', 'add', 'origin', 'git@github.com:user/repo.git']); expect(r).toEqual({ ok: true }); }); // Regression: syncDir 미생성 상태에서 `git -C init` 호출 시 // git 이 chdir 실패로 죽음 → mkdir(recursive) 가 init 보다 먼저 호출되어야 함. // (runSync 의 line 135 패턴과 동일.) it('mkdir(syncDir, recursive) 가 git init 전에 호출됨', async () => { const { deps, gitInstance } = makeDeps(); gitInstance.isRepo.mockResolvedValue(false); const callOrder: string[] = []; (mkdir as unknown as ReturnType).mockImplementationOnce(async () => { callOrder.push('mkdir'); }); (gitInstance.run as unknown as ReturnType).mockImplementation(async (args: string[]) => { callOrder.push(`git:${args[0]}`); return { stdout: '', stderr: '', exitCode: 0 }; }); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('settings:configure-sync'); await h({}, 'git@github.com:user/repo.git'); expect(mkdir).toHaveBeenCalledWith('/tmp/sync', { recursive: true }); expect(callOrder.indexOf('mkdir')).toBeLessThan(callOrder.indexOf('git:init')); }); it('valid URL → isRepo=true, hasRemote=true → remote set-url', async () => { const { deps, gitInstance } = makeDeps(); gitInstance.isRepo.mockResolvedValue(true); gitInstance.hasRemote.mockResolvedValue(true); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('settings:configure-sync'); const r = await h({}, 'git@github.com:user/new-repo.git'); expect(gitInstance.run).toHaveBeenCalledWith(['remote', 'set-url', 'origin', 'git@github.com:user/new-repo.git']); expect(r).toEqual({ ok: true }); }); it('git init failure → ok: false', async () => { const { deps, gitInstance } = makeDeps(); gitInstance.isRepo.mockResolvedValue(false); gitInstance.run.mockResolvedValue({ stdout: '', stderr: 'permission denied', exitCode: 1 }); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('settings:configure-sync'); const r = await h({}, 'git@github.com:user/repo.git'); expect(r).toMatchObject({ ok: false, reason: expect.stringContaining('git init failed') }); }); it('setSyncRepoUrl throws → ok: false', async () => { const { deps, settings } = makeDeps(); settings.setSyncRepoUrl.mockRejectedValue(new Error('disk full')); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('settings:configure-sync'); const r = await h({}, 'git@github.com:user/repo.git'); expect(r).toMatchObject({ ok: false, reason: expect.stringContaining('persist failed') }); }); }); describe('settings:test-sync-connection', () => { it('not initialized → ok: false, reason: not_initialized', async () => { const { deps, gitInstance } = makeDeps(); gitInstance.isRepo.mockResolvedValue(false); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('settings:test-sync-connection'); const r = await h({}); expect(r).toEqual({ ok: false, reason: 'not_initialized' }); }); it('ls-remote success → ok: true', async () => { const { deps, gitInstance } = makeDeps(); gitInstance.isRepo.mockResolvedValue(true); gitInstance.run.mockResolvedValue({ stdout: 'abc123\trefs/heads/main', stderr: '', exitCode: 0 }); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('settings:test-sync-connection'); const r = await h({}); expect(gitInstance.run).toHaveBeenCalledWith(['ls-remote', 'origin']); expect(r).toEqual({ ok: true }); }); it('ls-remote failure → ok: false', async () => { const { deps, gitInstance } = makeDeps(); gitInstance.isRepo.mockResolvedValue(true); gitInstance.run.mockResolvedValue({ stdout: '', stderr: 'connection refused', exitCode: 128 }); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('settings:test-sync-connection'); const r = await h({}); expect(r).toMatchObject({ ok: false, reason: 'connection refused' }); }); }); describe('sync:list-conflicts', () => { it('returns syncSvc.listConflicts() result', () => { const { deps, syncSvc } = makeDeps(); const conflicts = [{ path: 'notes/abc.md', localText: 'local', remoteText: 'remote' }]; syncSvc.listConflicts.mockReturnValue(conflicts); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:list-conflicts'); const r = h({}); expect(r).toEqual(conflicts); }); }); describe('sync:resolve-conflict', () => { it('valid choice "local" → delegates to syncSvc.resolveConflict (path)', async () => { const { deps, syncSvc } = makeDeps(); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:resolve-conflict'); const r = await h({}, 'notes/note-1.md', 'local'); expect(syncSvc.resolveConflict).toHaveBeenCalledWith('notes/note-1.md', 'local'); expect(r).toEqual({ ok: true }); }); it('valid choice "remote" → delegates to syncSvc.resolveConflict (path)', async () => { const { deps, syncSvc } = makeDeps(); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:resolve-conflict'); await h({}, 'notes/note-2.md', 'remote'); expect(syncSvc.resolveConflict).toHaveBeenCalledWith('notes/note-2.md', 'remote'); }); it('invalid choice → ok: false, reason: invalid choice', async () => { const { deps, syncSvc } = makeDeps(); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:resolve-conflict'); const r = await h({}, 'notes/note-1.md', 'both'); expect(syncSvc.resolveConflict).not.toHaveBeenCalled(); expect(r).toEqual({ ok: false, reason: 'invalid choice' }); }); }); describe('sync:get-status', () => { it('auto-sync disabled → nextAt: null', async () => { const { deps, syncSvc, settings } = makeDeps(); settings.isAutoSyncEnabled.mockResolvedValue(false); syncSvc.getLastStatus.mockReturnValue({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true } }); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:get-status'); const r = await h({}); expect(r).toMatchObject({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true }, nextAt: null }); }); it('auto-sync enabled → nextAt computed from lastAt + interval', async () => { const { deps, syncSvc, settings } = makeDeps(); settings.isAutoSyncEnabled.mockResolvedValue(true); settings.getSyncIntervalMin.mockResolvedValue(30); syncSvc.getLastStatus.mockReturnValue({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true } }); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:get-status'); const r = (await h({})) as { lastAt: string; nextAt: string }; const expectedNextAt = new Date(new Date('2026-05-09T10:00:00.000Z').getTime() + 30 * 60 * 1000).toISOString(); expect(r.nextAt).toBe(expectedNextAt); }); it('no previous sync + auto-sync enabled → nextAt based on Date.now()', async () => { const { deps, syncSvc, settings } = makeDeps(); settings.isAutoSyncEnabled.mockResolvedValue(true); settings.getSyncIntervalMin.mockResolvedValue(15); syncSvc.getLastStatus.mockReturnValue({ lastAt: null, lastResult: null }); registerSettingsApi(deps as SettingsIpcDeps); const h = getHandler('sync:get-status'); const before = Date.now(); const r = (await h({})) as { lastAt: null; nextAt: string }; const after = Date.now(); const nextAtMs = new Date(r.nextAt).getTime(); expect(nextAtMs).toBeGreaterThanOrEqual(before + 15 * 60 * 1000); expect(nextAtMs).toBeLessThanOrEqual(after + 15 * 60 * 1000); expect(r.lastAt).toBeNull(); }); }); });