diff --git a/src/App.tsx b/src/App.tsx index 123b98e904..d0fcaeb908 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -45,8 +45,8 @@ import useBeforeUnload from './hooks/useBeforeUnload'; import useVpnAuth from './hooks/useVpnAuth'; import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?raw'; -import { eventHandler } from 'services/sockets/event-handler.service'; import RealtimeService from 'services/sockets/socket.service'; +import { EventHandler } from 'services/sockets/event-handler.service'; const blob = new Blob([workerUrl], { type: 'application/javascript' }); pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(blob); @@ -90,17 +90,6 @@ const App = (props: AppProps): JSX.Element => { i18next.changeLanguage(); }, []); - useEffect(() => { - try { - const realtimeService = RealtimeService.getInstance(); - const cleanup = realtimeService.onEvent(eventHandler.onPlanUpdated); - - return cleanup; - } catch (err) { - errorService.reportError(err); - } - }, []); - useEffect(() => { if (!isWorkspaceIdParam) { navigationService.resetB2BWorkspaceCredentials(dispatch); @@ -132,8 +121,6 @@ const App = (props: AppProps): JSX.Element => { await domainManager.fetchDomains(); - RealtimeService.getInstance().init(); - dispatch(workspaceThunks.fetchWorkspaces()); navigationService.setWorkspaceFromParams(workspaceThunks, dispatch, false); @@ -142,6 +129,8 @@ const App = (props: AppProps): JSX.Element => { redirectToLogin: !!currentRouteConfig?.auth, }), ); + + RealtimeService.getInstance().init(EventHandler.instance); } catch (err: unknown) { const error = errorService.castError(err); errorService.reportError(error); diff --git a/src/services/sockets/event-handler.service.test.ts b/src/services/sockets/event-handler.service.test.ts index e2692bf2da..200b5ee1d3 100644 --- a/src/services/sockets/event-handler.service.test.ts +++ b/src/services/sockets/event-handler.service.test.ts @@ -5,10 +5,12 @@ import { DriveItemData } from 'app/drive/types'; import { store } from 'app/store'; import { planActions, planThunks } from 'app/store/slices/plan'; import { storageActions } from 'app/store/slices/storage'; +import storageSelectors from 'app/store/slices/storage/storage.selectors'; vi.mock('app/store', () => ({ store: { dispatch: vi.fn(), + getState: vi.fn(), }, })); @@ -18,9 +20,6 @@ vi.mock('app/store/slices/plan', () => ({ }, planThunks: { fetchLimitThunk: vi.fn(() => ({ type: 'plan/fetchLimitThunk' })), - fetchUsageThunk: vi.fn(() => ({ type: 'plan/fetchUsageThunk' })), - fetchSubscriptionThunk: vi.fn(() => ({ type: 'plan/fetchSubscriptionThunk' })), - fetchBusinessLimitUsageThunk: vi.fn(() => ({ type: 'plan/fetchBusinessLimitUsageThunk' })), }, })); @@ -30,6 +29,12 @@ vi.mock('app/store/slices/storage', () => ({ }, })); +vi.mock('app/store/slices/storage/storage.selectors', () => ({ + default: { + currentFolderId: vi.fn(), + }, +})); + describe('Event Handler', () => { let eventHandler: EventHandler; let consoleLogSpy: ReturnType; @@ -52,7 +57,7 @@ describe('Event Handler', () => { }, }; - eventHandler.onPlanUpdated(eventData); + eventHandler.handleEvent(eventData); expect(planActions.updatePlanLimit).toHaveBeenCalledWith(eventData.payload.maxSpaceBytes); expect(store.dispatch).toHaveBeenCalledWith({ @@ -72,7 +77,7 @@ describe('Event Handler', () => { }, }; - eventHandler.onPlanUpdated(eventData); + eventHandler.handleEvent(eventData); expect(store.dispatch).toHaveBeenCalledWith(planThunks.fetchLimitThunk()); }); @@ -110,6 +115,8 @@ describe('Event Handler', () => { }; test('When a file is created, then it should push item to storage', () => { + vi.mocked(storageSelectors.currentFolderId).mockReturnValue('folder-123'); + const eventData: EventData = { event: SOCKET_EVENTS.FILE_CREATED, email: 'test@example.com', @@ -118,7 +125,7 @@ describe('Event Handler', () => { payload: mockFileItem as unknown as DriveItemData, }; - eventHandler.onFileCreated(eventData, 'folder-123'); + eventHandler.handleEvent(eventData); expect(storageActions.pushItems).toHaveBeenCalledWith({ updateRecents: true, @@ -136,6 +143,8 @@ describe('Event Handler', () => { }); test('When a file is created but the folder id does not match, then should not push the item', () => { + vi.mocked(storageSelectors.currentFolderId).mockReturnValue('different-folder-123'); + const eventData: EventData = { event: SOCKET_EVENTS.FILE_CREATED, email: 'test@example.com', @@ -144,7 +153,7 @@ describe('Event Handler', () => { payload: mockFileItem as unknown as DriveItemData, }; - eventHandler.onFileCreated(eventData, 'different-folder-123'); + eventHandler.handleEvent(eventData); expect(consoleLogSpy).toHaveBeenCalledWith('[Event Handler] Handling created file:', { itemFolderId: 'folder-123', diff --git a/src/services/sockets/event-handler.service.ts b/src/services/sockets/event-handler.service.ts index 94a81d1f35..3d9584931f 100644 --- a/src/services/sockets/event-handler.service.ts +++ b/src/services/sockets/event-handler.service.ts @@ -2,9 +2,23 @@ import { planActions, planThunks } from 'app/store/slices/plan'; import { EventData, SOCKET_EVENTS } from './types/socket.types'; import { store } from 'app/store'; import { storageActions } from 'app/store/slices/storage'; +import storageSelectors from 'app/store/slices/storage/storage.selectors'; export class EventHandler { - public onPlanUpdated(data: EventData) { + static readonly instance: EventHandler = new EventHandler(); + + public handleEvent(data: EventData) { + switch (data.event) { + case SOCKET_EVENTS.PLAN_UPDATED: + this.onPlanUpdated(data); + break; + case SOCKET_EVENTS.FILE_CREATED: + this.onFileCreated(data); + break; + } + } + + private onPlanUpdated(data: EventData) { if (data.event !== SOCKET_EVENTS.PLAN_UPDATED) return; const newLimit = data.payload?.maxSpaceBytes; console.log('[Event Handler] Updating plan limit: ', newLimit); @@ -16,9 +30,10 @@ export class EventHandler { } } - public onFileCreated(data: EventData, currentFolderId: string) { + private onFileCreated(data: EventData) { if (data.event !== SOCKET_EVENTS.FILE_CREATED) return; const item = data.payload; + const currentFolderId = storageSelectors.currentFolderId(store.getState()); console.log('[Event Handler] Handling created file:', { itemFolderId: item.folderUuid, @@ -37,5 +52,3 @@ export class EventHandler { ); } } - -export const eventHandler = new EventHandler(); diff --git a/src/services/sockets/socket.service.test.ts b/src/services/sockets/socket.service.test.ts index 4e979eb24f..e4bc960111 100644 --- a/src/services/sockets/socket.service.test.ts +++ b/src/services/sockets/socket.service.test.ts @@ -2,18 +2,19 @@ import { beforeEach, describe, expect, vi, afterEach, test } from 'vitest'; import RealtimeService from './socket.service'; import localStorageService from '../local-storage.service'; import envService from '../env.service'; -import { SocketNotConnectedError } from './errors/socket.errors'; import { SOCKET_EVENTS } from './types/socket.types'; +import { EventHandler } from './event-handler.service'; const { mockSocket, ioMock } = vi.hoisted(() => { const mockSocket = { id: 'mock-socket-id', connected: true, disconnected: false, + active: false, on: vi.fn(), off: vi.fn(), - removeAllListeners: vi.fn(), - close: vi.fn(), + disconnect: vi.fn(), + connect: vi.fn(), }; const ioMock = vi.fn(() => mockSocket); @@ -25,6 +26,16 @@ vi.mock('socket.io-client', () => ({ default: ioMock, })); +vi.mock('./event-handler.service', () => ({ + EventHandler: { + instance: { + handleEvent: vi.fn(), + }, + }, +})); + +const mockEventHandler = EventHandler.instance as unknown as { handleEvent: ReturnType }; + describe('RealtimeService', () => { let service: RealtimeService; let consoleLogSpy: ReturnType; @@ -37,7 +48,6 @@ describe('RealtimeService', () => { consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(envService, 'getVariable').mockImplementation((key: string) => { - if (key === 'nodeEnv') return 'test'; if (key === 'notifications') return 'https://notifications.example.com'; return ''; }); @@ -47,10 +57,11 @@ describe('RealtimeService', () => { mockSocket.id = 'mock-socket-id'; mockSocket.connected = true; mockSocket.disconnected = false; + mockSocket.active = false; mockSocket.on.mockClear(); mockSocket.off.mockClear(); - mockSocket.removeAllListeners.mockClear(); - mockSocket.close.mockClear(); + mockSocket.disconnect.mockClear(); + mockSocket.connect.mockClear(); }); afterEach(() => { @@ -78,14 +89,14 @@ describe('RealtimeService', () => { test.each(['connect', 'event', 'disconnect', 'connect_error'])( 'When init is called, then it monitors connection lifecycle through %s events', (eventName) => { - service.init(); + service.init(mockEventHandler as unknown as EventHandler); expect(mockSocket.on).toHaveBeenCalledWith(eventName, expect.any(Function)); }, ); test('When connection is successfully established, then it notifies the application via callback', () => { const onConnectedCallback = vi.fn(); - service.init(onConnectedCallback); + service.init(mockEventHandler as unknown as EventHandler, onConnectedCallback); const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]; connectHandler?.(); @@ -103,7 +114,7 @@ describe('RealtimeService', () => { (RealtimeService as unknown as { instance: RealtimeService | undefined }).instance = undefined; service = RealtimeService.getInstance(); - service.init(); + service.init(mockEventHandler as unknown as EventHandler); expect(ioMock).toHaveBeenCalledWith('https://notifications.example.com', { auth: { token: 'mock-token-123' }, @@ -127,25 +138,28 @@ describe('RealtimeService', () => { return RealtimeService.getInstance(); }; - test('When not in production, then it logs on connect event', () => { + test('When not in production, then it logs connecting on init', () => { service = resetServiceWithProduction(false); - service.init(); - const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]; - connectHandler?.(); - expect(consoleLogSpy).toHaveBeenCalledWith('[REALTIME]: CONNECTED WITH ID', mockSocket.id); + service.init(mockEventHandler as unknown as EventHandler); + expect(consoleLogSpy).toHaveBeenCalledWith('[REALTIME]: CONNECTING...'); }); - test('When in production, then it does not log on connect event', () => { + test('When in production, then it does not log connecting on init', () => { service = resetServiceWithProduction(true); - service.init(); + service.init(mockEventHandler as unknown as EventHandler); + expect(consoleLogSpy).not.toHaveBeenCalledWith('[REALTIME]: CONNECTING...'); + }); + + test('When connected, then it logs the socket id', () => { + service.init(mockEventHandler as unknown as EventHandler); const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]; connectHandler?.(); - expect(consoleLogSpy).not.toHaveBeenCalledWith('[REALTIME]: CONNECTED WITH ID', mockSocket.id); + expect(consoleLogSpy).toHaveBeenCalledWith('[REALTIME]: CONNECTED WITH ID', mockSocket.id); }); test('When not in production, then it logs on disconnect event', () => { service = resetServiceWithProduction(false); - service.init(); + service.init(mockEventHandler as unknown as EventHandler); const disconnectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'disconnect')?.[1]; disconnectHandler?.('transport close'); expect(consoleLogSpy).toHaveBeenCalledWith('[REALTIME] DISCONNECTED:', 'transport close'); @@ -153,7 +167,7 @@ describe('RealtimeService', () => { test('When in production, then it does not log on disconnect event', () => { service = resetServiceWithProduction(true); - service.init(); + service.init(mockEventHandler as unknown as EventHandler); const disconnectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'disconnect')?.[1]; disconnectHandler?.('transport close'); expect(consoleLogSpy).not.toHaveBeenCalledWith('[REALTIME] DISCONNECTED:', 'transport close'); @@ -161,7 +175,7 @@ describe('RealtimeService', () => { test('When not in production, then it logs errors on connect_error event', () => { service = resetServiceWithProduction(false); - service.init(); + service.init(mockEventHandler as unknown as EventHandler); const connectErrorHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect_error')?.[1]; const error = new Error('connection refused'); connectErrorHandler?.(error); @@ -170,7 +184,7 @@ describe('RealtimeService', () => { test('When in production, then it does not log errors on connect_error event', () => { service = resetServiceWithProduction(true); - service.init(); + service.init(mockEventHandler as unknown as EventHandler); const connectErrorHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect_error')?.[1]; const error = new Error('connection refused'); connectErrorHandler?.(error); @@ -178,125 +192,25 @@ describe('RealtimeService', () => { }); }); - describe('Retrieving connection identifier', () => { - test('When getting the client Id after initialization, then it provides a unique identifier', () => { - service.init(); - - const clientId = service.getClientId(); - - expect(clientId).toBe('mock-socket-id'); - }); - - test('When getting the client id before connecting, then an error indicating so is thrown', () => { - expect(() => service.getClientId()).toThrow(SocketNotConnectedError); - }); - }); - describe('Receiving realtime notifications', () => { - test('When an event is received, then it delivers the notification to subscribed listeners', () => { - service.init(); - const callback = vi.fn(); - const eventData = { event: 'FILE_CREATED', payload: { fileId: '123' } }; - - const cleanup = service.onEvent(callback); - - expect(cleanup).toBeInstanceOf(Function); - - const eventHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'event')?.[1]; - eventHandler?.(eventData); - - expect(callback).toHaveBeenCalledWith(eventData); - }); - - test('When an event is received, then it distributes to all registered handlers', () => { - service.init(); - const callback1 = vi.fn(); - const callback2 = vi.fn(); - const eventData = { event: 'PLAN_UPDATED', payload: { maxSpaceBytes: 1000 } }; - - service.onEvent(callback1); - service.onEvent(callback2); - - const eventHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'event')?.[1]; - eventHandler?.(eventData); - - expect(callback1).toHaveBeenCalledWith(eventData); - expect(callback2).toHaveBeenCalledWith(eventData); - }); - - test('When one handler throws an error, then it does not affect other handlers', () => { - service.init(); - const errorCallback = vi.fn(() => { - throw new Error('Handler error'); - }); - const successCallback = vi.fn(); - const eventData = { event: 'TEST', payload: {} }; - - service.onEvent(errorCallback); - service.onEvent(successCallback); + test('When a PLAN_UPDATED event is received, then it calls handleEvent on the event handler', () => { + service.init(mockEventHandler as unknown as EventHandler); + const eventData = { event: SOCKET_EVENTS.PLAN_UPDATED, payload: { maxSpaceBytes: 2000 } }; const eventHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'event')?.[1]; eventHandler?.(eventData); - expect(errorCallback).toHaveBeenCalledWith(eventData); - expect(successCallback).toHaveBeenCalledWith(eventData); - expect(consoleErrorSpy).toHaveBeenCalledWith('[REALTIME] Error in event handler:', expect.any(Error)); + expect(mockEventHandler.handleEvent).toHaveBeenCalledWith(eventData); }); - test('When a handler is registered before init, then it receives events after initialization', () => { - const callback = vi.fn(); - const eventData = { event: 'TEST', payload: {} }; - - service.onEvent(callback); - - service.init(); + test('When a FILE_CREATED event is received, then it calls handleEvent on the event handler', () => { + service.init(mockEventHandler as unknown as EventHandler); + const eventData = { event: SOCKET_EVENTS.FILE_CREATED, payload: { fileId: '123' } }; const eventHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'event')?.[1]; eventHandler?.(eventData); - expect(callback).toHaveBeenCalledWith(eventData); - }); - }); - - describe('Cleaning up event subscriptions', () => { - test('When the cleanup function is called, then it removes a specific handler', () => { - service.init(); - const callback = vi.fn(); - const eventData = { event: 'TEST', payload: {} }; - - const cleanup = service.onEvent(callback); - - const eventHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'event')?.[1]; - - eventHandler?.(eventData); - expect(callback).toHaveBeenCalledTimes(1); - - cleanup(); - - eventHandler?.(eventData); - expect(callback).toHaveBeenCalledTimes(1); - }); - - test('When removing all listeners function is called, then it clears all active event subscriptions', () => { - service.init(); - const callback1 = vi.fn(); - const callback2 = vi.fn(); - const eventData = { event: 'TEST', payload: {} }; - - service.onEvent(callback1); - service.onEvent(callback2); - - service.removeAllListeners(); - - const eventHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'event')?.[1]; - eventHandler?.(eventData); - - expect(callback1).not.toHaveBeenCalled(); - expect(callback2).not.toHaveBeenCalled(); - }); - - test('When cleanup is called on uninitialized service, then it handles it safely', () => { - expect(() => service.removeAllListeners()).not.toThrow(); + expect(mockEventHandler.handleEvent).toHaveBeenCalledWith(eventData); }); }); @@ -304,41 +218,36 @@ describe('RealtimeService', () => { test.each([ { connected: true, closes: true }, { connected: false, closes: false }, - ])( - 'When the socket is connected, then closes it (connected=$connected, closes=$closes)', - ({ connected, closes }) => { - service.init(); - mockSocket.connected = connected; + ])('When the socket is connected=$connected, then closes=$closes', ({ connected, closes }) => { + service.init(mockEventHandler as unknown as EventHandler); + mockSocket.connected = connected; - service.stop(); + service.stop(); - if (closes) { - expect(mockSocket.close).toHaveBeenCalledTimes(1); - } else { - expect(mockSocket.close).not.toHaveBeenCalled(); - } - }, - ); + if (closes) { + expect(mockSocket.disconnect).toHaveBeenCalledTimes(1); + } else { + expect(mockSocket.disconnect).not.toHaveBeenCalled(); + } + }); }); describe('Complete workflow', () => { test('When the socket is connected, then receives notifications and disconnects successfully', () => { const onConnected = vi.fn(); - const eventCallback = vi.fn(); + service.init(mockEventHandler as unknown as EventHandler, onConnected); - service.init(onConnected); const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]; connectHandler?.(); - service.onEvent(eventCallback); const eventHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'event')?.[1]; - eventHandler?.({ event: 'FILE_CREATED', payload: { fileId: '123' } }); + eventHandler?.({ event: SOCKET_EVENTS.FILE_CREATED, payload: { fileId: '123' } }); service.stop(); expect(onConnected).toHaveBeenCalled(); - expect(eventCallback).toHaveBeenCalled(); - expect(mockSocket.close).toHaveBeenCalled(); + expect(mockEventHandler.handleEvent).toHaveBeenCalled(); + expect(mockSocket.disconnect).toHaveBeenCalled(); }); }); }); diff --git a/src/services/sockets/socket.service.ts b/src/services/sockets/socket.service.ts index 54b7d590dc..7ff9ec7a07 100644 --- a/src/services/sockets/socket.service.ts +++ b/src/services/sockets/socket.service.ts @@ -1,14 +1,13 @@ import io, { Socket } from 'socket.io-client'; import localStorageService from '../local-storage.service'; import envService from '../env.service'; -import { SocketNotConnectedError } from './errors/socket.errors'; -import { EventData } from './types/socket.types'; import { LocalStorageItem } from 'app/core/types'; +import type { EventHandler } from './event-handler.service'; + export default class RealtimeService { private socket?: Socket; private static instance: RealtimeService; - private readonly eventHandlers: Set<(data: EventData) => void> = new Set(); private readonly isProduction = envService.isProduction(); static getInstance(): RealtimeService { @@ -19,12 +18,16 @@ export default class RealtimeService { return this.instance; } - init(onConnected?: () => void): void { + init(eventHandler: EventHandler, onConnected?: () => void): void { + if (this.socket?.connected || this.socket?.active) return; + if (!this.isProduction) { console.log('[REALTIME]: CONNECTING...'); } - this.socket = io(envService.getVariable('notifications'), { + const notificationsUrl = envService.getVariable('notifications'); + + this.socket = io(notificationsUrl, { auth: { token: getToken(), }, @@ -33,22 +36,14 @@ export default class RealtimeService { }); this.socket.on('connect', () => { - if (!this.isProduction) { - console.log('[REALTIME]: CONNECTED WITH ID', this.socket?.id); - } + console.log('[REALTIME]: CONNECTED WITH ID', this.socket?.id); onConnected?.(); }); this.socket.on('event', (data) => { console.log('[REALTIME] EVENT RECEIVED:', JSON.stringify(data, null, 2)); - this.eventHandlers.forEach((handler) => { - try { - handler(data); - } catch (error) { - console.error('[REALTIME] Error in event handler:', error); - } - }); + eventHandler.handleEvent(data); }); this.socket.on('disconnect', (reason) => { @@ -58,45 +53,20 @@ export default class RealtimeService { }); this.socket.on('connect_error', (error) => { + if (error.message.includes('ID unknown')) { + this.socket?.disconnect(); + this.socket?.connect(); + } if (!this.isProduction) console.error('[REALTIME] CONNECTION ERROR:', error); }); } - getClientId(): string | undefined { - if (!this.socket) { - throw new SocketNotConnectedError(); - } - return this.socket.id; - } - - onEvent(cb: (data: any) => void): () => void { - if (!this.isProduction) { - console.log('[REALTIME] Registering event handler. Total handlers:', this.eventHandlers.size + 1); - } - - this.eventHandlers.add(cb); - - return () => { - if (!this.isProduction) { - console.log('[REALTIME] Removing event handler. Remaining handlers:', this.eventHandlers.size - 1); - } - this.eventHandlers.delete(cb); - }; - } - - removeAllListeners() { - if (!this.isProduction) { - console.log('[REALTIME] Clearing all event handlers'); - } - this.eventHandlers.clear(); - } - stop(): void { - console.log('[REALTIME] STOPING...'); + console.log('[REALTIME] STOPPING...'); if (this.socket?.connected) { console.log('[REALTIME] SOCKET CLOSED.'); - this.socket.close(); + this.socket.disconnect(); } } } diff --git a/src/utils/userStoragePolling.utils.test.ts b/src/utils/userStoragePolling.utils.test.ts new file mode 100644 index 0000000000..6d93d536ef --- /dev/null +++ b/src/utils/userStoragePolling.utils.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, vi, beforeEach, afterEach, test } from 'vitest'; + +vi.mock('app/store', () => ({ + store: { + getState: vi.fn(), + dispatch: vi.fn(), + }, +})); + +vi.mock('app/store/slices/plan', () => ({ + planThunks: { + fetchLimitThunk: vi.fn(() => 'fetchLimitThunk-action'), + }, +})); + +import { store } from 'app/store'; +import { userStoragePolling } from './userStoragePolling.utils'; + +const mockStore = store as unknown as { getState: ReturnType; dispatch: ReturnType }; + +describe('User Storage Polling', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockStore.dispatch.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + test('When an interval tick occurs, then the limit is fetched', async () => { + mockStore.getState.mockReturnValue({ plan: { planLimit: 100 } }); + + userStoragePolling(); + + await vi.advanceTimersByTimeAsync(5000); + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5000); + expect(mockStore.dispatch).toHaveBeenCalledTimes(2); + }); + + test('When limit changes, then stop polling', async () => { + mockStore.getState + .mockReturnValueOnce({ plan: { planLimit: 100 } }) + .mockReturnValueOnce({ plan: { planLimit: 100 } }) + .mockReturnValueOnce({ plan: { planLimit: 200 } }); + + userStoragePolling(); + + await vi.advanceTimersByTimeAsync(5000); + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5000); + expect(mockStore.dispatch).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(15000); + expect(mockStore.dispatch).toHaveBeenCalledTimes(2); + }); + + test('When there is no limit updates, then stops polling after 30 seconds', async () => { + mockStore.getState.mockReturnValue({ plan: { planLimit: 100 } }); + + userStoragePolling(); + + await vi.advanceTimersByTimeAsync(30000); + const callsAt30s = mockStore.dispatch.mock.calls.length; + + await vi.advanceTimersByTimeAsync(10000); + expect(mockStore.dispatch).toHaveBeenCalledTimes(callsAt30s); + }); +}); diff --git a/src/utils/userStoragePolling.utils.ts b/src/utils/userStoragePolling.utils.ts new file mode 100644 index 0000000000..b683098dd5 --- /dev/null +++ b/src/utils/userStoragePolling.utils.ts @@ -0,0 +1,31 @@ +import { store } from 'app/store'; +import { planThunks } from 'app/store/slices/plan'; + +const POLLING_INTERVAL_MS = 5 * 1000; +const MAX_POLLING_DURATION_MS = 30 * 1000; + +export const userStoragePolling = () => { + const initialLimit = store.getState().plan.planLimit; + + let interval: ReturnType | null = null; + + const stop = () => { + if (interval !== null) { + clearInterval(interval); + interval = null; + } + clearTimeout(timeout); + }; + + const timeout = setTimeout(stop, MAX_POLLING_DURATION_MS); + + interval = setInterval(async () => { + await store.dispatch(planThunks.fetchLimitThunk()); + const newLimit = store.getState().plan.planLimit; + if (newLimit !== initialLimit) { + stop(); + } + }, POLLING_INTERVAL_MS); + + return stop; +}; diff --git a/src/views/Checkout/views/CheckoutSuccessView.tsx b/src/views/Checkout/views/CheckoutSuccessView.tsx index 7db0d70afd..3fe98c529b 100644 --- a/src/views/Checkout/views/CheckoutSuccessView.tsx +++ b/src/views/Checkout/views/CheckoutSuccessView.tsx @@ -7,6 +7,7 @@ import localStorageService from 'services/local-storage.service'; import { trackPaymentConversion } from 'app/analytics/impact.service'; import gaService from 'app/analytics/ga.service'; import metaService from 'app/analytics/meta.service'; +import { userStoragePolling } from 'utils/userStoragePolling.utils'; export function removePaymentsStorage() { localStorageService.removeItem('subscriptionId'); @@ -42,6 +43,8 @@ const CheckoutSuccessView = (): JSX.Element => { console.error('Analytics error:', err); } + userStoragePolling(); + navigationService.push(AppView.Drive); }, [dispatch]); diff --git a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx index 5354a0654b..4ae58bf584 100644 --- a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx +++ b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx @@ -21,7 +21,6 @@ import { ContextMenu, Empty } from '@internxt/ui'; import { t } from 'i18next'; import BannerWrapper from 'app/banners/BannerWrapper'; import deviceService from 'services/device.service'; -import errorService from 'services/error.service'; import navigationService from 'services/navigation.service'; import { ClearTrashDialog } from 'views/Trash/components'; import { CreateFolderDialog } from 'views/Drive/components'; @@ -60,8 +59,6 @@ import WarningMessageWrapper from 'views/Home/components/WarningMessageWrapper'; import './DriveExplorer.scss'; import { DriveTopBarItems } from './DriveTopBarItems'; import { ShareDialogWrapper } from 'app/drive/components/ShareDialog/ShareDialogWrapper'; -import RealtimeService from 'services/sockets/socket.service'; -import { eventHandler } from 'services/sockets/event-handler.service'; const MenuItemToGetSize = ({ isTrash, @@ -307,24 +304,12 @@ const DriveExplorer = (props: DriveExplorerProps): JSX.Element => { }, ); - const realtimeService = RealtimeService.getInstance(); - useEffect(() => { if (itemToRename) { setEditNameItem(itemToRename); } }, [itemToRename]); - useEffect(() => { - try { - const cleanup = realtimeService.onEvent((data) => eventHandler.onFileCreated(data, currentFolderId)); - - return cleanup; - } catch (err) { - errorService.reportError(err); - } - }, [currentFolderId]); - useEffect(() => { deviceService.redirectForMobile(); }, []);