diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c960d99 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "chat.tools.terminal.autoApprove": { + "npx vitest": true + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cf560b0..069ca5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,8 +20,10 @@ "@tiptap/react": "^3.19.0", "@tiptap/starter-kit": "^3.19.0", "@types/turndown": "^5.0.6", + "chokidar": "^5.0.0", "date-fns": "^4.1.0", "drizzle-orm": "^0.29.0", + "dropbox": "^10.34.0", "electron-store": "^8.1.0", "gray-matter": "^4.0.3", "marked-react": "^3.0.2", @@ -38,6 +40,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/chokidar": "^1.7.5", "@types/node": "^20.10.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -4401,6 +4404,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chokidar": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@types/chokidar/-/chokidar-1.7.5.tgz", + "integrity": "sha512-PDkSRY7KltW3M60hSBlerxI8SFPXsO3AL/aRVsO4Kh9IHRW74Ih75gUuTd/aE4LSSFqypb10UIX3QzOJwBQMGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/events": "*", + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -4418,6 +4432,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/events": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", + "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -4488,6 +4509,7 @@ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "form-data": "^4.0.4" @@ -5667,6 +5689,21 @@ "node": "*" } }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -6783,6 +6820,41 @@ } } }, + "node_modules/dropbox": { + "version": "10.34.0", + "resolved": "https://registry.npmjs.org/dropbox/-/dropbox-10.34.0.tgz", + "integrity": "sha512-5jb5/XzU0fSnq36/hEpwT5/QIep7MgqKuxghEG44xCu7HruOAjPdOb3x0geXv5O/hd0nHpQpWO+r5MjYTpMvJg==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1" + }, + "engines": { + "node": ">=0.10.3" + }, + "peerDependencies": { + "@types/node-fetch": "^2.5.7" + } + }, + "node_modules/dropbox/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -8657,7 +8729,6 @@ "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", @@ -10400,6 +10471,19 @@ "node": ">=10" } }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", diff --git a/package.json b/package.json index 3cb4805..00d5023 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/chokidar": "^1.7.5", "@types/node": "^20.10.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -62,8 +63,10 @@ "@tiptap/react": "^3.19.0", "@tiptap/starter-kit": "^3.19.0", "@types/turndown": "^5.0.6", + "chokidar": "^5.0.0", "date-fns": "^4.1.0", "drizzle-orm": "^0.29.0", + "dropbox": "^10.34.0", "electron-store": "^8.1.0", "gray-matter": "^4.0.3", "marked-react": "^3.0.2", diff --git a/src/main/engine/DropboxSyncEngine.ts b/src/main/engine/DropboxSyncEngine.ts new file mode 100644 index 0000000..91da8e1 --- /dev/null +++ b/src/main/engine/DropboxSyncEngine.ts @@ -0,0 +1,873 @@ +import { EventEmitter } from 'events'; +import { v4 as uuidv4 } from 'uuid'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import { Dropbox } from 'dropbox'; +import { watch as chokidarWatch, type FSWatcher } from 'chokidar'; + +// ============================================ +// Types & Interfaces +// ============================================ + +export interface DropboxSyncConfig { + accessToken?: string; + refreshToken?: string; + appKey: string; + appSecret?: string; + syncEnabled: boolean; + syncInterval: number; // seconds between remote polls + localPostsDir: string; + localMediaDir: string; + remoteBasePath?: string; // e.g., '/bds' or '' for app folder root +} + +export type DropboxSyncStatus = 'idle' | 'syncing' | 'watching' | 'error' | 'unauthorized'; + +export type ConflictResolution = 'local-wins' | 'remote-wins' | 'manual'; + +export interface DropboxConflict { + id: string; + localPath: string; + remotePath: string; + localModified: Date; + remoteModified: Date; + localHash: string; + remoteHash: string; +} + +export interface DropboxRemoteChange { + tag: 'file' | 'folder' | 'deleted'; + name: string; + pathLower: string; + contentHash?: string; + serverModified?: string; + size?: number; +} + +export interface DropboxChangesResult { + entries: DropboxRemoteChange[]; + cursor: string; + hasMore: boolean; +} + +export interface FileUploadResult { + remotePath: string; + contentHash: string; + serverModified: string; + size: number; +} + +export interface FileDownloadResult { + localPath: string; + remotePath: string; + contentHash: string; + serverModified: string; + size: number; +} + +export interface FileSyncResult { + success: boolean; + uploaded: number; + downloaded: number; + deleted: number; + conflicts: number; + errors: string[]; +} + +// ============================================ +// DropboxSyncEngine +// ============================================ + +export class DropboxSyncEngine extends EventEmitter { + private status: DropboxSyncStatus = 'idle'; + private config: DropboxSyncConfig | null = null; + private dropboxClient: Dropbox; + private watchFn: typeof chokidarWatch; + private watcher: FSWatcher | null = null; + private pollIntervalId: NodeJS.Timeout | null = null; + private pendingConflicts: Map = new Map(); + private cursor: string | null = null; + private lastSyncTime: Date | null = null; + private isSyncing = false; + + // Debounce tracking for local file changes + private pendingUploads: Map = new Map(); + private uploadDebounceMs = 1000; + + // Track files we wrote ourselves (to ignore watcher events) + private recentDownloads: Set = new Set(); + + constructor(dropboxClient?: Dropbox, watchFn?: typeof chokidarWatch) { + super(); + this.dropboxClient = dropboxClient || new Dropbox({}); + this.watchFn = watchFn || chokidarWatch; + } + + // ============================================ + // Status & Configuration + // ============================================ + + getStatus(): DropboxSyncStatus { + return this.status; + } + + isConfigured(): boolean { + return this.config !== null && !!this.config.accessToken; + } + + getRemoteBasePath(): string { + return this.config?.remoteBasePath ?? ''; + } + + async configure(config: DropboxSyncConfig): Promise { + this.config = { + ...config, + remoteBasePath: config.remoteBasePath ?? '', + }; + + // Update the Dropbox client with new auth if using default client + if (config.accessToken) { + // Re-initialize client would happen here for real SDK usage + } + + this.emit('configured', this.config); + } + + // ============================================ + // Path Mapping + // ============================================ + + /** + * Map a local filesystem path to a remote Dropbox path. + * Returns null if the path doesn't fall under posts or media directories. + */ + localToRemotePath(localPath: string): string | null { + if (!this.config) return null; + + const normalizedLocal = localPath.replace(/\\/g, '/'); + const postsDir = this.config.localPostsDir.replace(/\\/g, '/'); + const mediaDir = this.config.localMediaDir.replace(/\\/g, '/'); + const basePath = this.config.remoteBasePath ?? ''; + + if (normalizedLocal.startsWith(postsDir)) { + const relativePath = normalizedLocal.slice(postsDir.length); + return `${basePath}/posts${relativePath}`; + } + + if (normalizedLocal.startsWith(mediaDir)) { + const relativePath = normalizedLocal.slice(mediaDir.length); + return `${basePath}/media${relativePath}`; + } + + return null; + } + + /** + * Map a remote Dropbox path to a local filesystem path. + * Returns null if the path doesn't match posts or media remote paths. + */ + remoteToLocalPath(remotePath: string): string | null { + if (!this.config) return null; + + const basePath = this.config.remoteBasePath ?? ''; + const postsPrefix = `${basePath}/posts`; + const mediaPrefix = `${basePath}/media`; + + if (remotePath.startsWith(postsPrefix)) { + const relativePath = remotePath.slice(postsPrefix.length); + return `${this.config.localPostsDir}${relativePath}`; + } + + if (remotePath.startsWith(mediaPrefix)) { + const relativePath = remotePath.slice(mediaPrefix.length); + return `${this.config.localMediaDir}${relativePath}`; + } + + return null; + } + + // ============================================ + // File Upload + // ============================================ + + async uploadFile(localPath: string): Promise { + const remotePath = this.localToRemotePath(localPath); + if (!remotePath) { + throw new Error('Cannot map local path to remote path'); + } + + let content: Buffer; + try { + content = await fs.readFile(localPath) as Buffer; + } catch (error) { + throw error; + } + + try { + const response = await this.dropboxClient.filesUpload({ + path: remotePath, + contents: content, + mode: { '.tag': 'overwrite' }, + autorename: false, + }); + + const result: FileUploadResult = { + remotePath: response.result.path_lower || remotePath, + contentHash: (response.result as any).content_hash || '', + serverModified: (response.result as any).server_modified || new Date().toISOString(), + size: (response.result as any).size || content.length, + }; + + this.emit('fileUploaded', { + localPath, + remotePath: result.remotePath, + contentHash: result.contentHash, + }); + + return result; + } catch (error: any) { + if (error?.status === 401) { + this.emit('authError', error); + } + throw error; + } + } + + // ============================================ + // File Download + // ============================================ + + async downloadFile(remotePath: string): Promise { + const localPath = this.remoteToLocalPath(remotePath); + if (!localPath) { + throw new Error('Cannot map remote path to local path'); + } + + const response = await this.dropboxClient.filesDownload({ path: remotePath }); + const result = response.result as any; + const content: Buffer = result.fileBinary; + + // Ensure the target directory exists + const dir = path.dirname(localPath); + await fs.mkdir(dir, { recursive: true }); + + // Mark as our own write so the watcher ignores it + this.recentDownloads.add(localPath); + setTimeout(() => this.recentDownloads.delete(localPath), 2000); + + await fs.writeFile(localPath, content); + + const downloadResult: FileDownloadResult = { + localPath, + remotePath, + contentHash: result.content_hash || '', + serverModified: result.server_modified || new Date().toISOString(), + size: result.size || content.length, + }; + + this.emit('fileDownloaded', downloadResult); + + return downloadResult; + } + + // ============================================ + // File Deletion (Remote) + // ============================================ + + async deleteRemoteFile(remotePath: string): Promise { + try { + await this.dropboxClient.filesDeleteV2({ path: remotePath }); + this.emit('fileDeleted', { remotePath }); + } catch (error: any) { + // If the file is already gone, that's fine + const isNotFound = + error?.error?.['.tag'] === 'path_lookup' || + error?.error?.path_lookup?.['.tag'] === 'not_found'; + if (!isNotFound) { + throw error; + } + } + } + + // ============================================ + // Delta Sync (Cursor-based) + // ============================================ + + async getLatestCursor(): Promise { + const basePath = this.config?.remoteBasePath ?? ''; + const response = await this.dropboxClient.filesListFolderGetLatestCursor({ + path: basePath, + recursive: true, + include_deleted: true, + } as any); + + return response.result.cursor; + } + + async getRemoteChanges(cursor: string): Promise { + const response = await this.dropboxClient.filesListFolderContinue({ cursor }); + const result = response.result; + + const entries: DropboxRemoteChange[] = result.entries.map((entry: any) => ({ + tag: entry['.tag'] as 'file' | 'folder' | 'deleted', + name: entry.name, + pathLower: entry.path_lower, + contentHash: entry.content_hash, + serverModified: entry.server_modified, + size: entry.size, + })); + + return { + entries, + cursor: result.cursor, + hasMore: result.has_more, + }; + } + + /** + * Fetch all remote changes, following pagination automatically. + */ + async getAllRemoteChanges(cursor: string): Promise { + const allEntries: DropboxRemoteChange[] = []; + let currentCursor = cursor; + let hasMore = true; + + while (hasMore) { + const changes = await this.getRemoteChanges(currentCursor); + allEntries.push(...changes.entries); + currentCursor = changes.cursor; + hasMore = changes.hasMore; + } + + return { + entries: allEntries, + cursor: currentCursor, + hasMore: false, + }; + } + + // ============================================ + // Full Sync Operation + // ============================================ + + async syncAll(): Promise { + if (!this.isConfigured()) { + return { + success: false, + uploaded: 0, + downloaded: 0, + deleted: 0, + conflicts: 0, + errors: ['Dropbox sync not configured'], + }; + } + + if (this.isSyncing) { + return { + success: false, + uploaded: 0, + downloaded: 0, + deleted: 0, + conflicts: 0, + errors: ['Sync already in progress'], + }; + } + + this.isSyncing = true; + this.status = 'syncing'; + this.emit('syncStarted'); + + const result: FileSyncResult = { + success: true, + uploaded: 0, + downloaded: 0, + deleted: 0, + conflicts: 0, + errors: [], + }; + + try { + // Phase 1: Get remote state + let remoteFiles: Map; + try { + remoteFiles = await this.listRemoteFiles(); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + result.success = false; + result.errors.push(`Failed to list remote files: ${msg}`); + this.status = 'error'; + this.emit('syncFailed', msg); + return result; + } + + // Phase 2: Get local state + const localFiles = await this.listLocalFiles(); + + // Phase 3: Compare and sync + // Files only on remote → download + for (const [remotePath, remoteEntry] of remoteFiles) { + const localPath = this.remoteToLocalPath(remotePath); + if (!localPath) continue; + + if (!localFiles.has(localPath)) { + try { + await this.downloadFile(remotePath); + result.downloaded++; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + result.errors.push(`Download failed ${remotePath}: ${msg}`); + } + } + } + + // Files only on local → upload + for (const [localPath] of localFiles) { + const remotePath = this.localToRemotePath(localPath); + if (!remotePath) continue; + + if (!remoteFiles.has(remotePath)) { + try { + await this.uploadFile(localPath); + result.uploaded++; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + result.errors.push(`Upload failed ${localPath}: ${msg}`); + } + } + } + + // Files on both → compare hashes, handle conflicts + for (const [remotePath, remoteEntry] of remoteFiles) { + const localPath = this.remoteToLocalPath(remotePath); + if (!localPath || !localFiles.has(localPath)) continue; + + const localInfo = localFiles.get(localPath)!; + const localContent = await fs.readFile(localPath) as Buffer; + const localHash = this.calculateContentHash(localContent); + + if (remoteEntry.contentHash && localHash !== remoteEntry.contentHash) { + // Both modified → conflict + const conflict = this.createConflict(localPath, remotePath, { + localModified: localInfo.mtime, + remoteModified: remoteEntry.serverModified + ? new Date(remoteEntry.serverModified) + : new Date(), + localHash, + remoteHash: remoteEntry.contentHash, + }); + this.addPendingConflict(conflict); + result.conflicts++; + } + } + + // Update cursor + try { + const newCursor = await this.getLatestCursor(); + this.cursor = newCursor; + } catch { + // Non-fatal: cursor update failure doesn't invalidate the sync + } + + this.lastSyncTime = new Date(); + this.status = 'idle'; + this.emit('syncCompleted', result); + + return result; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + result.success = false; + result.errors.push(msg); + this.status = 'error'; + this.emit('syncFailed', msg); + return result; + } finally { + this.isSyncing = false; + } + } + + // ============================================ + // List Remote Files + // ============================================ + + private async listRemoteFiles(): Promise> { + const files = new Map(); + const basePath = this.config?.remoteBasePath ?? ''; + + const response = await this.dropboxClient.filesListFolder({ + path: basePath, + recursive: true, + include_deleted: false, + } as any); + + for (const entry of response.result.entries as any[]) { + if (entry['.tag'] === 'file') { + files.set(entry.path_lower, { + tag: 'file', + name: entry.name, + pathLower: entry.path_lower, + contentHash: entry.content_hash, + serverModified: entry.server_modified, + size: entry.size, + }); + } + } + + let cursor = response.result.cursor; + let hasMore = response.result.has_more; + + while (hasMore) { + const continueResponse = await this.dropboxClient.filesListFolderContinue({ cursor }); + for (const entry of continueResponse.result.entries as any[]) { + if (entry['.tag'] === 'file') { + files.set(entry.path_lower, { + tag: 'file', + name: entry.name, + pathLower: entry.path_lower, + contentHash: entry.content_hash, + serverModified: entry.server_modified, + size: entry.size, + }); + } + } + cursor = continueResponse.result.cursor; + hasMore = continueResponse.result.has_more; + } + + return files; + } + + // ============================================ + // List Local Files + // ============================================ + + private async listLocalFiles(): Promise> { + const files = new Map(); + + if (!this.config) return files; + + const dirs = [this.config.localPostsDir, this.config.localMediaDir]; + + for (const dir of dirs) { + try { + await this.walkDirectory(dir, files); + } catch { + // Directory may not exist yet + } + } + + return files; + } + + private async walkDirectory( + dir: string, + files: Map + ): Promise { + let entries: string[]; + try { + entries = await fs.readdir(dir) as unknown as string[]; + } catch { + return; + } + + for (const entry of entries) { + const fullPath = path.join(dir, entry); + try { + const stat = await fs.stat(fullPath); + if (stat.isFile()) { + files.set(fullPath.replace(/\\/g, '/'), { + mtime: stat.mtime, + size: stat.size, + }); + } else if (stat.isDirectory()) { + await this.walkDirectory(fullPath, files); + } + } catch { + // Skip files we can't stat + } + } + } + + // ============================================ + // Conflict Management + // ============================================ + + createConflict( + localPath: string, + remotePath: string, + details: { + localModified: Date; + remoteModified: Date; + localHash: string; + remoteHash: string; + } + ): DropboxConflict { + const conflict: DropboxConflict = { + id: uuidv4(), + localPath, + remotePath, + localModified: details.localModified, + remoteModified: details.remoteModified, + localHash: details.localHash, + remoteHash: details.remoteHash, + }; + + this.emit('conflictDetected', conflict); + return conflict; + } + + async resolveConflict( + conflict: DropboxConflict, + resolution: ConflictResolution + ): Promise { + if (resolution === 'local-wins') { + // Upload local version to overwrite remote + await this.uploadFile(conflict.localPath); + } else if (resolution === 'remote-wins') { + // Download remote version to overwrite local + await this.downloadFile(conflict.remotePath); + } + // 'manual' resolution is handled by the UI + + this.removePendingConflict(conflict.id); + this.emit('conflictResolved', conflict, resolution); + } + + addPendingConflict(conflict: DropboxConflict): void { + this.pendingConflicts.set(conflict.id, conflict); + } + + removePendingConflict(id: string): void { + this.pendingConflicts.delete(id); + } + + getPendingConflicts(): DropboxConflict[] { + return Array.from(this.pendingConflicts.values()); + } + + clearPendingConflicts(): void { + this.pendingConflicts.clear(); + } + + // ============================================ + // Content Hash (Dropbox-compatible) + // ============================================ + + /** + * Calculate a content hash for a buffer. + * Uses SHA-256 of 4 MB blocks, then SHA-256 of the concatenated block hashes, + * matching Dropbox's content_hash algorithm. + */ + calculateContentHash(content: Buffer): string { + const BLOCK_SIZE = 4 * 1024 * 1024; // 4 MB + const blockHashes: Buffer[] = []; + + for (let offset = 0; offset < content.length; offset += BLOCK_SIZE) { + const block = content.subarray(offset, Math.min(offset + BLOCK_SIZE, content.length)); + const hash = crypto.createHash('sha256').update(block).digest(); + blockHashes.push(hash); + } + + // Handle empty file + if (blockHashes.length === 0) { + blockHashes.push(crypto.createHash('sha256').update(Buffer.alloc(0)).digest()); + } + + const concatenated = Buffer.concat(blockHashes); + return crypto.createHash('sha256').update(concatenated).digest('hex'); + } + + // ============================================ + // Local File Watching + // ============================================ + + startWatching(): void { + if (!this.config) return; + + const watchPaths = [ + this.config.localPostsDir, + this.config.localMediaDir, + ]; + + this.watcher = this.watchFn(watchPaths, { + ignoreInitial: true, + persistent: true, + awaitWriteFinish: { + stabilityThreshold: 500, + pollInterval: 100, + }, + }); + + this.watcher + .on('add', (filePath: string) => this.handleLocalFileChange(filePath, 'add')) + .on('change', (filePath: string) => this.handleLocalFileChange(filePath, 'change')) + .on('unlink', (filePath: string) => this.handleLocalFileUnlink(filePath)); + + this.status = 'watching'; + this.emit('watchStarted'); + } + + stopWatching(): void { + if (this.watcher) { + this.watcher.close(); + this.watcher = null; + } + + // Clear any pending upload debounces + for (const timeout of this.pendingUploads.values()) { + clearTimeout(timeout); + } + this.pendingUploads.clear(); + + if (this.status === 'watching') { + this.status = 'idle'; + } + this.emit('watchStopped'); + } + + private handleLocalFileChange(filePath: string, event: 'add' | 'change'): void { + const normalizedPath = filePath.replace(/\\/g, '/'); + + // Skip files we just downloaded from Dropbox + if (this.recentDownloads.has(normalizedPath)) { + return; + } + + // Debounce uploads to avoid rapid successive uploads + if (this.pendingUploads.has(normalizedPath)) { + clearTimeout(this.pendingUploads.get(normalizedPath)!); + } + + const timeout = setTimeout(async () => { + this.pendingUploads.delete(normalizedPath); + try { + await this.uploadFile(normalizedPath); + this.emit('localFileChanged', { path: normalizedPath, event }); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + this.emit('uploadError', { path: normalizedPath, error: msg }); + } + }, this.uploadDebounceMs); + + this.pendingUploads.set(normalizedPath, timeout); + } + + private async handleLocalFileUnlink(filePath: string): Promise { + const normalizedPath = filePath.replace(/\\/g, '/'); + const remotePath = this.localToRemotePath(normalizedPath); + + if (remotePath) { + try { + await this.deleteRemoteFile(remotePath); + this.emit('localFileDeleted', { localPath: normalizedPath, remotePath }); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + this.emit('deleteError', { path: normalizedPath, error: msg }); + } + } + } + + // ============================================ + // Remote Polling + // ============================================ + + startPolling(): void { + if (this.pollIntervalId) return; + + const intervalMs = (this.config?.syncInterval ?? 60) * 1000; + + this.pollIntervalId = setInterval(async () => { + await this.pollRemoteChanges(); + }, intervalMs); + + this.emit('pollingStarted'); + } + + stopPolling(): void { + if (this.pollIntervalId) { + clearInterval(this.pollIntervalId); + this.pollIntervalId = null; + } + this.emit('pollingStopped'); + } + + isPolling(): boolean { + return this.pollIntervalId !== null; + } + + private async pollRemoteChanges(): Promise { + if (!this.cursor) { + try { + this.cursor = await this.getLatestCursor(); + } catch { + return; + } + } + + try { + const changes = await this.getAllRemoteChanges(this.cursor); + this.cursor = changes.cursor; + + for (const entry of changes.entries) { + if (entry.tag === 'file') { + try { + await this.downloadFile(entry.pathLower); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + this.emit('downloadError', { path: entry.pathLower, error: msg }); + } + } else if (entry.tag === 'deleted') { + const localPath = this.remoteToLocalPath(entry.pathLower); + if (localPath) { + try { + await fs.unlink(localPath); + this.emit('remoteFileDeleted', { remotePath: entry.pathLower, localPath }); + } catch { + // File may already be deleted locally + } + } + } + } + + if (changes.entries.length > 0) { + this.emit('remoteChangesApplied', { count: changes.entries.length }); + } + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + this.emit('pollError', { error: msg }); + } + } + + // ============================================ + // Sync State + // ============================================ + + getCursor(): string | null { + return this.cursor; + } + + setCursor(cursor: string): void { + this.cursor = cursor; + } + + getLastSyncTime(): Date | null { + return this.lastSyncTime; + } + + setLastSyncTime(time: Date): void { + this.lastSyncTime = time; + } +} + +// ============================================ +// Singleton +// ============================================ + +let dropboxSyncEngine: DropboxSyncEngine | null = null; + +export function getDropboxSyncEngine(): DropboxSyncEngine { + if (!dropboxSyncEngine) { + dropboxSyncEngine = new DropboxSyncEngine(); + } + return dropboxSyncEngine; +} diff --git a/src/main/engine/SyncEngine.ts b/src/main/engine/SyncEngine.ts index 60f1401..5f723c8 100644 --- a/src/main/engine/SyncEngine.ts +++ b/src/main/engine/SyncEngine.ts @@ -6,6 +6,7 @@ import { syncLog, posts, media, NewSyncLogEntry } from '../database/schema'; import { taskManager, Task } from './TaskManager'; import { getPostEngine } from './PostEngine'; import { getMediaEngine } from './MediaEngine'; +import { getDropboxSyncEngine } from './DropboxSyncEngine'; export type SyncDirection = 'push' | 'pull' | 'bidirectional'; export type SyncStatus = 'idle' | 'syncing' | 'error'; @@ -23,6 +24,13 @@ export interface SyncResult { pulled: number; conflicts: number; errors: string[]; + dropboxResult?: { + uploaded: number; + downloaded: number; + deleted: number; + conflicts: number; + errors: string[]; + }; } export class SyncEngine extends EventEmitter { @@ -311,6 +319,41 @@ export class SyncEngine extends EventEmitter { } this.emit('autoSyncStopped'); } + + /** + * Full sync: metadata via Turso + files via Dropbox. + * Coordinates both sync engines for a complete bidirectional sync. + */ + async fullSync(direction: SyncDirection = 'bidirectional'): Promise { + // Run metadata sync (Turso) + const metadataResult = await this.sync(direction); + + // Run file sync (Dropbox) if configured + const dropboxEngine = getDropboxSyncEngine(); + if (dropboxEngine.isConfigured()) { + try { + const fileResult = await dropboxEngine.syncAll(); + metadataResult.dropboxResult = { + uploaded: fileResult.uploaded, + downloaded: fileResult.downloaded, + deleted: fileResult.deleted, + conflicts: fileResult.conflicts, + errors: fileResult.errors, + }; + + if (!fileResult.success) { + metadataResult.errors.push( + ...fileResult.errors.map(e => `Dropbox: ${e}`) + ); + } + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown Dropbox error'; + metadataResult.errors.push(`Dropbox sync failed: ${msg}`); + } + } + + return metadataResult; + } } // Singleton instance diff --git a/src/main/engine/index.ts b/src/main/engine/index.ts index 8090160..c1179ed 100644 --- a/src/main/engine/index.ts +++ b/src/main/engine/index.ts @@ -3,3 +3,16 @@ export { PostEngine, getPostEngine, type PostData, type PostFilter, type SearchR export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine'; export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine'; export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine'; +export { + DropboxSyncEngine, + getDropboxSyncEngine, + type DropboxSyncConfig, + type DropboxSyncStatus, + type DropboxConflict, + type DropboxRemoteChange, + type DropboxChangesResult, + type FileSyncResult, + type FileUploadResult, + type FileDownloadResult, + type ConflictResolution, +} from './DropboxSyncEngine'; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 88eccbc..31388a2 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -3,6 +3,7 @@ import { eq } from 'drizzle-orm'; import { getPostEngine, PostData, PostFilter } from '../engine/PostEngine'; import { getMediaEngine, MediaData } from '../engine/MediaEngine'; import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine'; +import { getDropboxSyncEngine, DropboxSyncConfig, ConflictResolution } from '../engine/DropboxSyncEngine'; import { getProjectEngine, ProjectData } from '../engine/ProjectEngine'; import { taskManager, TaskProgress } from '../engine/TaskManager'; import { getDatabase } from '../database'; @@ -289,6 +290,68 @@ export function registerIpcHandlers(): void { return engine.stopAutoSync(); }); + // ============ Dropbox Sync Handlers ============ + + ipcMain.handle('dropbox:configure', async (_, config: DropboxSyncConfig) => { + const engine = getDropboxSyncEngine(); + return engine.configure(config); + }); + + ipcMain.handle('dropbox:isConfigured', async () => { + const engine = getDropboxSyncEngine(); + return engine.isConfigured(); + }); + + ipcMain.handle('dropbox:getStatus', async () => { + const engine = getDropboxSyncEngine(); + return engine.getStatus(); + }); + + ipcMain.handle('dropbox:syncAll', async () => { + const engine = getDropboxSyncEngine(); + return engine.syncAll(); + }); + + ipcMain.handle('dropbox:startWatching', async () => { + const engine = getDropboxSyncEngine(); + engine.startWatching(); + }); + + ipcMain.handle('dropbox:stopWatching', async () => { + const engine = getDropboxSyncEngine(); + engine.stopWatching(); + }); + + ipcMain.handle('dropbox:startPolling', async () => { + const engine = getDropboxSyncEngine(); + engine.startPolling(); + }); + + ipcMain.handle('dropbox:stopPolling', async () => { + const engine = getDropboxSyncEngine(); + engine.stopPolling(); + }); + + ipcMain.handle('dropbox:getConflicts', async () => { + const engine = getDropboxSyncEngine(); + return engine.getPendingConflicts(); + }); + + ipcMain.handle('dropbox:resolveConflict', async (_, conflictId: string, resolution: ConflictResolution) => { + const engine = getDropboxSyncEngine(); + const conflicts = engine.getPendingConflicts(); + const conflict = conflicts.find(c => c.id === conflictId); + if (!conflict) { + throw new Error(`Conflict ${conflictId} not found`); + } + return engine.resolveConflict(conflict, resolution); + }); + + ipcMain.handle('dropbox:getLastSyncTime', async () => { + const engine = getDropboxSyncEngine(); + return engine.getLastSyncTime(); + }); + // ============ Task Handlers ============ ipcMain.handle('tasks:getAll', async () => { @@ -355,6 +418,20 @@ export function registerIpcHandlers(): void { syncEngine.on('syncCompleted', forwardEvent('sync:completed')); syncEngine.on('syncFailed', forwardEvent('sync:failed')); + const dropboxEngine = getDropboxSyncEngine(); + dropboxEngine.on('configured', forwardEvent('dropbox:configured')); + dropboxEngine.on('syncStarted', forwardEvent('dropbox:syncStarted')); + dropboxEngine.on('syncCompleted', forwardEvent('dropbox:syncCompleted')); + dropboxEngine.on('syncFailed', forwardEvent('dropbox:syncFailed')); + dropboxEngine.on('fileUploaded', forwardEvent('dropbox:fileUploaded')); + dropboxEngine.on('fileDownloaded', forwardEvent('dropbox:fileDownloaded')); + dropboxEngine.on('fileDeleted', forwardEvent('dropbox:fileDeleted')); + dropboxEngine.on('conflictDetected', forwardEvent('dropbox:conflictDetected')); + dropboxEngine.on('conflictResolved', forwardEvent('dropbox:conflictResolved')); + dropboxEngine.on('watchStarted', forwardEvent('dropbox:watchStarted')); + dropboxEngine.on('watchStopped', forwardEvent('dropbox:watchStopped')); + dropboxEngine.on('authError', forwardEvent('dropbox:authError')); + taskManager.on('taskCreated', forwardEvent('task:created')); taskManager.on('taskStarted', forwardEvent('task:started')); taskManager.on('taskProgress', forwardEvent('task:progress')); diff --git a/src/main/preload.ts b/src/main/preload.ts index 39e72a1..a8346fc 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -63,6 +63,22 @@ contextBridge.exposeInMainWorld('electronAPI', { stopAutoSync: () => ipcRenderer.invoke('sync:stopAutoSync'), }, + // Dropbox File Sync + dropbox: { + configure: (config: unknown) => ipcRenderer.invoke('dropbox:configure', config), + isConfigured: () => ipcRenderer.invoke('dropbox:isConfigured'), + getStatus: () => ipcRenderer.invoke('dropbox:getStatus'), + syncAll: () => ipcRenderer.invoke('dropbox:syncAll'), + startWatching: () => ipcRenderer.invoke('dropbox:startWatching'), + stopWatching: () => ipcRenderer.invoke('dropbox:stopWatching'), + startPolling: () => ipcRenderer.invoke('dropbox:startPolling'), + stopPolling: () => ipcRenderer.invoke('dropbox:stopPolling'), + getConflicts: () => ipcRenderer.invoke('dropbox:getConflicts'), + resolveConflict: (conflictId: string, resolution: string) => + ipcRenderer.invoke('dropbox:resolveConflict', conflictId, resolution), + getLastSyncTime: () => ipcRenderer.invoke('dropbox:getLastSyncTime'), + }, + // Tasks tasks: { getAll: () => ipcRenderer.invoke('tasks:getAll'), @@ -138,6 +154,19 @@ export interface ElectronAPI { getLog: (limit?: number) => Promise; stopAutoSync: () => Promise; }; + dropbox: { + configure: (config: unknown) => Promise; + isConfigured: () => Promise; + getStatus: () => Promise; + syncAll: () => Promise; + startWatching: () => Promise; + stopWatching: () => Promise; + startPolling: () => Promise; + stopPolling: () => Promise; + getConflicts: () => Promise; + resolveConflict: (conflictId: string, resolution: string) => Promise; + getLastSyncTime: () => Promise; + }; tasks: { getAll: () => Promise; getRunning: () => Promise; diff --git a/tests/engine/DropboxSyncEngine.test.ts b/tests/engine/DropboxSyncEngine.test.ts new file mode 100644 index 0000000..cecd9f4 --- /dev/null +++ b/tests/engine/DropboxSyncEngine.test.ts @@ -0,0 +1,1154 @@ +/** + * DropboxSyncEngine Unit Tests + * + * Tests the REAL DropboxSyncEngine class with mocked dependencies. + * Following TDD best practices: mock external dependencies (Dropbox SDK, filesystem), + * test real implementation. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + DropboxSyncEngine, + DropboxSyncConfig, + DropboxSyncStatus, + DropboxConflict, + FileSyncResult, +} from '../../src/main/engine/DropboxSyncEngine'; +import { resetMockCounters, createMockDropboxClient, createMockDropboxConfig } from '../utils/factories'; + +// ============================================ +// Mock Dependencies +// ============================================ + +// Mock fs/promises - vi.mock is hoisted, so we use vi.hoisted() for shared state +const { mockFs, mockWatcher, mockChokidarWatch } = vi.hoisted(() => { + const mockFs = { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + unlink: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), + access: vi.fn(), + }; + const mockWatcher = { + on: vi.fn().mockReturnThis(), + close: vi.fn().mockResolvedValue(undefined), + add: vi.fn(), + unwatch: vi.fn(), + }; + const mockChokidarWatch = vi.fn(() => mockWatcher); + return { mockFs, mockWatcher, mockChokidarWatch }; +}); + +vi.mock('fs/promises', () => mockFs); + +// Mock path (use posix-style for consistent tests) +vi.mock('path', async () => { + const actual = await vi.importActual('path'); + return { + ...actual, + default: actual, + }; +}); + +// Mock electron +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => '/mock/userData'), + }, +})); + +// Mock chokidar +vi.mock('chokidar', () => ({ + watch: mockChokidarWatch, + default: { watch: mockChokidarWatch }, +})); + +// Mock uuid +vi.mock('uuid', () => ({ + v4: vi.fn(() => 'mock-dropbox-uuid-' + Math.random().toString(36).substr(2, 9)), +})); + +// Mock the database module +vi.mock('../../src/main/database', () => ({ + getDatabase: vi.fn(() => ({ + getLocal: vi.fn(() => ({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + get: vi.fn(() => Promise.resolve(undefined)), + })), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + onConflictDoUpdate: vi.fn(() => Promise.resolve()), + })), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), + })), + })), + getDataPaths: vi.fn(() => ({ + database: '/mock/userData/bds.db', + posts: '/mock/userData/projects/default/posts', + media: '/mock/userData/projects/default/media', + })), + })), +})); + +describe('DropboxSyncEngine', () => { + let engine: DropboxSyncEngine; + let mockDropboxClient: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + resetMockCounters(); + + // Re-set mock implementations after clearAllMocks + mockChokidarWatch.mockReturnValue(mockWatcher); + mockWatcher.on.mockReturnThis(); + mockWatcher.close.mockResolvedValue(undefined); + + mockDropboxClient = createMockDropboxClient(); + engine = new DropboxSyncEngine(mockDropboxClient as any, mockChokidarWatch as any); + + // Default mock implementations + mockFs.readFile.mockResolvedValue(Buffer.from('test content')); + mockFs.writeFile.mockResolvedValue(undefined); + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.unlink.mockResolvedValue(undefined); + mockFs.readdir.mockResolvedValue([]); + mockFs.stat.mockResolvedValue({ + isFile: () => true, + isDirectory: () => false, + size: 100, + mtime: new Date('2026-01-15T10:00:00Z'), + }); + mockFs.access.mockResolvedValue(undefined); + }); + + afterEach(() => { + engine.stopWatching(); + engine.stopPolling(); + }); + + // ============================================ + // Constructor and Initialization + // ============================================ + + describe('Constructor and Initialization', () => { + it('should create a DropboxSyncEngine instance', () => { + expect(engine).toBeInstanceOf(DropboxSyncEngine); + }); + + it('should extend EventEmitter', () => { + expect(typeof engine.on).toBe('function'); + expect(typeof engine.emit).toBe('function'); + }); + + it('should start with idle status', () => { + expect(engine.getStatus()).toBe('idle'); + }); + + it('should start as not configured', () => { + expect(engine.isConfigured()).toBe(false); + }); + + it('should accept a custom Dropbox client', () => { + const customClient = createMockDropboxClient(); + const customEngine = new DropboxSyncEngine(customClient as any); + expect(customEngine).toBeInstanceOf(DropboxSyncEngine); + }); + }); + + // ============================================ + // Configuration + // ============================================ + + describe('Configuration', () => { + it('should configure with valid settings', async () => { + const config = createMockDropboxConfig(); + await engine.configure(config); + + expect(engine.isConfigured()).toBe(true); + }); + + it('should emit configured event on successful configuration', async () => { + const handler = vi.fn(); + engine.on('configured', handler); + + const config = createMockDropboxConfig(); + await engine.configure(config); + + expect(handler).toHaveBeenCalledWith(config); + }); + + it('should reject invalid configuration without access token', async () => { + const config = createMockDropboxConfig({ accessToken: '' }); + await engine.configure(config); + + expect(engine.isConfigured()).toBe(false); + }); + + it('should update configuration when called multiple times', async () => { + const config1 = createMockDropboxConfig({ accessToken: 'token-1' }); + const config2 = createMockDropboxConfig({ accessToken: 'token-2' }); + + await engine.configure(config1); + expect(engine.isConfigured()).toBe(true); + + await engine.configure(config2); + expect(engine.isConfigured()).toBe(true); + }); + + it('should store remote base path from config', async () => { + const config = createMockDropboxConfig({ remoteBasePath: '/my-blog' }); + await engine.configure(config); + + expect(engine.getRemoteBasePath()).toBe('/my-blog'); + }); + + it('should default remote base path to empty string for app folder', async () => { + const config = createMockDropboxConfig({ remoteBasePath: undefined }); + await engine.configure(config); + + expect(engine.getRemoteBasePath()).toBe(''); + }); + }); + + // ============================================ + // Path Mapping + // ============================================ + + describe('Path Mapping', () => { + beforeEach(async () => { + await engine.configure(createMockDropboxConfig({ + localPostsDir: '/mock/userData/projects/default/posts', + localMediaDir: '/mock/userData/projects/default/media', + remoteBasePath: '/bds', + })); + }); + + it('should map local post path to remote path', () => { + const localPath = '/mock/userData/projects/default/posts/2026/01/hello-world.md'; + const remotePath = engine.localToRemotePath(localPath); + expect(remotePath).toBe('/bds/posts/2026/01/hello-world.md'); + }); + + it('should map local media path to remote path', () => { + const localPath = '/mock/userData/projects/default/media/2026/01/image.jpg'; + const remotePath = engine.localToRemotePath(localPath); + expect(remotePath).toBe('/bds/media/2026/01/image.jpg'); + }); + + it('should map remote post path to local path', () => { + const remotePath = '/bds/posts/2026/01/hello-world.md'; + const localPath = engine.remoteToLocalPath(remotePath); + expect(localPath).toBe('/mock/userData/projects/default/posts/2026/01/hello-world.md'); + }); + + it('should map remote media path to local path', () => { + const remotePath = '/bds/media/2026/01/image.jpg'; + const localPath = engine.remoteToLocalPath(remotePath); + expect(localPath).toBe('/mock/userData/projects/default/media/2026/01/image.jpg'); + }); + + it('should return null for unmapped paths', () => { + const localPath = '/some/other/path/file.txt'; + const remotePath = engine.localToRemotePath(localPath); + expect(remotePath).toBeNull(); + }); + }); + + // ============================================ + // File Upload + // ============================================ + + describe('File Upload', () => { + beforeEach(async () => { + await engine.configure(createMockDropboxConfig({ + localPostsDir: '/mock/userData/projects/default/posts', + localMediaDir: '/mock/userData/projects/default/media', + remoteBasePath: '/bds', + })); + }); + + it('should upload a local file to Dropbox', async () => { + const localPath = '/mock/userData/projects/default/posts/2026/01/hello-world.md'; + mockFs.readFile.mockResolvedValue(Buffer.from('# Hello World')); + + await engine.uploadFile(localPath); + + expect(mockDropboxClient.filesUpload).toHaveBeenCalledWith({ + path: '/bds/posts/2026/01/hello-world.md', + contents: Buffer.from('# Hello World'), + mode: { '.tag': 'overwrite' }, + autorename: false, + }); + }); + + it('should emit fileUploaded event on successful upload', async () => { + const handler = vi.fn(); + engine.on('fileUploaded', handler); + + const localPath = '/mock/userData/projects/default/posts/2026/01/test.md'; + mockFs.readFile.mockResolvedValue(Buffer.from('content')); + + await engine.uploadFile(localPath); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + localPath, + remotePath: '/bds/posts/2026/01/test.md', + }) + ); + }); + + it('should throw error when file does not exist', async () => { + const localPath = '/mock/userData/projects/default/posts/missing.md'; + const error = new Error('ENOENT: no such file'); + (error as NodeJS.ErrnoException).code = 'ENOENT'; + mockFs.readFile.mockRejectedValue(error); + + await expect(engine.uploadFile(localPath)).rejects.toThrow('ENOENT'); + }); + + it('should throw error for unmapped paths', async () => { + await expect(engine.uploadFile('/random/path/file.txt')) + .rejects.toThrow('Cannot map local path to remote path'); + }); + + it('should return upload result with metadata', async () => { + const localPath = '/mock/userData/projects/default/posts/2026/01/test.md'; + mockFs.readFile.mockResolvedValue(Buffer.from('content')); + + mockDropboxClient.filesUpload.mockResolvedValue({ + result: { + name: 'test.md', + path_lower: '/bds/posts/2026/01/test.md', + content_hash: 'abc123hash', + server_modified: '2026-01-15T10:00:00Z', + size: 7, + }, + }); + + const result = await engine.uploadFile(localPath); + + expect(result).toEqual( + expect.objectContaining({ + remotePath: '/bds/posts/2026/01/test.md', + contentHash: 'abc123hash', + size: 7, + }) + ); + }); + }); + + // ============================================ + // File Download + // ============================================ + + describe('File Download', () => { + beforeEach(async () => { + await engine.configure(createMockDropboxConfig({ + localPostsDir: '/mock/userData/projects/default/posts', + localMediaDir: '/mock/userData/projects/default/media', + remoteBasePath: '/bds', + })); + }); + + it('should download a remote file to local path', async () => { + const remotePath = '/bds/posts/2026/01/hello-world.md'; + const fileContent = Buffer.from('# Hello World'); + + mockDropboxClient.filesDownload.mockResolvedValue({ + result: { + name: 'hello-world.md', + path_lower: remotePath, + fileBinary: fileContent, + content_hash: 'hash123', + server_modified: '2026-01-15T10:00:00Z', + size: fileContent.length, + }, + }); + + await engine.downloadFile(remotePath); + + expect(mockFs.writeFile).toHaveBeenCalledWith( + '/mock/userData/projects/default/posts/2026/01/hello-world.md', + fileContent + ); + }); + + it('should create directories before writing downloaded file', async () => { + const remotePath = '/bds/posts/2026/02/new-post.md'; + + mockDropboxClient.filesDownload.mockResolvedValue({ + result: { + name: 'new-post.md', + path_lower: remotePath, + fileBinary: Buffer.from('content'), + content_hash: 'hash456', + server_modified: '2026-02-01T10:00:00Z', + size: 7, + }, + }); + + await engine.downloadFile(remotePath); + + expect(mockFs.mkdir).toHaveBeenCalledWith( + expect.stringContaining('2026'), + { recursive: true } + ); + }); + + it('should emit fileDownloaded event on successful download', async () => { + const handler = vi.fn(); + engine.on('fileDownloaded', handler); + + const remotePath = '/bds/posts/2026/01/test.md'; + mockDropboxClient.filesDownload.mockResolvedValue({ + result: { + name: 'test.md', + path_lower: remotePath, + fileBinary: Buffer.from('content'), + content_hash: 'hash789', + server_modified: '2026-01-15T10:00:00Z', + size: 7, + }, + }); + + await engine.downloadFile(remotePath); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + remotePath, + localPath: '/mock/userData/projects/default/posts/2026/01/test.md', + }) + ); + }); + + it('should throw error for unmapped remote paths', async () => { + await expect(engine.downloadFile('/unknown/path/file.txt')) + .rejects.toThrow('Cannot map remote path to local path'); + }); + }); + + // ============================================ + // File Deletion + // ============================================ + + describe('File Deletion', () => { + beforeEach(async () => { + await engine.configure(createMockDropboxConfig({ + localPostsDir: '/mock/userData/projects/default/posts', + localMediaDir: '/mock/userData/projects/default/media', + remoteBasePath: '/bds', + })); + }); + + it('should delete a remote file', async () => { + const remotePath = '/bds/posts/2026/01/old-post.md'; + + await engine.deleteRemoteFile(remotePath); + + expect(mockDropboxClient.filesDeleteV2).toHaveBeenCalledWith({ + path: remotePath, + }); + }); + + it('should emit fileDeleted event on successful deletion', async () => { + const handler = vi.fn(); + engine.on('fileDeleted', handler); + + const remotePath = '/bds/posts/2026/01/old-post.md'; + await engine.deleteRemoteFile(remotePath); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ remotePath }) + ); + }); + + it('should handle deletion of non-existent file gracefully', async () => { + mockDropboxClient.filesDeleteV2.mockRejectedValue({ + error: { '.tag': 'path_lookup', path_lookup: { '.tag': 'not_found' } }, + status: 409, + }); + + // Should not throw - file already doesn't exist + await expect(engine.deleteRemoteFile('/bds/posts/missing.md')) + .resolves.not.toThrow(); + }); + }); + + // ============================================ + // Delta Sync (Cursor-based) + // ============================================ + + describe('Delta Sync', () => { + beforeEach(async () => { + await engine.configure(createMockDropboxConfig({ + localPostsDir: '/mock/userData/projects/default/posts', + localMediaDir: '/mock/userData/projects/default/media', + remoteBasePath: '/bds', + })); + }); + + it('should get initial cursor from Dropbox', async () => { + mockDropboxClient.filesListFolderGetLatestCursor.mockResolvedValue({ + result: { cursor: 'initial-cursor-abc' }, + }); + + const cursor = await engine.getLatestCursor(); + expect(cursor).toBe('initial-cursor-abc'); + + expect(mockDropboxClient.filesListFolderGetLatestCursor).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/bds', + recursive: true, + }) + ); + }); + + it('should list changes since last cursor', async () => { + mockDropboxClient.filesListFolderContinue.mockResolvedValue({ + result: { + entries: [ + { + '.tag': 'file', + name: 'new-post.md', + path_lower: '/bds/posts/2026/01/new-post.md', + content_hash: 'newhash123', + server_modified: '2026-01-20T12:00:00Z', + size: 500, + }, + { + '.tag': 'deleted', + name: 'old-post.md', + path_lower: '/bds/posts/2025/12/old-post.md', + }, + ], + cursor: 'new-cursor-def', + has_more: false, + }, + }); + + const changes = await engine.getRemoteChanges('previous-cursor'); + + expect(changes.entries).toHaveLength(2); + expect(changes.entries[0]).toEqual( + expect.objectContaining({ + tag: 'file', + pathLower: '/bds/posts/2026/01/new-post.md', + }) + ); + expect(changes.entries[1]).toEqual( + expect.objectContaining({ + tag: 'deleted', + pathLower: '/bds/posts/2025/12/old-post.md', + }) + ); + expect(changes.cursor).toBe('new-cursor-def'); + expect(changes.hasMore).toBe(false); + }); + + it('should handle paginated results', async () => { + mockDropboxClient.filesListFolderContinue + .mockResolvedValueOnce({ + result: { + entries: [ + { + '.tag': 'file', + name: 'file1.md', + path_lower: '/bds/posts/2026/01/file1.md', + content_hash: 'hash1', + server_modified: '2026-01-20T12:00:00Z', + size: 100, + }, + ], + cursor: 'cursor-page2', + has_more: true, + }, + }) + .mockResolvedValueOnce({ + result: { + entries: [ + { + '.tag': 'file', + name: 'file2.md', + path_lower: '/bds/posts/2026/01/file2.md', + content_hash: 'hash2', + server_modified: '2026-01-20T12:00:00Z', + size: 200, + }, + ], + cursor: 'cursor-final', + has_more: false, + }, + }); + + const allChanges = await engine.getAllRemoteChanges('initial-cursor'); + + expect(allChanges.entries).toHaveLength(2); + expect(allChanges.cursor).toBe('cursor-final'); + }); + }); + + // ============================================ + // Full Sync Operation + // ============================================ + + describe('Full Sync', () => { + beforeEach(async () => { + await engine.configure(createMockDropboxConfig({ + localPostsDir: '/mock/userData/projects/default/posts', + localMediaDir: '/mock/userData/projects/default/media', + remoteBasePath: '/bds', + })); + }); + + it('should return error result when not configured', async () => { + const unconfiguredEngine = new DropboxSyncEngine(mockDropboxClient as any); + const result = await unconfiguredEngine.syncAll(); + + expect(result.success).toBe(false); + expect(result.errors).toContain('Dropbox sync not configured'); + }); + + it('should prevent concurrent sync operations', async () => { + // Make the first sync take a while + mockDropboxClient.filesListFolder.mockImplementation( + () => new Promise(resolve => setTimeout(() => resolve({ + result: { entries: [], cursor: 'cursor', has_more: false }, + }), 100)) + ); + mockDropboxClient.filesListFolderGetLatestCursor.mockResolvedValue({ + result: { cursor: 'test-cursor' }, + }); + + const sync1 = engine.syncAll(); + const sync2 = engine.syncAll(); + + const result2 = await sync2; + expect(result2.success).toBe(false); + expect(result2.errors).toContain('Sync already in progress'); + + await sync1; + }); + + it('should emit syncStarted and syncCompleted events', async () => { + const startHandler = vi.fn(); + const completeHandler = vi.fn(); + engine.on('syncStarted', startHandler); + engine.on('syncCompleted', completeHandler); + + // Mock empty remote state + mockDropboxClient.filesListFolder.mockResolvedValue({ + result: { entries: [], cursor: 'cursor', has_more: false }, + }); + mockDropboxClient.filesListFolderGetLatestCursor.mockResolvedValue({ + result: { cursor: 'cursor' }, + }); + mockFs.readdir.mockResolvedValue([]); + + await engine.syncAll(); + + expect(startHandler).toHaveBeenCalled(); + expect(completeHandler).toHaveBeenCalled(); + }); + + it('should set status to syncing during operation', async () => { + let statusDuringSync: DropboxSyncStatus | null = null; + + mockDropboxClient.filesListFolder.mockImplementation(async () => { + statusDuringSync = engine.getStatus(); + return { result: { entries: [], cursor: 'cursor', has_more: false } }; + }); + mockDropboxClient.filesListFolderGetLatestCursor.mockResolvedValue({ + result: { cursor: 'cursor' }, + }); + mockFs.readdir.mockResolvedValue([]); + + await engine.syncAll(); + + expect(statusDuringSync).toBe('syncing'); + expect(engine.getStatus()).toBe('idle'); + }); + + it('should return sync result with counts', async () => { + mockDropboxClient.filesListFolder.mockResolvedValue({ + result: { entries: [], cursor: 'cursor', has_more: false }, + }); + mockDropboxClient.filesListFolderGetLatestCursor.mockResolvedValue({ + result: { cursor: 'cursor' }, + }); + mockFs.readdir.mockResolvedValue([]); + + const result = await engine.syncAll(); + + expect(result).toEqual( + expect.objectContaining({ + success: true, + uploaded: expect.any(Number), + downloaded: expect.any(Number), + deleted: expect.any(Number), + conflicts: expect.any(Number), + errors: expect.any(Array), + }) + ); + }); + }); + + // ============================================ + // Conflict Detection + // ============================================ + + describe('Conflict Detection', () => { + beforeEach(async () => { + await engine.configure(createMockDropboxConfig({ + localPostsDir: '/mock/userData/projects/default/posts', + localMediaDir: '/mock/userData/projects/default/media', + remoteBasePath: '/bds', + })); + }); + + it('should detect conflict when both local and remote changed', async () => { + const localPath = '/mock/userData/projects/default/posts/2026/01/conflicted.md'; + const remotePath = '/bds/posts/2026/01/conflicted.md'; + + // Local file was modified + mockFs.stat.mockResolvedValue({ + isFile: () => true, + isDirectory: () => false, + size: 200, + mtime: new Date('2026-01-20T15:00:00Z'), + }); + mockFs.readFile.mockResolvedValue(Buffer.from('local content')); + + const conflict = engine.createConflict(localPath, remotePath, { + localModified: new Date('2026-01-20T15:00:00Z'), + remoteModified: new Date('2026-01-20T14:00:00Z'), + localHash: 'localhash', + remoteHash: 'remotehash', + }); + + expect(conflict).toEqual( + expect.objectContaining({ + localPath, + remotePath, + localModified: expect.any(Date), + remoteModified: expect.any(Date), + }) + ); + }); + + it('should resolve conflict with local-wins strategy', async () => { + const conflict: DropboxConflict = { + id: 'conflict-1', + localPath: '/mock/userData/projects/default/posts/2026/01/test.md', + remotePath: '/bds/posts/2026/01/test.md', + localModified: new Date('2026-01-20T15:00:00Z'), + remoteModified: new Date('2026-01-20T14:00:00Z'), + localHash: 'localhash', + remoteHash: 'remotehash', + }; + + mockFs.readFile.mockResolvedValue(Buffer.from('local content')); + mockDropboxClient.filesUpload.mockResolvedValue({ + result: { + name: 'test.md', + path_lower: conflict.remotePath, + content_hash: 'newhash', + server_modified: '2026-01-20T15:30:00Z', + size: 13, + }, + }); + + await engine.resolveConflict(conflict, 'local-wins'); + + // Should upload local version to remote + expect(mockDropboxClient.filesUpload).toHaveBeenCalled(); + }); + + it('should resolve conflict with remote-wins strategy', async () => { + const conflict: DropboxConflict = { + id: 'conflict-1', + localPath: '/mock/userData/projects/default/posts/2026/01/test.md', + remotePath: '/bds/posts/2026/01/test.md', + localModified: new Date('2026-01-20T14:00:00Z'), + remoteModified: new Date('2026-01-20T15:00:00Z'), + localHash: 'localhash', + remoteHash: 'remotehash', + }; + + mockDropboxClient.filesDownload.mockResolvedValue({ + result: { + name: 'test.md', + path_lower: conflict.remotePath, + fileBinary: Buffer.from('remote content'), + content_hash: 'remotehash', + server_modified: '2026-01-20T15:00:00Z', + size: 14, + }, + }); + + await engine.resolveConflict(conflict, 'remote-wins'); + + // Should download remote version to local + expect(mockFs.writeFile).toHaveBeenCalled(); + }); + + it('should emit conflictDetected event', () => { + const handler = vi.fn(); + engine.on('conflictDetected', handler); + + const conflict = engine.createConflict( + '/mock/userData/projects/default/posts/2026/01/test.md', + '/bds/posts/2026/01/test.md', + { + localModified: new Date(), + remoteModified: new Date(), + localHash: 'a', + remoteHash: 'b', + } + ); + + expect(handler).toHaveBeenCalledWith(conflict); + }); + + it('should emit conflictResolved event after resolution', async () => { + const handler = vi.fn(); + engine.on('conflictResolved', handler); + + const conflict: DropboxConflict = { + id: 'conflict-1', + localPath: '/mock/userData/projects/default/posts/2026/01/test.md', + remotePath: '/bds/posts/2026/01/test.md', + localModified: new Date(), + remoteModified: new Date(), + localHash: 'a', + remoteHash: 'b', + }; + + mockFs.readFile.mockResolvedValue(Buffer.from('content')); + mockDropboxClient.filesUpload.mockResolvedValue({ + result: { name: 'test.md', path_lower: '/bds/posts/2026/01/test.md', content_hash: 'new', server_modified: '2026-01-20T15:00:00Z', size: 7 }, + }); + + await engine.resolveConflict(conflict, 'local-wins'); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ id: 'conflict-1' }), + 'local-wins' + ); + }); + }); + + // ============================================ + // Local File Watching + // ============================================ + + describe('Local File Watching', () => { + beforeEach(async () => { + await engine.configure(createMockDropboxConfig({ + localPostsDir: '/mock/userData/projects/default/posts', + localMediaDir: '/mock/userData/projects/default/media', + remoteBasePath: '/bds', + })); + }); + + it('should start watching local directories', () => { + engine.startWatching(); + + expect(mockChokidarWatch).toHaveBeenCalledWith( + expect.arrayContaining([ + '/mock/userData/projects/default/posts', + '/mock/userData/projects/default/media', + ]), + expect.objectContaining({ + ignoreInitial: true, + persistent: true, + }) + ); + }); + + it('should set status to watching when watching starts', () => { + engine.startWatching(); + expect(engine.getStatus()).toBe('watching'); + }); + + it('should stop watching when requested', () => { + engine.startWatching(); + engine.stopWatching(); + + expect(mockWatcher.close).toHaveBeenCalled(); + }); + + it('should set status to idle when watching stops', () => { + engine.startWatching(); + engine.stopWatching(); + expect(engine.getStatus()).toBe('idle'); + }); + + it('should emit watchStarted event', () => { + const handler = vi.fn(); + engine.on('watchStarted', handler); + + engine.startWatching(); + + expect(handler).toHaveBeenCalled(); + }); + + it('should emit watchStopped event', () => { + const handler = vi.fn(); + engine.on('watchStopped', handler); + + engine.startWatching(); + engine.stopWatching(); + + expect(handler).toHaveBeenCalled(); + }); + + it('should register add, change, and unlink handlers', () => { + engine.startWatching(); + + const onCalls = mockWatcher.on.mock.calls.map((call: any[]) => call[0]); + expect(onCalls).toContain('add'); + expect(onCalls).toContain('change'); + expect(onCalls).toContain('unlink'); + }); + }); + + // ============================================ + // Remote Polling + // ============================================ + + describe('Remote Polling', () => { + beforeEach(async () => { + await engine.configure(createMockDropboxConfig({ + localPostsDir: '/mock/userData/projects/default/posts', + localMediaDir: '/mock/userData/projects/default/media', + remoteBasePath: '/bds', + syncInterval: 30, + })); + }); + + it('should start polling for remote changes', () => { + vi.useFakeTimers(); + + engine.startPolling(); + expect(engine.isPolling()).toBe(true); + + vi.useRealTimers(); + }); + + it('should stop polling when requested', () => { + vi.useFakeTimers(); + + engine.startPolling(); + engine.stopPolling(); + expect(engine.isPolling()).toBe(false); + + vi.useRealTimers(); + }); + + it('should emit pollingStarted event', () => { + vi.useFakeTimers(); + const handler = vi.fn(); + engine.on('pollingStarted', handler); + + engine.startPolling(); + expect(handler).toHaveBeenCalled(); + + engine.stopPolling(); + vi.useRealTimers(); + }); + + it('should emit pollingStopped event', () => { + vi.useFakeTimers(); + const handler = vi.fn(); + engine.on('pollingStopped', handler); + + engine.startPolling(); + engine.stopPolling(); + expect(handler).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + }); + + // ============================================ + // Content Hash Comparison + // ============================================ + + describe('Content Hash', () => { + it('should calculate content hash for a buffer', () => { + const content = Buffer.from('Hello, World!'); + const hash = engine.calculateContentHash(content); + + expect(hash).toBeTruthy(); + expect(typeof hash).toBe('string'); + }); + + it('should return same hash for same content', () => { + const content = Buffer.from('Test content'); + const hash1 = engine.calculateContentHash(content); + const hash2 = engine.calculateContentHash(content); + + expect(hash1).toBe(hash2); + }); + + it('should return different hash for different content', () => { + const hash1 = engine.calculateContentHash(Buffer.from('Content A')); + const hash2 = engine.calculateContentHash(Buffer.from('Content B')); + + expect(hash1).not.toBe(hash2); + }); + }); + + // ============================================ + // Error Handling + // ============================================ + + describe('Error Handling', () => { + beforeEach(async () => { + await engine.configure(createMockDropboxConfig({ + localPostsDir: '/mock/userData/projects/default/posts', + localMediaDir: '/mock/userData/projects/default/media', + remoteBasePath: '/bds', + })); + }); + + it('should handle Dropbox API errors gracefully', async () => { + mockDropboxClient.filesUpload.mockRejectedValue( + new Error('Dropbox API error: insufficient_space') + ); + + const localPath = '/mock/userData/projects/default/posts/2026/01/test.md'; + mockFs.readFile.mockResolvedValue(Buffer.from('content')); + + await expect(engine.uploadFile(localPath)).rejects.toThrow('Dropbox API error'); + }); + + it('should emit error event on sync failure', async () => { + const handler = vi.fn(); + engine.on('syncFailed', handler); + + mockDropboxClient.filesListFolder.mockRejectedValue(new Error('Network error')); + mockDropboxClient.filesListFolderGetLatestCursor.mockRejectedValue(new Error('Network error')); + mockFs.readdir.mockResolvedValue([]); + + await engine.syncAll(); + + expect(handler).toHaveBeenCalled(); + }); + + it('should set status to error on repeated failures', async () => { + mockDropboxClient.filesListFolder.mockRejectedValue(new Error('Network error')); + mockDropboxClient.filesListFolderGetLatestCursor.mockRejectedValue(new Error('Network error')); + mockFs.readdir.mockResolvedValue([]); + + await engine.syncAll(); + + expect(engine.getStatus()).toBe('error'); + }); + + it('should handle auth token expiration', async () => { + const handler = vi.fn(); + engine.on('authError', handler); + + const authError = new Error('expired_access_token'); + (authError as any).status = 401; + mockDropboxClient.filesUpload.mockRejectedValue(authError); + + const localPath = '/mock/userData/projects/default/posts/2026/01/test.md'; + mockFs.readFile.mockResolvedValue(Buffer.from('content')); + + await expect(engine.uploadFile(localPath)).rejects.toThrow(); + expect(handler).toHaveBeenCalled(); + }); + }); + + // ============================================ + // Pending Conflicts + // ============================================ + + describe('Pending Conflicts Management', () => { + it('should track pending conflicts', () => { + const conflict: DropboxConflict = { + id: 'conflict-1', + localPath: '/local/test.md', + remotePath: '/remote/test.md', + localModified: new Date(), + remoteModified: new Date(), + localHash: 'a', + remoteHash: 'b', + }; + + engine.addPendingConflict(conflict); + expect(engine.getPendingConflicts()).toHaveLength(1); + expect(engine.getPendingConflicts()[0].id).toBe('conflict-1'); + }); + + it('should remove resolved conflicts', () => { + const conflict: DropboxConflict = { + id: 'conflict-1', + localPath: '/local/test.md', + remotePath: '/remote/test.md', + localModified: new Date(), + remoteModified: new Date(), + localHash: 'a', + remoteHash: 'b', + }; + + engine.addPendingConflict(conflict); + engine.removePendingConflict('conflict-1'); + expect(engine.getPendingConflicts()).toHaveLength(0); + }); + + it('should clear all pending conflicts', () => { + engine.addPendingConflict({ + id: 'c1', localPath: '/a', remotePath: '/b', + localModified: new Date(), remoteModified: new Date(), + localHash: 'a', remoteHash: 'b', + }); + engine.addPendingConflict({ + id: 'c2', localPath: '/c', remotePath: '/d', + localModified: new Date(), remoteModified: new Date(), + localHash: 'c', remoteHash: 'd', + }); + + engine.clearPendingConflicts(); + expect(engine.getPendingConflicts()).toHaveLength(0); + }); + }); + + // ============================================ + // Sync State Persistence + // ============================================ + + describe('Sync State', () => { + it('should store and retrieve the last cursor', () => { + engine.setCursor('my-cursor-123'); + expect(engine.getCursor()).toBe('my-cursor-123'); + }); + + it('should store and retrieve the last sync timestamp', () => { + const now = new Date(); + engine.setLastSyncTime(now); + expect(engine.getLastSyncTime()).toEqual(now); + }); + + it('should return null for cursor when never set', () => { + expect(engine.getCursor()).toBeNull(); + }); + + it('should return null for last sync time when never synced', () => { + expect(engine.getLastSyncTime()).toBeNull(); + }); + }); +}); diff --git a/tests/utils/factories.ts b/tests/utils/factories.ts index f6579aa..4103fbd 100644 --- a/tests/utils/factories.ts +++ b/tests/utils/factories.ts @@ -273,6 +273,95 @@ export function createMockFileSystem() { }; } +// ============================================ +// Dropbox Mock Factory +// ============================================ + +import type { DropboxSyncConfig, DropboxConflict } from '../../src/main/engine/DropboxSyncEngine'; + +let dropboxConflictIdCounter = 1; + +export function createMockDropboxConfig(overrides?: Partial): DropboxSyncConfig { + return { + accessToken: 'mock-dropbox-access-token', + refreshToken: 'mock-dropbox-refresh-token', + appKey: 'mock-app-key', + appSecret: 'mock-app-secret', + syncEnabled: true, + syncInterval: 60, + localPostsDir: '/mock/userData/projects/default/posts', + localMediaDir: '/mock/userData/projects/default/media', + remoteBasePath: '/bds', + ...overrides, + }; +} + +export function createMockDropboxClient() { + return { + filesUpload: vi.fn().mockResolvedValue({ + result: { + name: 'test.md', + path_lower: '/bds/posts/2026/01/test.md', + content_hash: 'mockhash123', + server_modified: '2026-01-15T10:00:00Z', + size: 100, + }, + }), + filesDownload: vi.fn().mockResolvedValue({ + result: { + name: 'test.md', + path_lower: '/bds/posts/2026/01/test.md', + fileBinary: Buffer.from('downloaded content'), + content_hash: 'mockhash123', + server_modified: '2026-01-15T10:00:00Z', + size: 18, + }, + }), + filesDeleteV2: vi.fn().mockResolvedValue({ + result: { metadata: { '.tag': 'file', name: 'test.md' } }, + }), + filesListFolder: vi.fn().mockResolvedValue({ + result: { entries: [], cursor: 'mock-cursor', has_more: false }, + }), + filesListFolderContinue: vi.fn().mockResolvedValue({ + result: { entries: [], cursor: 'mock-cursor-next', has_more: false }, + }), + filesListFolderGetLatestCursor: vi.fn().mockResolvedValue({ + result: { cursor: 'mock-latest-cursor' }, + }), + filesListFolderLongpoll: vi.fn().mockResolvedValue({ + result: { changes: false, backoff: 0 }, + }), + filesGetMetadata: vi.fn().mockResolvedValue({ + result: { + '.tag': 'file', + name: 'test.md', + path_lower: '/bds/posts/2026/01/test.md', + content_hash: 'mockhash123', + server_modified: '2026-01-15T10:00:00Z', + size: 100, + }, + }), + setRefreshToken: vi.fn(), + getRefreshToken: vi.fn().mockReturnValue('mock-refresh-token'), + getAccessToken: vi.fn().mockReturnValue('mock-access-token'), + }; +} + +export function createMockDropboxConflict(overrides?: Partial): DropboxConflict { + const id = `conflict-${dropboxConflictIdCounter++}`; + return { + id, + localPath: '/mock/userData/projects/default/posts/2026/01/test.md', + remotePath: '/bds/posts/2026/01/test.md', + localModified: new Date('2026-01-20T15:00:00Z'), + remoteModified: new Date('2026-01-20T14:00:00Z'), + localHash: 'localhash123', + remoteHash: 'remotehash456', + ...overrides, + }; +} + // ============================================ // Reset Utilities // ============================================ @@ -281,6 +370,7 @@ export function resetMockCounters(): void { postIdCounter = 1; mediaIdCounter = 1; taskIdCounter = 1; + dropboxConflictIdCounter = 1; } // ============================================