settings:configure-sync IPC 핸들러가 `git -C <syncDir> init` 호출 전에 syncDir 디렉토리를 생성하지 않아, sync 첫 설정 시 git 이 chdir 단계에서 `fatal: cannot change to '<profileDir>/sync': No such file or directory` 로 실패하던 문제. SyncService.runSync() 의 동일 패턴 (mkdir recursive) 을 핸들러에도 추가. 연쇄 증상: SyncSection 의 "연결 테스트" 버튼 disabled 조건이 저장된 url state 기반이라, 저장 실패로 url 영영 비어 있어 버튼 활성화 불가 (닭/달걀). mkdir fix 로 자동 해소. 회귀: sync-ipc.test.ts 에 mkdir 호출 순서 검증 1건 추가 (18 pass). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
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<typeof vi.fn> }).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<typeof vi.fn>).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<SettingsIpcDeps> = {
|
|
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<typeof vi.fn> }).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<typeof vi.fn> }).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 <syncDir> 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<typeof vi.fn>).mockImplementationOnce(async () => { callOrder.push('mkdir'); });
|
|
(gitInstance.run as unknown as ReturnType<typeof vi.fn>).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();
|
|
});
|
|
});
|
|
});
|