@rsweeten/dropbox-sync
Version:
Reusable Dropbox synchronization module with framework adapters
376 lines (375 loc) • 15.8 kB
JavaScript
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([]);
});
});
});