UNPKG

@rsweeten/dropbox-sync

Version:

Reusable Dropbox synchronization module with framework adapters

509 lines (416 loc) 16.6 kB
import { createSyncMethods } from '../sync' import fs from 'fs' import path from 'path' import { SocketMethods } from '../types' // 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: SocketMethods = { 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: ReturnType<typeof createSyncMethods> const mockGetClient = jest.fn().mockReturnValue(mockDropboxClient) const mockCredentials = { clientId: 'test-client-id', accessToken: 'test-token', } beforeEach(() => { jest.clearAllMocks() // Configure mock fs behavior ;(fs.readdirSync as jest.Mock).mockImplementation((dir) => { if (dir.includes('images/subdir')) return mockSubdirEntries if (dir.includes('images')) return mockImagesEntries return mockFileEntries }) ;(fs.existsSync as jest.Mock).mockReturnValue(true) ;(fs.readFileSync as jest.Mock).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 as any).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 as jest.Mock).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 as jest.Mock).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([]) }) }) })