diff --git a/src/App.tsx b/src/App.tsx index baea30f474..7cc43e85b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,7 +22,7 @@ import envService from 'services/env.service'; import errorService from 'services/error.service'; import localStorageService from 'services/local-storage.service'; import navigationService from 'services/navigation.service'; -import RealtimeService from 'services/socket.service'; + import { AppViewConfig } from './app/core/types'; import { LRUFilesCacheManager } from './app/database/services/database.service/LRUFilesCacheManager'; import { LRUFilesPreviewCacheManager } from './app/database/services/database.service/LRUFilesPreviewCacheManager'; @@ -45,6 +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'; const blob = new Blob([workerUrl], { type: 'application/javascript' }); pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(blob); @@ -88,6 +90,17 @@ 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); diff --git a/src/app/store/slices/plan/index.ts b/src/app/store/slices/plan/index.ts index a22e5e1890..602492f368 100644 --- a/src/app/store/slices/plan/index.ts +++ b/src/app/store/slices/plan/index.ts @@ -123,7 +123,14 @@ export const fetchBusinessLimitUsageThunk = createAsyncThunk { + if (action.payload) { + state.planLimit = action.payload; + state.isLoadingPlanLimit = false; + } + }, + }, extraReducers: (builder) => { builder .addCase(initializeThunk.pending, (state) => { @@ -217,7 +224,7 @@ export const planSelectors = { isCurrentPlanLifetime: (state: RootState): boolean => { const currentPlan = currentPlanSelector(state); - return currentPlan !== null && currentPlan.isLifetime; + return currentPlan?.isLifetime ?? false; }, planLimitToShow: (state: RootState): number => { const { selectedWorkspace } = state.workspaces; @@ -255,4 +262,6 @@ export const planThunks = { fetchBusinessLimitUsageThunk, }; +export const planActions = planSlice.actions; + export default planSlice.reducer; diff --git a/src/app/store/slices/plan/plan.test.ts b/src/app/store/slices/plan/plan.test.ts index 9bbab6e5eb..59f026fe56 100644 --- a/src/app/store/slices/plan/plan.test.ts +++ b/src/app/store/slices/plan/plan.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { planSelectors, PlanState } from './index'; +import { describe, it, expect, test } from 'vitest'; +import { planSelectors, PlanState, planSlice, planActions } from './index'; import { RootState } from '../..'; import { StoragePlan, RenewalPeriod } from '@internxt/sdk/dist/drive/payments/types/types'; @@ -139,3 +139,54 @@ describe('Plan Selectors', () => { }); }); }); + +describe('Plan Reducers', () => { + describe('Update Limit plan', () => { + test('When providing a max space bytes, then should update the plan limit and stop loading the plan limit', () => { + const initialState: PlanState = { + isLoadingPlanLimit: true, + isLoadingPlanUsage: false, + isLoadingBusinessLimitAndUsage: false, + individualPlan: null, + businessPlan: null, + planLimit: 0, + planUsage: 0, + usageDetails: null, + individualSubscription: null, + businessSubscription: null, + businessPlanLimit: 0, + businessPlanUsage: 0, + businessPlanUsageDetails: null, + }; + + const newLimit = 5000000000; + const result = planSlice.reducer(initialState, planActions.updatePlanLimit(newLimit)); + + expect(result.planLimit).toStrictEqual(newLimit); + expect(result.isLoadingPlanLimit).toStrictEqual(false); + }); + + test('When there is no max space bytes, then should not update the user plan limit', () => { + const initialState: PlanState = { + isLoadingPlanLimit: true, + isLoadingPlanUsage: false, + isLoadingBusinessLimitAndUsage: false, + individualPlan: null, + businessPlan: null, + planLimit: 1000000000, + planUsage: 0, + usageDetails: null, + individualSubscription: null, + businessSubscription: null, + businessPlanLimit: 0, + businessPlanUsage: 0, + businessPlanUsageDetails: null, + }; + + const result = planSlice.reducer(initialState, planActions.updatePlanLimit(null)); + + expect(result.planLimit).toStrictEqual(initialState.planLimit); + expect(result.isLoadingPlanLimit).toStrictEqual(true); + }); + }); +}); diff --git a/src/services/auth.service.test.ts b/src/services/auth.service.test.ts index 26f6e0b0cf..6f7898fea7 100644 --- a/src/services/auth.service.test.ts +++ b/src/services/auth.service.test.ts @@ -86,7 +86,7 @@ beforeAll(() => { workspaceThunks: vi.fn(), })); - vi.mock('services/socket.service', () => ({ + vi.mock('services/sockets/socket.service', () => ({ default: { getInstance: vi.fn().mockReturnValue({ stop: vi.fn(), diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index a5023d097e..ffa887aae7 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -15,7 +15,7 @@ import { trackLead } from 'app/analytics/meta.service'; import { getCookie, setCookie } from 'app/analytics/utils'; import localStorageService from 'services/local-storage.service'; import navigationService from 'services/navigation.service'; -import RealtimeService from 'services/socket.service'; +import RealtimeService from 'services/sockets/socket.service'; import AppError, { AppView } from 'app/core/types'; import { assertPrivateKeyIsValid, diff --git a/src/services/index.ts b/src/services/index.ts index f0479d9e06..0ebf8dedc2 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -20,8 +20,8 @@ export * from './navigation.service'; export { default as navigationService } from './navigation.service'; export * from './operating-system.service'; export { default as operatingSystemService } from './operating-system.service'; -export * from './socket.service'; -export { default as RealtimeService } from './socket.service'; +export * from './sockets/socket.service'; +export { default as RealtimeService } from './sockets/socket.service'; export * from './storage-keys'; export * from './stream.service'; export * from './validation.service'; diff --git a/src/services/socket.service.test.ts b/src/services/socket.service.test.ts deleted file mode 100644 index 43543f030a..0000000000 --- a/src/services/socket.service.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; -import RealtimeService, { SOCKET_EVENTS } from './socket.service'; -import localStorageService from './local-storage.service'; -import envService from './env.service'; - -const { mockSocket, ioMock } = vi.hoisted(() => { - const mockSocket = { - id: 'mock-socket-id', - connected: true, - disconnected: false, - on: vi.fn(), - removeAllListeners: vi.fn(), - close: vi.fn(), - }; - - const ioMock = vi.fn(() => mockSocket); - - return { mockSocket, ioMock }; -}); - -vi.mock('socket.io-client', () => ({ - default: ioMock, -})); - -vi.mock('./local-storage.service', () => ({ - default: { - get: vi.fn(), - }, -})); - -vi.mock('./env.service', () => ({ - default: { - getVariable: vi.fn(), - }, -})); - -describe('RealtimeService', () => { - let service: RealtimeService; - let consoleLogSpy: ReturnType; - let consoleErrorSpy: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - (RealtimeService as unknown as { instance: RealtimeService | undefined }).instance = undefined; - service = RealtimeService.getInstance(); - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - vi.mocked(envService.getVariable).mockImplementation((key: string) => { - if (key === 'nodeEnv') return 'test'; - if (key === 'notifications') return 'https://notifications.example.com'; - return ''; - }); - - vi.mocked(localStorageService.get).mockReturnValue('mock-token-123'); - - mockSocket.id = 'mock-socket-id'; - mockSocket.connected = true; - mockSocket.disconnected = false; - mockSocket.on.mockClear(); - mockSocket.removeAllListeners.mockClear(); - mockSocket.close.mockClear(); - }); - - afterEach(() => { - consoleLogSpy.mockRestore(); - consoleErrorSpy.mockRestore(); - }); - - describe('Service instance management', () => { - it('returns the same service instance across multiple requests', () => { - const instance1 = RealtimeService.getInstance(); - const instance2 = RealtimeService.getInstance(); - - expect(instance1).toBe(instance2); - }); - }); - - describe('Event constants', () => { - it('provides predefined event types for subscription', () => { - expect(SOCKET_EVENTS).toHaveProperty('FILE_CREATED'); - expect(SOCKET_EVENTS.FILE_CREATED).toBe('FILE_CREATED'); - }); - }); - - describe('Establishing realtime connection', () => { - it('establishes a secure connection with authentication when initialized', () => { - service.init(); - - expect(ioMock).toHaveBeenCalledWith('https://notifications.example.com', { - auth: { token: 'mock-token-123' }, - reconnection: false, - withCredentials: true, - }); - }); - - it.each(['connect', 'disconnect', 'connect_error'])( - 'monitors connection lifecycle through %s events', - (eventName) => { - service.init(); - expect(mockSocket.on).toHaveBeenCalledWith(eventName, expect.any(Function)); - }, - ); - - it('notifies the application when connection is successfully established', () => { - const onConnectedCallback = vi.fn(); - service.init(onConnectedCallback); - - const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]; - connectHandler?.(); - - expect(onConnectedCallback).toHaveBeenCalledTimes(1); - }); - - it.each([ - { env: 'production', reconnection: true, logs: false }, - { env: 'development', reconnection: false, logs: true }, - ])( - 'adjusts behavior for $env environment with reconnection=$reconnection and logging=$logs', - ({ env, reconnection, logs }) => { - vi.mocked(envService.getVariable).mockImplementation((key: string) => { - if (key === 'nodeEnv') return env; - if (key === 'notifications') return 'https://notifications.example.com'; - return ''; - }); - - service.init(); - - expect(ioMock).toHaveBeenCalledWith('https://notifications.example.com', { - auth: { token: 'mock-token-123' }, - reconnection, - withCredentials: true, - }); - - if (logs) { - expect(consoleLogSpy).toHaveBeenCalledWith('[REALTIME]: CONNECTING...'); - } else { - expect(consoleLogSpy).not.toHaveBeenCalledWith('[REALTIME]: CONNECTING...'); - } - }, - ); - }); - - describe('Retrieving connection identifier', () => { - it('provides a unique identifier for the connected client', () => { - service.init(); - - const clientId = service.getClientId(); - - expect(clientId).toBe('mock-socket-id'); - }); - - it('reports an error when requesting the client ID before connecting', () => { - expect(() => service.getClientId()).toThrow('Realtime service is not connected'); - }); - }); - - describe('Receiving realtime notifications', () => { - it('delivers realtime notifications to subscribed listeners', () => { - service.init(); - const callback = vi.fn(); - const eventData = { type: 'FILE_CREATED', payload: { fileId: '123' } }; - - const result = service.onEvent(callback); - - expect(result).toBe(true); - expect(mockSocket.on).toHaveBeenCalledWith('event', expect.any(Function)); - - const eventHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'event')?.[1]; - eventHandler?.(eventData); - - expect(callback).toHaveBeenCalledWith(eventData); - }); - - it('prevents event subscriptions when the connection is lost', () => { - service.init(); - mockSocket.disconnected = true; - - const result = service.onEvent(vi.fn()); - - expect(result).toBe(false); - expect(consoleLogSpy).toHaveBeenCalledWith('[REALTIME] SOCKET IS DISCONNECTED'); - }); - }); - - describe('Cleaning up event subscriptions', () => { - it('clears all active event subscriptions when requested', () => { - service.init(); - service.removeAllListeners(); - - expect(mockSocket.removeAllListeners).toHaveBeenCalledTimes(1); - }); - - it('handles cleanup safely even when not initialized', () => { - expect(() => service.removeAllListeners()).not.toThrow(); - }); - }); - - describe('Closing the connection', () => { - it.each([ - { connected: true, closes: true }, - { connected: false, closes: false }, - ])('closes the socket only when connected (connected=$connected, closes=$closes)', ({ connected, closes }) => { - service.init(); - mockSocket.connected = connected; - - service.stop(); - - if (closes) { - expect(mockSocket.close).toHaveBeenCalledTimes(1); - } else { - expect(mockSocket.close).not.toHaveBeenCalled(); - } - }); - }); - - describe('Complete workflow', () => { - it('connects, receives notifications, and disconnects successfully in a complete flow', () => { - const onConnected = vi.fn(); - const eventCallback = vi.fn(); - - 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?.({ type: 'FILE_CREATED' }); - - service.stop(); - - expect(onConnected).toHaveBeenCalled(); - expect(eventCallback).toHaveBeenCalled(); - expect(mockSocket.close).toHaveBeenCalled(); - }); - }); -}); diff --git a/src/services/sockets/errors/socket.errors.ts b/src/services/sockets/errors/socket.errors.ts new file mode 100644 index 0000000000..395f2dbbd0 --- /dev/null +++ b/src/services/sockets/errors/socket.errors.ts @@ -0,0 +1,6 @@ +export class SocketNotConnectedError extends Error { + constructor() { + super('Realtime service is not connected'); + Object.setPrototypeOf(this, SocketNotConnectedError.prototype); + } +} diff --git a/src/services/sockets/event-handler.service.test.ts b/src/services/sockets/event-handler.service.test.ts new file mode 100644 index 0000000000..49bd21d7eb --- /dev/null +++ b/src/services/sockets/event-handler.service.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, vi, test } from 'vitest'; +import { EventHandler } from './event-handler.service'; +import { SOCKET_EVENTS, EventData } from './types/socket.types'; +import { store } from 'app/store'; +import { planActions, planThunks } from 'app/store/slices/plan'; +import { storageActions } from 'app/store/slices/storage'; + +vi.mock('app/store', () => ({ + store: { + dispatch: vi.fn(), + }, +})); + +vi.mock('app/store/slices/plan', () => ({ + planActions: { + updatePlanLimit: vi.fn((limit: number) => ({ type: 'plan/updatePlanLimit', payload: limit })), + }, + 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' })), + }, +})); + +vi.mock('app/store/slices/storage', () => ({ + storageActions: { + pushItems: vi.fn((payload) => ({ type: 'storage/pushItems', payload })), + }, +})); + +describe('Event Handler', () => { + let eventHandler: EventHandler; + let consoleLogSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + eventHandler = new EventHandler(); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + describe('Plan updated', () => { + test('When the event contains the new plan limit, then it should update plan limit', () => { + const eventData: EventData = { + event: SOCKET_EVENTS.PLAN_UPDATED, + email: 'test@example.com', + clientId: 'client-123', + userId: 'user-123', + payload: { + maxSpaceBytes: 1000000, + }, + }; + + eventHandler.onPlanUpdated(eventData); + + expect(planActions.updatePlanLimit).toHaveBeenCalledWith(eventData.payload.maxSpaceBytes); + expect(store.dispatch).toHaveBeenCalledWith({ + type: 'plan/updatePlanLimit', + payload: 1000000, + }); + }); + + test('When there is no new limit, then it should fetch the limit', () => { + const eventData: EventData = { + event: SOCKET_EVENTS.PLAN_UPDATED, + email: 'test@example.com', + clientId: 'client-123', + userId: 'user-123', + payload: { + maxSpaceBytes: 0, + }, + }; + + eventHandler.onPlanUpdated(eventData); + + expect(store.dispatch).toHaveBeenCalledWith(planThunks.fetchLimitThunk()); + }); + }); + + describe('Create File', () => { + const mockFileItem = { + id: 1, + fileId: 'file-123', + type: 'file' as const, + name: 'test.txt', + size: 1024, + folderUuid: 'folder-123', + uuid: 'uuid-123', + bucket: 'bucket-1', + createdAt: '2024-01-01', + created_at: '2024-01-01', + deleted: false, + deletedAt: null, + encryptVersion: '03-aes', + encrypt_version: '03-aes', + folderId: 1, + folder_id: 1, + modificationTime: '2024-01-01', + updatedAt: '2024-01-01', + updated_at: '2024-01-01', + userId: 1, + plainName: 'test.txt', + removed: false, + removed_at: null, + status: 'EXISTS' as const, + }; + + test('When a file is created, then it should push item to storage', () => { + const eventData: EventData = { + event: SOCKET_EVENTS.FILE_CREATED, + email: 'test@example.com', + clientId: 'client-123', + userId: 'user-123', + payload: mockFileItem, + }; + + eventHandler.onFileCreated(eventData, 'folder-123'); + + expect(storageActions.pushItems).toHaveBeenCalledWith({ + updateRecents: true, + folderIds: ['folder-123'], + items: [mockFileItem], + }); + expect(store.dispatch).toHaveBeenCalledWith({ + type: 'storage/pushItems', + payload: { + updateRecents: true, + folderIds: ['folder-123'], + items: [mockFileItem], + }, + }); + }); + + test('When a file is created but the folder id does not match, then should not push the item', () => { + const eventData: EventData = { + event: SOCKET_EVENTS.FILE_CREATED, + email: 'test@example.com', + clientId: 'client-123', + userId: 'user-123', + payload: mockFileItem, + }; + + eventHandler.onFileCreated(eventData, 'different-folder-123'); + + expect(consoleLogSpy).toHaveBeenCalledWith('[Event Handler] Handling created file:', { + itemFolderId: 'folder-123', + currentFolderId: 'different-folder-123', + match: false, + }); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/sockets/event-handler.service.ts b/src/services/sockets/event-handler.service.ts new file mode 100644 index 0000000000..94a81d1f35 --- /dev/null +++ b/src/services/sockets/event-handler.service.ts @@ -0,0 +1,41 @@ +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'; + +export class EventHandler { + public onPlanUpdated(data: EventData) { + if (data.event !== SOCKET_EVENTS.PLAN_UPDATED) return; + const newLimit = data.payload?.maxSpaceBytes; + console.log('[Event Handler] Updating plan limit: ', newLimit); + + if (newLimit) { + store.dispatch(planActions.updatePlanLimit(Number(newLimit))); + } else { + store.dispatch(planThunks.fetchLimitThunk()); + } + } + + public onFileCreated(data: EventData, currentFolderId: string) { + if (data.event !== SOCKET_EVENTS.FILE_CREATED) return; + const item = data.payload; + + console.log('[Event Handler] Handling created file:', { + itemFolderId: item.folderUuid, + currentFolderId, + match: item.folderUuid === currentFolderId, + }); + + if (item.folderUuid !== currentFolderId) return; + + store.dispatch( + storageActions.pushItems({ + updateRecents: true, + folderIds: [item.folderUuid], + items: [item], + }), + ); + } +} + +export const eventHandler = new EventHandler(); diff --git a/src/services/sockets/socket.service.test.ts b/src/services/sockets/socket.service.test.ts new file mode 100644 index 0000000000..d1163c1fd8 --- /dev/null +++ b/src/services/sockets/socket.service.test.ts @@ -0,0 +1,288 @@ +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'; + +const { mockSocket, ioMock } = vi.hoisted(() => { + const mockSocket = { + id: 'mock-socket-id', + connected: true, + disconnected: false, + on: vi.fn(), + off: vi.fn(), + removeAllListeners: vi.fn(), + close: vi.fn(), + }; + + const ioMock = vi.fn(() => mockSocket); + + return { mockSocket, ioMock }; +}); + +vi.mock('socket.io-client', () => ({ + default: ioMock, +})); + +describe('RealtimeService', () => { + let service: RealtimeService; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + (RealtimeService as unknown as { instance: RealtimeService | undefined }).instance = undefined; + service = RealtimeService.getInstance(); + 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 ''; + }); + + vi.spyOn(localStorageService, 'get').mockReturnValue('mock-token-123'); + + mockSocket.id = 'mock-socket-id'; + mockSocket.connected = true; + mockSocket.disconnected = false; + mockSocket.on.mockClear(); + mockSocket.off.mockClear(); + mockSocket.removeAllListeners.mockClear(); + mockSocket.close.mockClear(); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + describe('Service instance management', () => { + test('When getInstance is called multiple times, then it returns the same service instance', () => { + const instance1 = RealtimeService.getInstance(); + const instance2 = RealtimeService.getInstance(); + + expect(instance1).toBe(instance2); + }); + }); + + describe('Event constants', () => { + test('When accessing SOCKET_EVENTS, then it provides predefined event types', () => { + expect(SOCKET_EVENTS.FILE_CREATED).toStrictEqual('FILE_CREATED'); + expect(SOCKET_EVENTS.PLAN_UPDATED).toStrictEqual('PLAN_UPDATED'); + }); + }); + + describe('Establishing realtime connection', () => { + test.each(['connect', 'event', 'disconnect', 'connect_error'])( + 'When init is called, then it monitors connection lifecycle through %s events', + (eventName) => { + service.init(); + 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); + + const connectHandler = mockSocket.on.mock.calls.find((call) => call[0] === 'connect')?.[1]; + connectHandler?.(); + + expect(onConnectedCallback).toHaveBeenCalledTimes(1); + }); + + test.each([ + { env: 'production', reconnection: true, withCredentials: true, logs: false }, + { env: 'development', reconnection: true, withCredentials: false, logs: true }, + ])( + 'When running in $env environment, then it adjusts reconnection=$reconnection, withCredentials=$withCredentials and logging=$logs', + ({ env, reconnection, withCredentials, logs }) => { + vi.spyOn(envService, 'getVariable').mockImplementation((key: string) => { + if (key === 'nodeEnv') return env; + if (key === 'notifications') return 'https://notifications.example.com'; + return ''; + }); + + service.init(); + + expect(ioMock).toHaveBeenCalledWith('https://notifications.example.com', { + auth: { token: 'mock-token-123' }, + reconnection, + withCredentials, + }); + + if (logs) { + expect(consoleLogSpy).toHaveBeenCalledWith('[REALTIME]: CONNECTING...'); + } else { + expect(consoleLogSpy).not.toHaveBeenCalledWith('[REALTIME]: CONNECTING...'); + } + }, + ); + }); + + 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); + + 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)); + }); + + 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(); + + 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(); + }); + }); + + describe('Closing the connection', () => { + 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; + + service.stop(); + + if (closes) { + expect(mockSocket.close).toHaveBeenCalledTimes(1); + } else { + expect(mockSocket.close).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(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' } }); + + service.stop(); + + expect(onConnected).toHaveBeenCalled(); + expect(eventCallback).toHaveBeenCalled(); + expect(mockSocket.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/socket.service.ts b/src/services/sockets/socket.service.ts similarity index 56% rename from src/services/socket.service.ts rename to src/services/sockets/socket.service.ts index 28b5aa1955..20ed76bb83 100644 --- a/src/services/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'; - -export const SOCKET_EVENTS = { - FILE_CREATED: 'FILE_CREATED', -}; +import localStorageService from '../local-storage.service'; +import envService from '../env.service'; +import { SocketNotConnectedError } from './errors/socket.errors'; +import { EventData } from './types/socket.types'; export default class RealtimeService { private socket?: Socket; private static instance: RealtimeService; + private readonly eventHandlers: Set<(data: EventData) => void> = new Set(); static getInstance(): RealtimeService { if (!this.instance) { @@ -27,18 +26,31 @@ export default class RealtimeService { auth: { token: getToken(), }, - reconnection: isProduction(), - withCredentials: true, + reconnection: true, + withCredentials: isProduction(), }); this.socket.on('connect', () => { if (!isProduction()) { console.log('[REALTIME]: CONNECTED WITH ID', this.socket?.id); } - onConnected?.(); }); + this.socket.on('event', (data) => { + if (!isProduction()) { + 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); + } + }); + }); + this.socket.on('disconnect', (reason) => { if (!isProduction()) { console.log('[REALTIME] DISCONNECTED:', reason); @@ -52,37 +64,39 @@ export default class RealtimeService { getClientId(): string | undefined { if (!this.socket) { - throw new Error('Realtime service is not connected'); + throw new SocketNotConnectedError(); } return this.socket.id; } - onEvent(cb: (data: any) => void): boolean { - if (this.socket?.disconnected) { - console.log('[REALTIME] SOCKET IS DISCONNECTED'); - return false; + onEvent(cb: (data: any) => void): () => void { + if (!isProduction()) { + console.log('[REALTIME] Registering event handler. Total handlers:', this.eventHandlers.size + 1); } - this.socket?.on('event', (data) => { + this.eventHandlers.add(cb); + + return () => { if (!isProduction()) { - console.log('[REALTIME] EVENT RECEIVED:', JSON.stringify(data, null, 2)); + console.log('[REALTIME] Removing event handler. Remaining handlers:', this.eventHandlers.size - 1); } - - cb(data); - }); - return true; + this.eventHandlers.delete(cb); + }; } removeAllListeners() { - this.socket?.removeAllListeners(); + if (!isProduction()) { + console.log('[REALTIME] Clearing all event handlers'); + } + this.eventHandlers.clear(); } stop(): void { console.log('[REALTIME] STOPING...'); - if (this.socket && this.socket?.connected) { + if (this.socket?.connected) { console.log('[REALTIME] SOCKET CLOSED.'); - this.socket?.close(); + this.socket.close(); } } } diff --git a/src/services/sockets/types/socket.types.ts b/src/services/sockets/types/socket.types.ts new file mode 100644 index 0000000000..5c3da8b8b3 --- /dev/null +++ b/src/services/sockets/types/socket.types.ts @@ -0,0 +1,27 @@ +import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; +import { DriveItemData } from 'app/drive/types'; + +export const SOCKET_EVENTS = { + FILE_CREATED: 'FILE_CREATED', + PLAN_UPDATED: 'PLAN_UPDATED', +} as const; + +interface BaseEventData { + email: UserSettings['email']; + clientId: string; + userId: UserSettings['userId']; +} + +interface FileCreatedEvent extends BaseEventData { + event: typeof SOCKET_EVENTS.FILE_CREATED; + payload: DriveItemData; +} + +interface PlanUpdatedEvent extends BaseEventData { + event: typeof SOCKET_EVENTS.PLAN_UPDATED; + payload: { + maxSpaceBytes: number; + }; +} + +export type EventData = FileCreatedEvent | PlanUpdatedEvent; diff --git a/src/views/Checkout/views/CheckoutSuccessView.tsx b/src/views/Checkout/views/CheckoutSuccessView.tsx index ef932f9be7..7db0d70afd 100644 --- a/src/views/Checkout/views/CheckoutSuccessView.tsx +++ b/src/views/Checkout/views/CheckoutSuccessView.tsx @@ -2,11 +2,8 @@ import useEffectAsync from 'hooks/useEffectAsync'; import navigationService from 'services/navigation.service'; import { AppView } from 'app/core/types'; import { useAppDispatch } from 'app/store/hooks'; -import { planThunks } from 'app/store/slices/plan'; -import { userThunks } from 'app/store/slices/user'; import { useCallback, useRef } from 'react'; import localStorageService from 'services/local-storage.service'; -import { workspaceThunks } from 'app/store/slices/workspaces/workspacesStore'; import { trackPaymentConversion } from 'app/analytics/impact.service'; import gaService from 'app/analytics/ga.service'; import metaService from 'app/analytics/meta.service'; @@ -35,12 +32,6 @@ const CheckoutSuccessView = (): JSX.Element => { hasTrackedRef.current = true; - setTimeout(async () => { - await dispatch(userThunks.initializeUserThunk()); - await dispatch(planThunks.initializeThunk()); - await dispatch(workspaceThunks.fetchWorkspaces()); - }, 3000); - try { metaService.trackPurchase(); gaService.trackPurchase(); diff --git a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx index 11a33dd929..d93a3c9264 100644 --- a/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx +++ b/src/views/Drive/components/DriveExplorer/DriveExplorer.tsx @@ -24,7 +24,6 @@ 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 RealtimeService, { SOCKET_EVENTS } from 'services/socket.service'; import { ClearTrashDialog } from 'views/Trash/components'; import { CreateFolderDialog } from 'views/Drive/components'; import DeleteItemsDialog from 'views/Trash/components/DeleteItemsDialog'; @@ -62,6 +61,8 @@ 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, @@ -308,20 +309,6 @@ const DriveExplorer = (props: DriveExplorerProps): JSX.Element => { ); const realtimeService = RealtimeService.getInstance(); - const handleFileCreatedEvent = (data) => { - if (data.event === SOCKET_EVENTS.FILE_CREATED) { - const folderId = data.payload.folderId; - if (folderId === currentFolderId) { - dispatch( - storageActions.pushItems({ - updateRecents: true, - folderIds: [folderId], - items: [data.payload as DriveItemData], - }), - ); - } - } - }; useEffect(() => { if (itemToRename) { @@ -331,8 +318,9 @@ const DriveExplorer = (props: DriveExplorerProps): JSX.Element => { useEffect(() => { try { - realtimeService.removeAllListeners(); - realtimeService.onEvent(handleFileCreatedEvent); + const cleanup = realtimeService.onEvent((data) => eventHandler.onFileCreated(data, currentFolderId)); + + return cleanup; } catch (err) { errorService.reportError(err); }