From 5cfdbbf86c5d2b8271bd5735b4b1c7349aef2485 Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Wed, 29 Apr 2026 13:43:34 +0200 Subject: [PATCH 1/2] feat: get correct user limit after purchase --- src/App.tsx | 17 +- .../sockets/event-handler.service.test.ts | 19 +- src/services/sockets/event-handler.service.ts | 7 +- src/services/sockets/socket.service.test.ts | 209 +++++------------- src/services/sockets/socket.service.ts | 70 ++---- src/utils/userStoragePolling.utils.test.ts | 73 ++++++ src/utils/userStoragePolling.utils.ts | 31 +++ .../Checkout/views/CheckoutSuccessView.tsx | 3 + .../DriveExplorer/DriveExplorer.tsx | 15 -- 9 files changed, 213 insertions(+), 231 deletions(-) create mode 100644 src/utils/userStoragePolling.utils.test.ts create mode 100644 src/utils/userStoragePolling.utils.ts 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..0e29cc639b 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; @@ -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.onFileCreated(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.onFileCreated(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..5dae714131 100644 --- a/src/services/sockets/event-handler.service.ts +++ b/src/services/sockets/event-handler.service.ts @@ -2,8 +2,10 @@ 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 { + static readonly instance: EventHandler = new EventHandler(); public onPlanUpdated(data: EventData) { if (data.event !== SOCKET_EVENTS.PLAN_UPDATED) return; const newLimit = data.payload?.maxSpaceBytes; @@ -16,9 +18,10 @@ export class EventHandler { } } - public onFileCreated(data: EventData, currentFolderId: string) { + public 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 +40,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..6ce2d03efa 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,20 @@ vi.mock('socket.io-client', () => ({ default: ioMock, })); +vi.mock('./event-handler.service', () => ({ + EventHandler: { + instance: { + onPlanUpdated: vi.fn(), + onFileCreated: vi.fn(), + }, + }, +})); + +const mockEventHandler = EventHandler.instance as { + onPlanUpdated: ReturnType; + onFileCreated: ReturnType; +}; + describe('RealtimeService', () => { let service: RealtimeService; let consoleLogSpy: ReturnType; @@ -37,7 +52,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 +61,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 +93,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); 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, onConnectedCallback); const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]; connectHandler?.(); @@ -103,7 +118,7 @@ describe('RealtimeService', () => { (RealtimeService as unknown as { instance: RealtimeService | undefined }).instance = undefined; service = RealtimeService.getInstance(); - service.init(); + service.init(mockEventHandler); expect(ioMock).toHaveBeenCalledWith('https://notifications.example.com', { auth: { token: 'mock-token-123' }, @@ -127,25 +142,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); + 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); + expect(consoleLogSpy).not.toHaveBeenCalledWith('[REALTIME]: CONNECTING...'); + }); + + test('When connected, then it logs the socket id', () => { + service.init(mockEventHandler); 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); const disconnectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'disconnect')?.[1]; disconnectHandler?.('transport close'); expect(consoleLogSpy).toHaveBeenCalledWith('[REALTIME] DISCONNECTED:', 'transport close'); @@ -153,7 +171,7 @@ describe('RealtimeService', () => { test('When in production, then it does not log on disconnect event', () => { service = resetServiceWithProduction(true); - service.init(); + service.init(mockEventHandler); 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 +179,7 @@ describe('RealtimeService', () => { test('When not in production, then it logs errors on connect_error event', () => { service = resetServiceWithProduction(false); - service.init(); + service.init(mockEventHandler); const connectErrorHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect_error')?.[1]; const error = new Error('connection refused'); connectErrorHandler?.(error); @@ -170,7 +188,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); const connectErrorHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect_error')?.[1]; const error = new Error('connection refused'); connectErrorHandler?.(error); @@ -178,125 +196,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 onPlanUpdated on the event handler', () => { + service.init(mockEventHandler); + 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.onPlanUpdated).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 onFileCreated on the event handler', () => { + service.init(mockEventHandler); + 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.onFileCreated).toHaveBeenCalledWith(eventData); }); }); @@ -304,41 +222,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); + 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, 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.onFileCreated).toHaveBeenCalled(); + expect(mockSocket.disconnect).toHaveBeenCalled(); }); }); }); diff --git a/src/services/sockets/socket.service.ts b/src/services/sockets/socket.service.ts index 54b7d590dc..824f937ba2 100644 --- a/src/services/sockets/socket.service.ts +++ b/src/services/sockets/socket.service.ts @@ -1,14 +1,14 @@ 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 { SOCKET_EVENTS } 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 +19,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 +37,21 @@ 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); - } - }); + switch (data.event) { + case SOCKET_EVENTS.PLAN_UPDATED: + eventHandler.onPlanUpdated(data); + break; + case SOCKET_EVENTS.FILE_CREATED: + eventHandler.onFileCreated(data); + break; + } }); this.socket.on('disconnect', (reason) => { @@ -58,45 +61,20 @@ export default class RealtimeService { }); this.socket.on('connect_error', (error) => { + if (error.message === 'Session 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(); }, []); From 5974f2e323e02dc16f795e1cfb16b47462a501dd Mon Sep 17 00:00:00 2001 From: Xavier Abad Date: Thu, 30 Apr 2026 11:09:51 +0200 Subject: [PATCH 2/2] refactor: handle events on the event handler file --- .../sockets/event-handler.service.test.ts | 8 ++-- src/services/sockets/event-handler.service.ts | 16 ++++++- src/services/sockets/socket.service.test.ts | 46 +++++++++---------- src/services/sockets/socket.service.ts | 12 +---- 4 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/services/sockets/event-handler.service.test.ts b/src/services/sockets/event-handler.service.test.ts index 0e29cc639b..200b5ee1d3 100644 --- a/src/services/sockets/event-handler.service.test.ts +++ b/src/services/sockets/event-handler.service.test.ts @@ -57,7 +57,7 @@ describe('Event Handler', () => { }, }; - eventHandler.onPlanUpdated(eventData); + eventHandler.handleEvent(eventData); expect(planActions.updatePlanLimit).toHaveBeenCalledWith(eventData.payload.maxSpaceBytes); expect(store.dispatch).toHaveBeenCalledWith({ @@ -77,7 +77,7 @@ describe('Event Handler', () => { }, }; - eventHandler.onPlanUpdated(eventData); + eventHandler.handleEvent(eventData); expect(store.dispatch).toHaveBeenCalledWith(planThunks.fetchLimitThunk()); }); @@ -125,7 +125,7 @@ describe('Event Handler', () => { payload: mockFileItem as unknown as DriveItemData, }; - eventHandler.onFileCreated(eventData); + eventHandler.handleEvent(eventData); expect(storageActions.pushItems).toHaveBeenCalledWith({ updateRecents: true, @@ -153,7 +153,7 @@ describe('Event Handler', () => { payload: mockFileItem as unknown as DriveItemData, }; - eventHandler.onFileCreated(eventData); + 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 5dae714131..3d9584931f 100644 --- a/src/services/sockets/event-handler.service.ts +++ b/src/services/sockets/event-handler.service.ts @@ -6,7 +6,19 @@ import storageSelectors from 'app/store/slices/storage/storage.selectors'; export class EventHandler { static readonly instance: EventHandler = new EventHandler(); - public onPlanUpdated(data: EventData) { + + 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); @@ -18,7 +30,7 @@ export class EventHandler { } } - public onFileCreated(data: EventData) { + private onFileCreated(data: EventData) { if (data.event !== SOCKET_EVENTS.FILE_CREATED) return; const item = data.payload; const currentFolderId = storageSelectors.currentFolderId(store.getState()); diff --git a/src/services/sockets/socket.service.test.ts b/src/services/sockets/socket.service.test.ts index 6ce2d03efa..e4bc960111 100644 --- a/src/services/sockets/socket.service.test.ts +++ b/src/services/sockets/socket.service.test.ts @@ -29,16 +29,12 @@ vi.mock('socket.io-client', () => ({ vi.mock('./event-handler.service', () => ({ EventHandler: { instance: { - onPlanUpdated: vi.fn(), - onFileCreated: vi.fn(), + handleEvent: vi.fn(), }, }, })); -const mockEventHandler = EventHandler.instance as { - onPlanUpdated: ReturnType; - onFileCreated: ReturnType; -}; +const mockEventHandler = EventHandler.instance as unknown as { handleEvent: ReturnType }; describe('RealtimeService', () => { let service: RealtimeService; @@ -93,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(mockEventHandler); + 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(mockEventHandler, onConnectedCallback); + service.init(mockEventHandler as unknown as EventHandler, onConnectedCallback); const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]; connectHandler?.(); @@ -118,7 +114,7 @@ describe('RealtimeService', () => { (RealtimeService as unknown as { instance: RealtimeService | undefined }).instance = undefined; service = RealtimeService.getInstance(); - service.init(mockEventHandler); + service.init(mockEventHandler as unknown as EventHandler); expect(ioMock).toHaveBeenCalledWith('https://notifications.example.com', { auth: { token: 'mock-token-123' }, @@ -144,18 +140,18 @@ describe('RealtimeService', () => { test('When not in production, then it logs connecting on init', () => { service = resetServiceWithProduction(false); - service.init(mockEventHandler); + service.init(mockEventHandler as unknown as EventHandler); expect(consoleLogSpy).toHaveBeenCalledWith('[REALTIME]: CONNECTING...'); }); test('When in production, then it does not log connecting on init', () => { service = resetServiceWithProduction(true); - service.init(mockEventHandler); + 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); + service.init(mockEventHandler as unknown as EventHandler); const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]; connectHandler?.(); expect(consoleLogSpy).toHaveBeenCalledWith('[REALTIME]: CONNECTED WITH ID', mockSocket.id); @@ -163,7 +159,7 @@ describe('RealtimeService', () => { test('When not in production, then it logs on disconnect event', () => { service = resetServiceWithProduction(false); - service.init(mockEventHandler); + 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'); @@ -171,7 +167,7 @@ describe('RealtimeService', () => { test('When in production, then it does not log on disconnect event', () => { service = resetServiceWithProduction(true); - service.init(mockEventHandler); + 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'); @@ -179,7 +175,7 @@ describe('RealtimeService', () => { test('When not in production, then it logs errors on connect_error event', () => { service = resetServiceWithProduction(false); - service.init(mockEventHandler); + 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); @@ -188,7 +184,7 @@ describe('RealtimeService', () => { test('When in production, then it does not log errors on connect_error event', () => { service = resetServiceWithProduction(true); - service.init(mockEventHandler); + 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); @@ -197,24 +193,24 @@ describe('RealtimeService', () => { }); describe('Receiving realtime notifications', () => { - test('When a PLAN_UPDATED event is received, then it calls onPlanUpdated on the event handler', () => { - service.init(mockEventHandler); + 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(mockEventHandler.onPlanUpdated).toHaveBeenCalledWith(eventData); + expect(mockEventHandler.handleEvent).toHaveBeenCalledWith(eventData); }); - test('When a FILE_CREATED event is received, then it calls onFileCreated on the event handler', () => { - service.init(mockEventHandler); + 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(mockEventHandler.onFileCreated).toHaveBeenCalledWith(eventData); + expect(mockEventHandler.handleEvent).toHaveBeenCalledWith(eventData); }); }); @@ -223,7 +219,7 @@ describe('RealtimeService', () => { { connected: true, closes: true }, { connected: false, closes: false }, ])('When the socket is connected=$connected, then closes=$closes', ({ connected, closes }) => { - service.init(mockEventHandler); + service.init(mockEventHandler as unknown as EventHandler); mockSocket.connected = connected; service.stop(); @@ -239,7 +235,7 @@ describe('RealtimeService', () => { describe('Complete workflow', () => { test('When the socket is connected, then receives notifications and disconnects successfully', () => { const onConnected = vi.fn(); - service.init(mockEventHandler, onConnected); + service.init(mockEventHandler as unknown as EventHandler, onConnected); const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]; connectHandler?.(); @@ -250,7 +246,7 @@ describe('RealtimeService', () => { service.stop(); expect(onConnected).toHaveBeenCalled(); - expect(mockEventHandler.onFileCreated).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 824f937ba2..7ff9ec7a07 100644 --- a/src/services/sockets/socket.service.ts +++ b/src/services/sockets/socket.service.ts @@ -1,7 +1,6 @@ import io, { Socket } from 'socket.io-client'; import localStorageService from '../local-storage.service'; import envService from '../env.service'; -import { SOCKET_EVENTS } from './types/socket.types'; import { LocalStorageItem } from 'app/core/types'; import type { EventHandler } from './event-handler.service'; @@ -44,14 +43,7 @@ export default class RealtimeService { this.socket.on('event', (data) => { console.log('[REALTIME] EVENT RECEIVED:', JSON.stringify(data, null, 2)); - switch (data.event) { - case SOCKET_EVENTS.PLAN_UPDATED: - eventHandler.onPlanUpdated(data); - break; - case SOCKET_EVENTS.FILE_CREATED: - eventHandler.onFileCreated(data); - break; - } + eventHandler.handleEvent(data); }); this.socket.on('disconnect', (reason) => { @@ -61,7 +53,7 @@ export default class RealtimeService { }); this.socket.on('connect_error', (error) => { - if (error.message === 'Session ID unknown') { + if (error.message.includes('ID unknown')) { this.socket?.disconnect(); this.socket?.connect(); }