import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import Database from 'better-sqlite3'; import { mkdtempSync, rmSync, mkdirSync, existsSync, closeSync, openSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { runMigrations } from '@main/db/migrations/index.js'; import { NoteRepository } from '@main/repository/NoteRepository.js'; import { MediaStore } from '@main/services/MediaStore.js'; import { ExportService } from '@main/services/ExportService.js'; import { ImportService } from '@main/services/ImportService.js'; import { SyncService } from '@main/services/SyncService.js'; const execFileAsync = promisify(execFile); let emptyConfigPath: string; let emptyConfigDir: string; function gitEnv(): NodeJS.ProcessEnv { return { ...process.env, GIT_CONFIG_GLOBAL: emptyConfigPath, GIT_CONFIG_SYSTEM: emptyConfigPath, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@inkling.local', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@inkling.local' }; } async function initRepoWithBareRemote(workDir: string): Promise { mkdirSync(workDir, { recursive: true }); const env = gitEnv(); await execFileAsync('git', ['init', '-b', 'main', workDir], { env }); await execFileAsync('git', ['-C', workDir, 'config', 'user.email', 'test@inkling.local'], { env }); await execFileAsync('git', ['-C', workDir, 'config', 'user.name', 'Test'], { env }); // Bare local remote so push works without network. const remoteDir = `${workDir}-remote.git`; await execFileAsync('git', ['init', '--bare', '-b', 'main', remoteDir], { env }); await execFileAsync('git', ['-C', workDir, 'remote', 'add', 'origin', remoteDir], { env }); return remoteDir; } describe('SyncService', () => { let profileDir: string; let db: Database.Database; let repo: NoteRepository; let mediaStore: MediaStore; let exportSvc: ExportService; let importSvc: ImportService; let svc: SyncService; let remoteDir: string | null = null; let prevEnv: NodeJS.ProcessEnv; beforeEach(() => { emptyConfigDir = mkdtempSync(join(tmpdir(), 'inkling-sync-cfg-')); emptyConfigPath = join(emptyConfigDir, 'empty.gitconfig'); closeSync(openSync(emptyConfigPath, 'w')); prevEnv = { ...process.env }; Object.assign(process.env, { GIT_CONFIG_GLOBAL: emptyConfigPath, GIT_CONFIG_SYSTEM: emptyConfigPath, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@inkling.local', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@inkling.local' }); profileDir = mkdtempSync(join(tmpdir(), 'inkling-sync-')); mkdirSync(join(profileDir, 'media'), { recursive: true }); db = new Database(':memory:'); runMigrations(db); repo = new NoteRepository(db); mediaStore = new MediaStore(profileDir); exportSvc = new ExportService(repo, mediaStore, () => new Date('2026-04-26T12:00:00Z')); importSvc = new ImportService(repo, mediaStore); svc = new SyncService(profileDir, exportSvc, importSvc, () => new Date('2026-04-26T12:00:00Z')); }); afterEach(() => { db.close(); rmSync(profileDir, { recursive: true, force: true }); if (remoteDir !== null) { rmSync(remoteDir, { recursive: true, force: true }); remoteDir = null; } rmSync(emptyConfigDir, { recursive: true, force: true }); for (const k of Object.keys(process.env)) delete process.env[k]; Object.assign(process.env, prevEnv); }); it('isConfigured returns false before sync dir is set up', async () => { expect(await svc.isConfigured()).toBe(false); }); it('sync returns not_configured when sync dir missing', async () => { const r = await svc.sync(); expect(r.ok).toBe(false); expect(r.reason).toBe('not_configured'); }); it('isConfigured returns true after init + remote', async () => { remoteDir = await initRepoWithBareRemote(svc.getSyncDir()); expect(await svc.isConfigured()).toBe(true); }); it('sync writes export tree, commits, pushes — first run', async () => { remoteDir = await initRepoWithBareRemote(svc.getSyncDir()); repo.create({ rawText: 'first note' }); const r = await svc.sync(); expect(r.ok).toBe(true); expect(r.changed).toBe(true); expect(r.pushed).toBe(true); expect(r.localSha).toMatch(/^[0-9a-f]{40}$/); expect(existsSync(join(svc.getSyncDir(), 'manifest.json'))).toBe(true); expect(existsSync(join(svc.getSyncDir(), 'notes'))).toBe(true); expect(existsSync(join(svc.getSyncDir(), 'index.jsonl'))).toBe(true); }); it('sync returns changed=false when DB unchanged on second run', async () => { remoteDir = await initRepoWithBareRemote(svc.getSyncDir()); repo.create({ rawText: 'first note' }); const first = await svc.sync(); expect(first.ok).toBe(true); expect(first.changed).toBe(true); // Re-sync without DB change. With fixed now() → identical files → git sees no local change. // New bidirectional flow: always does fetch+rebase+re-import+push. const second = await svc.sync(); expect(second.ok).toBe(true); expect(second.changed).toBe(false); // no local commit + importedCount=0 expect(second.pushed).toBe(true); // push always runs on success }); });