Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions src/app/core/factory/sdk/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { Share, Users } from '@internxt/sdk/dist/drive';
import packageJson from '../../../../../package.json';
import { Auth } from '@internxt/sdk/dist/auth';
import { Location } from '@internxt/sdk';
import { HttpClient } from '@internxt/sdk/dist/shared/http/client';
import { USER_NOTIFICATION_MAX_RETRIES } from './retryStrategies';
import notificationsService, { ToastType } from 'app/notifications/services/notifications.service';
import dateService from 'services/date.service';

const MOCKED_NEW_API = 'https://api.internxt.com';
const MOCKED_PAYMENTS = 'https://payments.internxt.com';
Expand All @@ -20,6 +24,18 @@ vi.mock('@internxt/sdk/dist/drive', () => ({
Share: {
client: vi.fn(),
},
Storage: {
client: vi.fn(),
},
}));

vi.mock('app/notifications/services/notifications.service', () => ({
default: {
show: vi.fn(),
},
ToastType: {
Warning: 'WARNING',
},
}));

vi.mock('@internxt/sdk/dist/auth', () => ({
Expand All @@ -34,6 +50,22 @@ vi.mock('@internxt/sdk', () => ({
},
}));

vi.mock('@internxt/sdk/dist/shared/http/client', () => ({
HttpClient: {
enableGlobalRetry: vi.fn(),
},
}));

vi.mock('i18next', () => ({
t: vi.fn((key: string) => key),
}));

vi.mock('services/date.service', () => ({
default: {
hasElapsed: vi.fn().mockReturnValue(true),
},
}));

vi.mock('services/env.service', () => ({
default: {
getVariable: vi.fn((key: string) => {
Expand All @@ -55,6 +87,12 @@ describe('SdkFactory', () => {
let mockDispatch: any;
let mockLocalStorage: LocalStorageService;

const getNotifyCallback = () => {
const callArgs = vi.mocked(HttpClient.enableGlobalRetry).mock.calls[0];
const retryOptions = callArgs[0] as { onRetry: (attempt: number, delay: number) => void };
return retryOptions.onRetry;
};

beforeEach(() => {
vi.clearAllMocks();
mockDispatch = vi.fn();
Expand All @@ -66,6 +104,31 @@ describe('SdkFactory', () => {
SdkFactory.initialize(mockDispatch, mockLocalStorage);
});

describe('initialize', () => {
it('When initialized, then the global retry is enabled with user notification strategy', () => {
expect(HttpClient.enableGlobalRetry).toHaveBeenCalledTimes(1);
expect(HttpClient.enableGlobalRetry).toHaveBeenCalledWith(
expect.objectContaining({ maxRetries: USER_NOTIFICATION_MAX_RETRIES, onRetry: expect.any(Function) }),
);
});

it('When SDK clients are created without calling initialize, then enableGlobalRetry is not called again', () => {
vi.mocked(HttpClient.enableGlobalRetry).mockClear();

vi.spyOn(mockLocalStorage, 'getWorkspace').mockReturnValue(Workspace.Individuals);
vi.spyOn(mockLocalStorage, 'get').mockImplementation((key: string) => {
if (key === 'xNewToken') return 'test-token';
return null;
});

const instance = SdkFactory.getNewApiInstance();
instance.createNewStorageClient();
instance.createShareClient();

expect(HttpClient.enableGlobalRetry).not.toHaveBeenCalled();
});
});

describe('getNewApiSecurity', () => {
it('should return ApiSecurity with token and default unauthorized callback', () => {
const mockToken = 'test-token';
Expand Down Expand Up @@ -365,5 +428,41 @@ describe('SdkFactory', () => {
expect(Location.client).toHaveBeenCalledWith(MOCKED_LOCATION);
});
});

describe('notifyUserWithCooldown', () => {
it('When notifyUserWithCooldown is called for the first time, then it shows a toast notification', () => {
const onRetry = getNotifyCallback();

onRetry(1, 1000);

expect(notificationsService.show).toHaveBeenCalledWith({
text: 'sdk.rateLimitToast',
type: ToastType.Warning,
duration: 60000,
});
});

it('When notifyUserWithCooldown is called again within cooldown, then it does not show a second toast', () => {
const onRetry = getNotifyCallback();

onRetry(1, 1000);
expect(notificationsService.show).toHaveBeenCalledTimes(1);

vi.mocked(dateService.hasElapsed).mockReturnValueOnce(false);
onRetry(2, 2000);
expect(notificationsService.show).toHaveBeenCalledTimes(1);
});

it('When notifyUserWithCooldown is called after cooldown has elapsed, then it shows the toast again', () => {
const onRetry = getNotifyCallback();

onRetry(1, 1000);
expect(notificationsService.show).toHaveBeenCalledTimes(1);

vi.mocked(dateService.hasElapsed).mockReturnValueOnce(true);
onRetry(2, 2000);
expect(notificationsService.show).toHaveBeenCalledTimes(2);
});
});
});
});
30 changes: 30 additions & 0 deletions src/app/core/factory/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ import { Checkout } from '@internxt/sdk/dist/payments';
import envService from 'services/env.service';
import { STORAGE_KEYS } from 'services/storage-keys';
import { Location } from '@internxt/sdk';
import { HttpClient } from '@internxt/sdk/dist/shared/http/client';
import dayjs, { Dayjs } from 'dayjs';
import dateService from 'services/date.service';
import notificationsService, { ToastType } from 'app/notifications/services/notifications.service';
import { t } from 'i18next';
import { retryStrategies, NotifyUserCallback } from './retryStrategies';

const RETRY_TOAST_DURATION_MS = 60000;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should move all constants in src/app/core/constants.ts so that they are all in one place. @CandelR do we have a file with constants specific to sdk or is the one in core the only one?

const RETRY_TOAST_COOLDOWN_MINUTES = 1;
let lastRetryToastShownAt: Dayjs | null = null;

const notifyUserWithCooldown: NotifyUserCallback = () => {
const isToastOnCooldown =
lastRetryToastShownAt && !dateService.hasElapsed(lastRetryToastShownAt, RETRY_TOAST_COOLDOWN_MINUTES, 'minute');
if (!isToastOnCooldown) {
lastRetryToastShownAt = dayjs();
notificationsService.show({
text: t('sdk.rateLimitToast'),
type: ToastType.Warning,
duration: RETRY_TOAST_DURATION_MS,
});
}
};

const SdkClient = {
Storage: 'Storage',
Share: 'Share',
} as const;

export class SdkFactory {
private static sdk: {
Expand All @@ -30,6 +58,8 @@ export class SdkFactory {
localStorage,
newApiInstance: new SdkFactory(envService.getVariable('newApi')),
};

HttpClient.enableGlobalRetry(retryStrategies.withUserNotification(SdkClient.Storage, notifyUserWithCooldown));
}

public static getNewApiInstance(): SdkFactory {
Expand Down
61 changes: 61 additions & 0 deletions src/app/core/factory/sdk/retryStrategies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RetryOptions } from '@internxt/sdk/dist/shared';
import { retryStrategies, SILENT_MAX_RETRIES, USER_NOTIFICATION_MAX_RETRIES } from './retryStrategies';

const getOnRetry = (options: RetryOptions): ((attempt: number, delay: number) => void) => {
if (!options.onRetry) throw new Error('onRetry callback was not provided');
return options.onRetry;
};

describe('retryStrategies', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('silent', () => {
it('When a silent strategy is created, then it retries up to the configured max', () => {
const options = retryStrategies.silent('Test');

expect(options.maxRetries).toBe(SILENT_MAX_RETRIES);
});

it('When a request is retried, then it logs a warning without notifying the user', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const notifyUser = vi.fn();
const options = retryStrategies.silent('Test');

getOnRetry(options)(1, 1000);

expect(warnSpy).toHaveBeenCalledWith('[SDK] Test retry attempt 1, waiting 1000ms');
expect(notifyUser).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});

describe('withUserNotification', () => {
it('When a notification strategy is created, then it retries up to the configured max', () => {
const options = retryStrategies.withUserNotification('Test', vi.fn());

expect(options.maxRetries).toBe(USER_NOTIFICATION_MAX_RETRIES);
});

it('When a request is retried, then it calls the notifyUser callback', () => {
const notifyUser = vi.fn();
const options = retryStrategies.withUserNotification('Test', notifyUser);

getOnRetry(options)(1, 1000);

expect(notifyUser).toHaveBeenCalledOnce();
});

it('When a request is retried, then it logs a warning', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const options = retryStrategies.withUserNotification('Test', vi.fn());

getOnRetry(options)(1, 1000);

expect(warnSpy).toHaveBeenCalledWith('[SDK] Test retry attempt 1, waiting 1000ms');
warnSpy.mockRestore();
});
});
});
23 changes: 23 additions & 0 deletions src/app/core/factory/sdk/retryStrategies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { RetryOptions } from '@internxt/sdk/dist/shared';

export const SILENT_MAX_RETRIES = 2;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same with those constants

export const USER_NOTIFICATION_MAX_RETRIES = 5;

export type NotifyUserCallback = () => void;

export const retryStrategies = {
silent: (label = 'Global'): RetryOptions => ({
maxRetries: SILENT_MAX_RETRIES,
onRetry(attempt, delay) {
console.warn(`[SDK] ${label} retry attempt ${attempt}, waiting ${delay}ms`);
},
}),

withUserNotification: (label: string, notifyUser: NotifyUserCallback): RetryOptions => ({
maxRetries: USER_NOTIFICATION_MAX_RETRIES,
onRetry(attempt, delay) {
console.warn(`[SDK] ${label} retry attempt ${attempt}, waiting ${delay}ms`);
notifyUser();
},
}),
};
3 changes: 3 additions & 0 deletions src/app/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2115,5 +2115,8 @@
"todayAt": "heute um "
},
"sharedWorkspace": "Gemeinsamer Arbeitsbereich"
},
"sdk": {
"rateLimitToast": "Dies dauert länger als erwartet. Bitte warten..."
}
}
3 changes: 3 additions & 0 deletions src/app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2199,5 +2199,8 @@
"todayAt": "today at "
},
"sharedWorkspace": "Shared workspace"
},
"sdk": {
"rateLimitToast": "This is taking longer than expected. Please wait..."
}
}
3 changes: 3 additions & 0 deletions src/app/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -2175,5 +2175,8 @@
"todayAt": "hoy a las "
},
"sharedWorkspace": "Espacio de trabajo compartido"
},
"sdk": {
"rateLimitToast": "Esto está tardando más de lo esperado. Por favor, espere..."
}
}
3 changes: 3 additions & 0 deletions src/app/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -2121,5 +2121,8 @@
"todayAt": "aujourd'hui à "
},
"sharedWorkspace": "Espace de travail partagé"
},
"sdk": {
"rateLimitToast": "Cela prend plus de temps que prévu. Veuillez patienter..."
}
}
3 changes: 3 additions & 0 deletions src/app/i18n/locales/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -2229,5 +2229,8 @@
"todayAt": "oggi alle "
},
"sharedWorkspace": "Spazio di lavoro condiviso"
},
"sdk": {
"rateLimitToast": "Ci sta volendo più del previsto. Attendere prego..."
}
}
3 changes: 3 additions & 0 deletions src/app/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -2136,5 +2136,8 @@
"todayAt": "сегодня в "
},
"sharedWorkspace": "Общее рабочее пространство"
},
"sdk": {
"rateLimitToast": "Это занимает больше времени, чем ожидалось. Пожалуйста, подождите..."
}
}
3 changes: 3 additions & 0 deletions src/app/i18n/locales/tw.json
Original file line number Diff line number Diff line change
Expand Up @@ -2126,5 +2126,8 @@
"todayAt": "今天在 "
},
"sharedWorkspace": "共用工作區"
},
"sdk": {
"rateLimitToast": "這花費的時間比預期的長,請稍候..."
}
}
3 changes: 3 additions & 0 deletions src/app/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -2163,5 +2163,8 @@
"todayAt": "今天在 "
},
"sharedWorkspace": "共享工作区"
},
"sdk": {
"rateLimitToast": "这花费的时间比预期的长,请稍候..."
}
}
4 changes: 4 additions & 0 deletions src/services/date.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ const getHoursUntilExpiration = (expiresAt: Date | string): number => {
return Math.max(0, Math.ceil(dayjs(expiresAt).diff(dayjs(), 'hour', true)));
};

const hasElapsed = (since: Dayjs, amount: number, unit: dayjs.ManipulateType): boolean =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a function for this? It's called in only one place and it's a one-line function

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me its better that way:

  • Readability: hasElapsed(since, 5, 'minute') reads closer to plain English than dayjs().diff(since, 'minute') >= 5, which requires 'knowing' the dayjs API
  • Testability: a named function is easier to unit test in isolation
  • Future reuse: "called in only one place now" doesn't mean it won't be reused later 🤔
  • Abstraction: it hides the dayjs dependency, so if you ever swap date libraries, there's one place to change

wdyt? @TamaraFinogina

dayjs().diff(since, unit) >= amount;

const dateService = {
format,
isDateOneBefore,
Expand All @@ -45,6 +48,7 @@ const dateService = {
getDaysUntilExpiration,
getDaysSince,
getHoursUntilExpiration,
hasElapsed,
};

export default dateService;
3 changes: 3 additions & 0 deletions src/upload.worker.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { uploadFile } from 'app/network/upload';
import { HttpClient } from '@internxt/sdk/dist/shared';

HttpClient.enableGlobalRetry();

self.addEventListener('message', async (event) => {
console.log('[WORKER]: Event received -->', event);
Expand Down
Loading