/** * 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 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', async () => { await 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', async () => { await engine.startWatching(); expect(engine.getStatus()).toBe('watching'); }); it('should stop watching when requested', async () => { await engine.startWatching(); engine.stopWatching(); expect(mockWatcher.close).toHaveBeenCalled(); }); it('should set status to idle when watching stops', async () => { await engine.startWatching(); engine.stopWatching(); expect(engine.getStatus()).toBe('idle'); }); it('should emit watchStarted event', async () => { const handler = vi.fn(); engine.on('watchStarted', handler); await engine.startWatching(); expect(handler).toHaveBeenCalled(); }); it('should emit watchStopped event', async () => { const handler = vi.fn(); engine.on('watchStopped', handler); await engine.startWatching(); engine.stopWatching(); expect(handler).toHaveBeenCalled(); }); it('should register add, change, and unlink handlers', async () => { await 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(); }); }); });