Skip to content

Commit f0ba7ec

Browse files
authored
Feat/add remaining alerts for drive path selection (#392)
1 parent bed67a2 commit f0ba7ec

12 files changed

Lines changed: 468 additions & 22 deletions

File tree

src/apps/main/interface.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { StoredValues } from './config/service.types';
1616
import { AppStore } from './config';
1717
import { ConfigTheme } from '../shared/types/Theme';
1818
import { UserNotification } from '../../infra/drive-server/out/dto';
19+
import { ChooseSyncRootResult } from './virtual-root-folder/service';
1920

2021
/** This interface and declare global will replace the preload.d.ts.
2122
* The thing is that instead of that, we will gradually will be declaring the interface here as we generate tests
@@ -151,7 +152,7 @@ export interface IElectronAPI {
151152
cancelScan: () => Promise<void>;
152153
};
153154
getVirtualDriveRoot(): Promise<string>;
154-
chooseSyncRootWithDialog(): Promise<string | null>;
155+
chooseSyncRootWithDialog(): Promise<ChooseSyncRootResult>;
155156
getBackupErrorByFolder(folderId: number): Promise<BackupErrorRecord | undefined>;
156157
getLastBackupHadIssues(): Promise<boolean>;
157158
onBackupFatalErrorsChanged(fn: (backupErrors: Array<BackupErrorRecord>) => void): () => void;

src/apps/main/virtual-root-folder/service.test.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { dialog } from 'electron';
22
import configStore, { type AppStore } from '../config';
33
import eventBus from '../event-bus';
4+
import * as validateRootFolderChangeModule from './validate-root-folder-change';
45
import { chooseSyncRootWithDialog, getRootVirtualDrive } from './service';
56

67
vi.mock('electron', () => ({
@@ -36,10 +37,17 @@ vi.mock('../../shared/fs/ensure-folder-exists', () => ({
3637
ensureFolderExists: vi.fn(),
3738
}));
3839

40+
vi.mock('./validate-root-folder-change', () => ({
41+
validateRootFolderChange: vi.fn(),
42+
isPermissionError: vi.fn(),
43+
}));
44+
3945
describe('service', () => {
4046
const configGetMock = vi.mocked(configStore.get);
4147
const configSetMock = vi.mocked(configStore.set);
4248
const eventBusEmitMock = vi.mocked(eventBus.emit);
49+
const validateRootFolderChangeMock = vi.mocked(validateRootFolderChangeModule.validateRootFolderChange);
50+
const isPermissionErrorMock = vi.mocked(validateRootFolderChangeModule.isPermissionError);
4351

4452
beforeEach(() => {
4553
const state = new Map<keyof AppStore, AppStore[keyof AppStore]>([
@@ -51,6 +59,9 @@ describe('service', () => {
5159
configSetMock.mockImplementation((key, value) => {
5260
state.set(key, value);
5361
});
62+
63+
validateRootFolderChangeMock.mockResolvedValue(null);
64+
isPermissionErrorMock.mockReturnValue(false);
5465
});
5566

5667
it('should fallback to default root folder when no saved path exists', () => {
@@ -90,10 +101,91 @@ describe('service', () => {
90101

91102
const selectedPath = await chooseSyncRootWithDialog();
92103

93-
expect(selectedPath).toBe('/new/root/Internxt Drive/');
104+
expect(selectedPath).toStrictEqual({ status: 'success', path: '/new/root/Internxt Drive/' });
94105
expect(eventBusEmitMock).toHaveBeenCalledWith('SYNC_ROOT_CHANGED', {
95106
oldPath: '/old/root/Internxt Drive/',
96107
newPath: '/new/root/Internxt Drive/',
97108
});
98109
});
110+
111+
it('should return cancelled result when user dismisses folder picker', async () => {
112+
vi.mocked(dialog.showOpenDialog).mockResolvedValue({
113+
canceled: true,
114+
filePaths: [],
115+
} as Awaited<ReturnType<typeof dialog.showOpenDialog>>);
116+
117+
const selectedPath = await chooseSyncRootWithDialog();
118+
119+
expect(selectedPath).toStrictEqual({ status: 'cancelled' });
120+
expect(validateRootFolderChangeMock).not.toHaveBeenCalled();
121+
expect(eventBusEmitMock).not.toHaveBeenCalled();
122+
});
123+
124+
it('should return validation error from validateRootFolderChange', async () => {
125+
vi.mocked(dialog.showOpenDialog).mockResolvedValue({
126+
canceled: false,
127+
filePaths: ['/media/user/usb'],
128+
} as Awaited<ReturnType<typeof dialog.showOpenDialog>>);
129+
validateRootFolderChangeMock.mockResolvedValueOnce({
130+
status: 'error',
131+
code: 'REMOVABLE_DEVICE',
132+
});
133+
134+
const selectedPath = await chooseSyncRootWithDialog();
135+
136+
expect(selectedPath).toStrictEqual({ status: 'error', code: 'REMOVABLE_DEVICE' });
137+
expect(eventBusEmitMock).not.toHaveBeenCalled();
138+
});
139+
140+
it('should not emit SYNC_ROOT_CHANGED when resulting mount path does not change', async () => {
141+
const state = new Map<keyof AppStore, AppStore[keyof AppStore]>([
142+
['virtualDriveRoot', '/old/root/'],
143+
['lastSavedListing', ''],
144+
]);
145+
146+
configGetMock.mockImplementation((key) => state.get(key) as AppStore[typeof key]);
147+
configSetMock.mockImplementation((key, value) => {
148+
state.set(key, value);
149+
});
150+
151+
vi.mocked(dialog.showOpenDialog).mockResolvedValue({
152+
canceled: false,
153+
filePaths: ['/old/root'],
154+
} as Awaited<ReturnType<typeof dialog.showOpenDialog>>);
155+
156+
const selectedPath = await chooseSyncRootWithDialog();
157+
158+
expect(selectedPath).toStrictEqual({ status: 'success', path: '/old/root/Internxt Drive/' });
159+
expect(eventBusEmitMock).not.toHaveBeenCalled();
160+
});
161+
162+
it('should return INSUFFICIENT_PERMISSION when an error is caught and identified as permission error', async () => {
163+
vi.mocked(dialog.showOpenDialog).mockResolvedValue({
164+
canceled: false,
165+
filePaths: ['/restricted/path'],
166+
} as Awaited<ReturnType<typeof dialog.showOpenDialog>>);
167+
168+
validateRootFolderChangeMock.mockRejectedValueOnce(new Error('permission denied'));
169+
isPermissionErrorMock.mockReturnValueOnce(true);
170+
171+
const selectedPath = await chooseSyncRootWithDialog();
172+
173+
expect(selectedPath).toStrictEqual({ status: 'error', code: 'INSUFFICIENT_PERMISSION' });
174+
expect(eventBusEmitMock).not.toHaveBeenCalled();
175+
});
176+
177+
it('should return UNKNOWN when an unknown error is caught', async () => {
178+
vi.mocked(dialog.showOpenDialog).mockResolvedValue({
179+
canceled: false,
180+
filePaths: ['/problematic/path'],
181+
} as Awaited<ReturnType<typeof dialog.showOpenDialog>>);
182+
183+
validateRootFolderChangeMock.mockRejectedValueOnce(new Error('unexpected failure'));
184+
isPermissionErrorMock.mockReturnValueOnce(false);
185+
186+
const selectedPath = await chooseSyncRootWithDialog();
187+
188+
expect(selectedPath).toStrictEqual({ status: 'error', code: 'UNKNOWN' });
189+
expect(eventBusEmitMock).not.toHaveBeenCalled();
190+
});
99191
});

src/apps/main/virtual-root-folder/service.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@ import eventBus from '../event-bus';
66
import { exec } from 'node:child_process';
77
import { ensureFolderExists } from '../../shared/fs/ensure-folder-exists';
88
import { PATHS } from '../../../core/electron/paths';
9+
import { ChooseSyncRootFailureCode, isPermissionError, validateRootFolderChange } from './validate-root-folder-change';
910

1011
const VIRTUAL_DRIVE_FOLDER = PATHS.ROOT_DRIVE_FOLDER;
1112
const VIRTUAL_DRIVE_FOLDER_NAME = PATHS.VIRTUAL_DRIVE_FOLDER_NAME;
1213

14+
export type ChooseSyncRootResult =
15+
| { status: 'cancelled' }
16+
| { status: 'success'; path: string }
17+
| { status: 'error'; code: ChooseSyncRootFailureCode };
18+
1319
export async function clearDirectory(pathname: string): Promise<boolean> {
1420
try {
1521
await fs.rm(pathname, { recursive: true });
@@ -57,12 +63,24 @@ export function getRootVirtualDrive(): string {
5763
return normalizePathname(mountPath);
5864
}
5965

60-
export async function chooseSyncRootWithDialog(): Promise<string | null> {
66+
export async function chooseSyncRootWithDialog(): Promise<ChooseSyncRootResult> {
6167
const previousPath = getRootVirtualDrive();
6268
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
6369

64-
if (!result.canceled) {
70+
if (result.canceled) {
71+
return { status: 'cancelled' };
72+
}
73+
74+
try {
6575
const chosenPath = result.filePaths[0];
76+
const validationResult = await validateRootFolderChange({
77+
pathname: chosenPath,
78+
virtualDriveFolderName: VIRTUAL_DRIVE_FOLDER_NAME,
79+
});
80+
81+
if (validationResult) {
82+
return validationResult;
83+
}
6684

6785
setupRootFolder(chosenPath);
6886
const nextPath = getRootVirtualDrive();
@@ -71,10 +89,14 @@ export async function chooseSyncRootWithDialog(): Promise<string | null> {
7189
eventBus.emit('SYNC_ROOT_CHANGED', { oldPath: previousPath, newPath: nextPath });
7290
}
7391

74-
return nextPath;
75-
}
92+
return { status: 'success', path: nextPath };
93+
} catch (error) {
94+
if (isPermissionError(error)) {
95+
return { status: 'error', code: 'INSUFFICIENT_PERMISSION' };
96+
}
7697

77-
return null;
98+
return { status: 'error', code: 'UNKNOWN' };
99+
}
78100
}
79101

80102
export async function openVirtualDriveRootFolder() {
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import fs from 'node:fs/promises';
2+
import {
3+
isPermissionError,
4+
OTHER_CLOUD_PROVIDER_KEYWORDS,
5+
REMOVABLE_PATH_PREFIXES,
6+
validateRootFolderChange,
7+
} from './validate-root-folder-change';
8+
9+
vi.mock('node:fs/promises', () => ({
10+
default: {
11+
access: vi.fn(),
12+
mkdir: vi.fn(),
13+
},
14+
}));
15+
16+
describe('validate-root-folder-change', () => {
17+
const fsAccessMock = vi.mocked(fs.access);
18+
const fsMkdirMock = vi.mocked(fs.mkdir);
19+
20+
beforeEach(() => {
21+
fsAccessMock.mockResolvedValue(undefined);
22+
fsMkdirMock.mockResolvedValue(undefined);
23+
});
24+
25+
it('should expose removable path prefixes', () => {
26+
expect(REMOVABLE_PATH_PREFIXES).toMatchObject(['/media/', '/run/media/', '/mnt/']);
27+
});
28+
29+
it('should expose known cloud provider keywords', () => {
30+
expect(OTHER_CLOUD_PROVIDER_KEYWORDS).toContain('dropbox');
31+
expect(OTHER_CLOUD_PROVIDER_KEYWORDS).toContain('google drive');
32+
expect(OTHER_CLOUD_PROVIDER_KEYWORDS).toContain('onedrive');
33+
});
34+
35+
it.each(['/media/user/usb', '/run/media/user/ssd', '/mnt/external'])(
36+
'should return REMOVABLE_DEVICE for removable path %s',
37+
async (pathname) => {
38+
const result = await validateRootFolderChange({
39+
pathname,
40+
virtualDriveFolderName: 'Internxt Drive',
41+
});
42+
43+
expect(result).toStrictEqual({ status: 'error', code: 'REMOVABLE_DEVICE' });
44+
expect(fsAccessMock).not.toHaveBeenCalled();
45+
expect(fsMkdirMock).not.toHaveBeenCalled();
46+
},
47+
);
48+
49+
it('should return OTHER_CLOUD_PROVIDER when pathname contains cloud provider keyword', async () => {
50+
const result = await validateRootFolderChange({
51+
pathname: '/home/user/Dropbox/Work',
52+
virtualDriveFolderName: 'Internxt Drive',
53+
});
54+
55+
expect(result).toStrictEqual({ status: 'error', code: 'OTHER_CLOUD_PROVIDER' });
56+
expect(fsAccessMock).not.toHaveBeenCalled();
57+
expect(fsMkdirMock).not.toHaveBeenCalled();
58+
});
59+
60+
it('should match cloud provider keyword case-insensitively', async () => {
61+
const result = await validateRootFolderChange({
62+
pathname: '/home/user/Google Drive/My Folder',
63+
virtualDriveFolderName: 'Internxt Drive',
64+
});
65+
66+
expect(result).toStrictEqual({ status: 'error', code: 'OTHER_CLOUD_PROVIDER' });
67+
});
68+
69+
it.each(['EACCES', 'EPERM'])('should return INSUFFICIENT_PERMISSION when access fails with %s', async (code) => {
70+
fsAccessMock.mockRejectedValueOnce(Object.assign(new Error('permission denied'), { code }));
71+
72+
const result = await validateRootFolderChange({
73+
pathname: '/home/user/folder',
74+
virtualDriveFolderName: 'Internxt Drive',
75+
});
76+
77+
expect(result).toStrictEqual({ status: 'error', code: 'INSUFFICIENT_PERMISSION' });
78+
});
79+
80+
it('should return INSUFFICIENT_PERMISSION when mount folder creation fails with permission error', async () => {
81+
fsMkdirMock.mockRejectedValueOnce(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
82+
83+
const result = await validateRootFolderChange({
84+
pathname: '/home/user/folder',
85+
virtualDriveFolderName: 'Internxt Drive',
86+
});
87+
88+
expect(result).toStrictEqual({ status: 'error', code: 'INSUFFICIENT_PERMISSION' });
89+
});
90+
91+
it('should throw when fs fails with unknown error', async () => {
92+
const unknownError = Object.assign(new Error('disk failure'), { code: 'EIO' });
93+
fsAccessMock.mockRejectedValueOnce(unknownError);
94+
95+
await expect(
96+
validateRootFolderChange({
97+
pathname: '/home/user/folder',
98+
virtualDriveFolderName: 'Internxt Drive',
99+
}),
100+
).rejects.toThrow('disk failure');
101+
});
102+
103+
it('should return null when pathname is valid and accessible', async () => {
104+
const result = await validateRootFolderChange({
105+
pathname: '/home/user/folder',
106+
virtualDriveFolderName: 'Internxt Drive',
107+
});
108+
109+
expect(result).toBe(null);
110+
expect(fsAccessMock).toHaveBeenCalledTimes(2);
111+
expect(fsMkdirMock).toHaveBeenCalledWith('/home/user/folder/Internxt Drive', { recursive: true });
112+
});
113+
114+
it.each(['EACCES', 'EPERM'])('isPermissionError should return true for %s', (code) => {
115+
const result = isPermissionError(Object.assign(new Error('permission denied'), { code }));
116+
117+
expect(result).toBe(true);
118+
});
119+
120+
it('isPermissionError should return false for unknown code', () => {
121+
const result = isPermissionError(Object.assign(new Error('other'), { code: 'ENOENT' }));
122+
123+
expect(result).toBe(false);
124+
});
125+
126+
it('isPermissionError should return false for non-objects', () => {
127+
expect(isPermissionError(null)).toBe(false);
128+
expect(isPermissionError(undefined)).toBe(false);
129+
expect(isPermissionError('error')).toBe(false);
130+
});
131+
});

0 commit comments

Comments
 (0)