UNPKG

@rsweeten/dropbox-sync

Version:

Reusable Dropbox synchronization module with framework adapters

376 lines (375 loc) 15.8 kB
import { createSyncMethods } from '../sync'; import fs from 'fs'; // Mock fs module jest.mock('fs', () => ({ readdirSync: jest.fn(), readFileSync: jest.fn(), writeFileSync: jest.fn(), mkdirSync: jest.fn(), existsSync: jest.fn(), })); // Mock path module jest.mock('path', () => ({ join: jest.fn((...args) => args.join('/')), dirname: jest.fn((path) => path.split('/').slice(0, -1).join('/') || '/'), })); // Mock Dropbox client responses const mockDropboxListFolderResponse = { result: { entries: [ { '.tag': 'file', path_display: '/test.jpg' }, { '.tag': 'file', path_display: '/images/photo.png' }, { '.tag': 'folder', path_display: '/images' }, { '.tag': 'file', path_display: '/doc.txt' }, // Not an image, should be filtered out ], has_more: false, cursor: 'mock-cursor', }, }; const mockDropboxListFolderContinueResponse = { result: { entries: [], has_more: false, cursor: 'mock-cursor-2', }, }; const mockDropboxDownloadResponse = { result: { fileBinary: 'mock-file-content', }, }; // Create mock client const mockDropboxClient = { filesListFolder: jest.fn().mockResolvedValue(mockDropboxListFolderResponse), filesListFolderContinue: jest .fn() .mockResolvedValue(mockDropboxListFolderContinueResponse), filesDownload: jest.fn().mockResolvedValue(mockDropboxDownloadResponse), filesUpload: jest.fn().mockResolvedValue({ result: {} }), }; // Mock Dropbox constructor jest.mock('dropbox', () => ({ Dropbox: jest.fn().mockImplementation(() => mockDropboxClient), })); // Create mock socket methods const mockSocketMethods = { connect: jest.fn(), disconnect: jest.fn(), on: jest.fn(), off: jest.fn(), emit: jest.fn(), }; // Create mock file system entries const mockFileEntries = [ { name: 'test.jpg', isDirectory: () => false, }, { name: 'images', isDirectory: () => true, }, { name: 'document.txt', isDirectory: () => false, }, ]; const mockImagesEntries = [ { name: 'photo.png', isDirectory: () => false, }, { name: 'subdir', isDirectory: () => true, }, ]; const mockSubdirEntries = [ { name: 'deep.gif', isDirectory: () => false, }, ]; describe('Sync Methods', () => { let syncMethods; const mockGetClient = jest.fn().mockReturnValue(mockDropboxClient); const mockCredentials = { clientId: 'test-client-id', accessToken: 'test-token', }; beforeEach(() => { jest.clearAllMocks(); fs.readdirSync.mockImplementation((dir) => { if (dir.includes('images/subdir')) return mockSubdirEntries; if (dir.includes('images')) return mockImagesEntries; return mockFileEntries; }); fs.existsSync.mockReturnValue(true); fs.readFileSync.mockReturnValue(Buffer.from('test-content')); syncMethods = createSyncMethods(mockGetClient, mockCredentials, mockSocketMethods); }); describe('scanLocalFiles', () => { it('should scan local directory and return image files', async () => { const files = await syncMethods.scanLocalFiles('/test-dir'); expect(fs.readdirSync).toHaveBeenCalledWith('/test-dir', { withFileTypes: true, }); // Updated assertion to match actual path format expect(files).toEqual([ '//test.jpg', '//images/photo.png', '//images/subdir/deep.gif', ]); }); it('should throw error if directory not provided in Node environment', async () => { // Save original window property const originalWindow = global.window; // Delete window to simulate Node environment delete global.window; await expect(syncMethods.scanLocalFiles()).rejects.toThrow('Local directory must be provided in Node.js environment'); // Restore window property global.window = originalWindow; }); it('should handle errors when scanning directories', async () => { ; fs.readdirSync.mockImplementationOnce(() => { throw new Error('Permission denied'); }); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); const files = await syncMethods.scanLocalFiles('/test-dir'); expect(consoleSpy).toHaveBeenCalled(); expect(files).toEqual([]); consoleSpy.mockRestore(); }); }); describe('scanDropboxFiles', () => { it('should scan Dropbox folder and return image files', async () => { const files = await syncMethods.scanDropboxFiles('/test-dir'); expect(mockDropboxClient.filesListFolder).toHaveBeenCalledWith({ path: '/test-dir', recursive: true, }); // Updated assertion to match actual response structure expect(files).toEqual(['/test.jpg', '/images/photo.png']); }); it('should handle errors when scanning Dropbox', async () => { mockDropboxClient.filesListFolder.mockRejectedValueOnce(new Error('API error')); await expect(syncMethods.scanDropboxFiles()).rejects.toThrow('API error'); }); it('should continue listing with cursor when has_more is true', async () => { // Setup a response with has_more=true and a continuation response with additional files mockDropboxClient.filesListFolder.mockResolvedValueOnce({ result: { entries: [{ '.tag': 'file', path_display: '/first.jpg' }], has_more: true, cursor: 'test-cursor', }, }); mockDropboxClient.filesListFolderContinue.mockResolvedValueOnce({ result: { entries: [ { '.tag': 'file', path_display: '/more/second.jpg' }, ], has_more: false, cursor: 'test-cursor-2', }, }); const files = await syncMethods.scanDropboxFiles(); expect(mockDropboxClient.filesListFolderContinue).toHaveBeenCalledWith({ cursor: 'test-cursor', }); expect(files).toEqual(['/first.jpg', '/more/second.jpg']); }); }); describe('createSyncQueue', () => { it('should create upload and download queues', async () => { // Mock scan results jest.spyOn(syncMethods, 'scanLocalFiles').mockResolvedValue([ '/local1.jpg', '/local2.png', ]); jest.spyOn(syncMethods, 'scanDropboxFiles').mockResolvedValue([ '/dropbox1.jpg', '/dropbox2.png', ]); const { uploadQueue, downloadQueue } = await syncMethods.createSyncQueue({ localDir: '/local-dir', dropboxDir: '/dropbox-dir', }); // Files in local but not in Dropbox expect(uploadQueue).toEqual(['/local1.jpg', '/local2.png']); // Files in Dropbox but not in local expect(downloadQueue).toEqual(['/dropbox1.jpg', '/dropbox2.png']); }); it('should use default directories when none provided', async () => { // Clear previous mocks to start fresh jest.clearAllMocks(); // Mock both scan methods to return empty arrays to simplify the test const scanLocalSpy = jest .spyOn(syncMethods, 'scanLocalFiles') .mockResolvedValue([]); const scanDropboxSpy = jest .spyOn(syncMethods, 'scanDropboxFiles') .mockResolvedValue([]); await syncMethods.createSyncQueue(); // Verify both scan methods were called expect(scanLocalSpy).toHaveBeenCalled(); expect(scanDropboxSpy).toHaveBeenCalled(); }); it('should normalize paths for comparison', async () => { // Clear previous mocks jest.clearAllMocks(); // Different case and leading slashes but same file jest.spyOn(syncMethods, 'scanLocalFiles').mockResolvedValue([ '/FILE.jpg', ]); jest.spyOn(syncMethods, 'scanDropboxFiles').mockResolvedValue([ '///file.jpg', ]); const { uploadQueue, downloadQueue } = await syncMethods.createSyncQueue(); // Should not include files with normalized paths that match expect(uploadQueue).toEqual([]); expect(downloadQueue).toEqual([]); }); }); describe('syncFiles', () => { beforeEach(() => { // Mock createSyncQueue for sync tests jest.spyOn(syncMethods, 'createSyncQueue').mockResolvedValue({ uploadQueue: ['/upload1.jpg', '/upload2.png'], downloadQueue: ['/download1.jpg', '/download2.png'], }); }); it('should upload and download files', async () => { const result = await syncMethods.syncFiles({ localDir: '/local-dir', dropboxDir: '/dropbox-dir', }); // Check if progress was reported expect(mockSocketMethods.emit).toHaveBeenCalledWith('sync:progress', expect.anything()); // Check if files were uploaded expect(mockDropboxClient.filesUpload).toHaveBeenCalledTimes(2); expect(fs.readFileSync).toHaveBeenCalledTimes(2); // Check if files were downloaded expect(mockDropboxClient.filesDownload).toHaveBeenCalledTimes(2); expect(fs.writeFileSync).toHaveBeenCalledTimes(2); // Check if directories were created for downloaded files expect(fs.existsSync).toHaveBeenCalledTimes(2); expect(fs.mkdirSync).toHaveBeenCalledTimes(0); // Should be 0 since we mocked existsSync to return true // Check the result expect(result.uploaded).toEqual(['/upload1.jpg', '/upload2.png']); expect(result.downloaded).toEqual([ '/download1.jpg', '/download2.png', ]); expect(result.errors).toEqual([]); // Check that completion was emitted expect(mockSocketMethods.emit).toHaveBeenCalledWith('sync:complete', expect.anything()); }); it('should handle upload errors', async () => { // Mock one upload to fail mockDropboxClient.filesUpload.mockRejectedValueOnce(new Error('Upload failed')); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); const result = await syncMethods.syncFiles(); expect(result.errors.length).toBe(1); expect(result.errors[0].file).toBe('/upload1.jpg'); expect(result.uploaded).toEqual(['/upload2.png']); expect(result.downloaded).toEqual([ '/download1.jpg', '/download2.png', ]); consoleSpy.mockRestore(); }); it('should handle download errors', async () => { // Mock one download to fail mockDropboxClient.filesDownload.mockRejectedValueOnce(new Error('Download failed')); const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); const result = await syncMethods.syncFiles(); expect(result.errors.length).toBe(1); expect(result.errors[0].file).toBe('/download1.jpg'); expect(result.uploaded).toEqual(['/upload1.jpg', '/upload2.png']); expect(result.downloaded).toEqual(['/download2.png']); // Check that error was emitted expect(mockSocketMethods.emit).toHaveBeenCalledWith('sync:error', expect.objectContaining({ message: expect.stringContaining('Download failed'), })); consoleSpy.mockRestore(); }); it("should create directory if it doesn't exist", async () => { // Mock directory not existing ; fs.existsSync.mockReturnValue(false); await syncMethods.syncFiles(); expect(fs.mkdirSync).toHaveBeenCalledTimes(2); expect(fs.mkdirSync).toHaveBeenCalledWith(expect.anything(), { recursive: true, }); }); it('should report when no files need syncing', async () => { jest.spyOn(syncMethods, 'createSyncQueue').mockResolvedValue({ uploadQueue: [], downloadQueue: [], }); const result = await syncMethods.syncFiles(); expect(mockSocketMethods.emit).toHaveBeenCalledWith('sync:complete', { message: 'All files are already in sync', }); expect(result.uploaded).toEqual([]); expect(result.downloaded).toEqual([]); }); it('should handle main sync error', async () => { jest.spyOn(syncMethods, 'createSyncQueue').mockRejectedValue(new Error('Sync failed')); await expect(syncMethods.syncFiles()).rejects.toThrow('Sync failed'); expect(mockSocketMethods.emit).toHaveBeenCalledWith('sync:error', { message: 'Sync failed', }); }); it('should respect progress callback', async () => { const mockProgressCallback = jest.fn(); await syncMethods.syncFiles({ progressCallback: mockProgressCallback, }); expect(mockProgressCallback).toHaveBeenCalled(); expect(mockProgressCallback).toHaveBeenCalledWith(expect.objectContaining({ stage: 'init', progress: 0, })); }); it('should handle file blob conversion', async () => { // Clear previous mocks jest.clearAllMocks(); // Mock the download response to return a Blob const mockBlob = { arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)), }; // Create a simplified version of our test that focuses only on blob handling jest.spyOn(syncMethods, 'createSyncQueue').mockResolvedValue({ uploadQueue: [], downloadQueue: ['/download-blob.jpg'], }); // Setup a response with a fileBlob property mockDropboxClient.filesDownload.mockResolvedValueOnce({ result: { fileBlob: mockBlob }, }); await syncMethods.syncFiles(); expect(mockBlob.arrayBuffer).toHaveBeenCalled(); expect(fs.writeFileSync).toHaveBeenCalledWith(expect.any(String), expect.any(Buffer)); }); }); describe('cancelSync', () => { it('should cancel ongoing sync', async () => { // Start a long sync operation but don't await it const syncPromise = syncMethods.syncFiles(); // Cancel the sync syncMethods.cancelSync(); // Complete the sync and check result const result = await syncPromise; expect(mockSocketMethods.emit).toHaveBeenCalledWith('sync:cancel', {}); // Result should be empty since we cancelled before uploading/downloading expect(result.uploaded).toEqual([]); expect(result.downloaded).toEqual([]); }); }); });