diff --git a/README.md b/README.md index dbd64f87cd..028f18fcab 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ const App = () => { | `profiles` | [`ProfilesTypes`](./typings/connectProps.d.ts) | The connect widget uses the profiles to set the initial state of the widget. [More details](./docs/PROFILES.md) | See more details | | `userFeatures` | [`UserFeaturesType`](./typings/connectProps.d.ts) | The connect widget uses user features to determine the behavior of the widget. [More details](./docs/USER_FEATURES.md) | See more details | | `showTooSmallDialog` | `boolean` | The connect widget can show a warning when the widget size is below the supported 320px. | `true` | +| `webSocketConnection` | `object` | An object containing `isConnected()` function and `webSocketMessages$` observable for real-time updates. | `null` | +| `experimentalFeatures` | `object` | An object to enable or disable experimental features like `useWebSockets: true`. | `null` | ## ApiProvider diff --git a/src/ConnectWidget.tsx b/src/ConnectWidget.tsx index 389a40e163..97acf4bf03 100644 --- a/src/ConnectWidget.tsx +++ b/src/ConnectWidget.tsx @@ -9,6 +9,7 @@ import { initGettextLocaleData } from 'src/utilities/Personalization' import { ConnectedTokenProvider } from 'src/ConnectedTokenProvider' import { TooSmallDialog } from 'src/components/app/TooSmallDialog' import { setLocalizedContent } from 'src/redux/reducers/localizedContentSlice' +import { WebSocketProvider } from 'src/context/WebSocketContext' import './sharedVariables.css' interface PostMessageContextType { @@ -27,6 +28,7 @@ export const ConnectWidget = ({ onAnalyticPageview = () => {}, postMessageEventOverrides, showTooSmallDialog = true, + webSocketConnection, ...props }: any) => { initGettextLocaleData(props.language) @@ -38,12 +40,14 @@ export const ConnectWidget = ({ return ( - - - {showTooSmallDialog && } - - - + + + + {showTooSmallDialog && } + + + + ) diff --git a/src/__tests__/ConnectWidget-test.tsx b/src/__tests__/ConnectWidget-test.tsx new file mode 100644 index 0000000000..1ab6d38be6 --- /dev/null +++ b/src/__tests__/ConnectWidget-test.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { render } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { of } from 'rxjs' + +import { ConnectWidget } from '../ConnectWidget' +import { useWebSocket } from '../context/WebSocketContext' + +vi.mock('src/Connect', () => ({ + default: vi.fn(() => { + // In actual implementation, it uses Context + // But for the test we just want to see if it renders without crashing + // and correctly provides the context which we can check via useWebSocket in a child if we want + return
mock-connect
+ }), +})) + +// A simple component to verify context +const ContextChecker = () => { + const ws = useWebSocket() + return
{ws ? 'has-ws' : 'no-ws'}
+} + +// We need to mock Connect to render the ContextChecker instead +vi.mock('src/Connect', () => ({ + default: () => , +})) + +describe('ConnectWidget', () => { + const defaultProps = { + clientConfig: {}, + profiles: {}, + userFeatures: {}, + language: { locale: 'en', localizedContent: {} }, + } + + it('provides webSocketConnection to children when passed as a prop', () => { + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$: of({}), + } + + const { getByTestId } = render() + + expect(getByTestId('context-checker')).toHaveTextContent('has-ws') + }) + + it('does not provide webSocketConnection when not passed', () => { + const { getByTestId } = render() + + expect(getByTestId('context-checker')).toHaveTextContent('no-ws') + }) +}) diff --git a/src/context/WebSocketContext.tsx b/src/context/WebSocketContext.tsx new file mode 100644 index 0000000000..04f24ec9f3 --- /dev/null +++ b/src/context/WebSocketContext.tsx @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { createContext, useContext } from 'react' +import { Observable } from 'rxjs' + +export interface WebSocketConnection { + isConnected: () => boolean + webSocketMessages$: Observable +} + +const WebSocketContext = createContext(undefined) + +export const WebSocketProvider: React.FC<{ + value?: WebSocketConnection + children: React.ReactNode +}> = ({ value, children }) => ( + {children} +) + +export const useWebSocket = () => useContext(WebSocketContext) diff --git a/src/hooks/__tests__/usePollMember-test.tsx b/src/hooks/__tests__/usePollMember-test.tsx index 41869a1fda..9571e2328a 100644 --- a/src/hooks/__tests__/usePollMember-test.tsx +++ b/src/hooks/__tests__/usePollMember-test.tsx @@ -1,21 +1,29 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import React from 'react' import { renderHook, waitFor } from '@testing-library/react' import { vi } from 'vitest' import { usePollMember, PollingState } from 'src/hooks/usePollMember' import { ApiProvider, ApiContextTypes } from 'src/context/ApiContext' +import { WebSocketProvider, WebSocketConnection } from 'src/context/WebSocketContext' import { Provider } from 'react-redux' import { createReduxStore, RootState } from 'src/redux/Store' import { member, JOB_DATA } from 'src/services/mockedData' import { ReadableStatuses } from 'src/const/Statuses' import { CONNECTING_MESSAGES } from 'src/utilities/pollers' import { take } from 'rxjs/operators' +import { Subject } from 'rxjs' -const createWrapper = (apiValue: Partial, preloadedState?: Partial) => { +const createWrapper = ( + apiValue: Partial, + preloadedState?: Partial, + webSocketValue?: WebSocketConnection, +) => { const store = createReduxStore(preloadedState) const Wrapper = ({ children }: { children: React.ReactNode }) => ( - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - {children} + + {children} + ) Wrapper.displayName = 'TestWrapper' @@ -27,6 +35,10 @@ describe('usePollMember', () => { document.documentElement.setAttribute('lang', 'en') }) + afterEach(() => { + vi.restoreAllMocks() + }) + it('should return a pollMember function', () => { const apiValue = { loadMemberByGuid: vi.fn().mockResolvedValue(member.member), @@ -303,9 +315,12 @@ describe('usePollMember', () => { }) it('should increment pollingCount on each poll', async () => { + const member1 = { ...member.member, guid: 'MBR-1', most_recent_job_guid: 'JOB-1' } + const member2 = { ...member.member, guid: 'MBR-2', most_recent_job_guid: 'JOB-2' } + const apiValue = { - loadMemberByGuid: vi.fn().mockResolvedValue(member.member), - loadJob: vi.fn().mockResolvedValue(JOB_DATA), + loadMemberByGuid: vi.fn().mockResolvedValueOnce(member1).mockResolvedValue(member2), + loadJob: vi.fn().mockImplementation((guid) => Promise.resolve({ ...JOB_DATA, guid })), } const preloadedState = { @@ -446,15 +461,23 @@ describe('usePollMember', () => { async_account_data_ready: true, } - const memberWithJob = { + const member1 = { ...member.member, + guid: 'MBR-1', + most_recent_job_guid: 'JOB-1', is_being_aggregated: false, connection_status: ReadableStatuses.CONNECTED, } + const member2 = { ...member1, guid: 'MBR-2', most_recent_job_guid: 'JOB-2' } + const member3 = { ...member1, guid: 'MBR-3', most_recent_job_guid: 'JOB-3' } const apiValue = { - loadMemberByGuid: vi.fn().mockResolvedValue(memberWithJob), - loadJob: vi.fn().mockResolvedValue(jobWithAsyncData), + loadMemberByGuid: vi + .fn() + .mockResolvedValueOnce(member1) + .mockResolvedValueOnce(member2) + .mockResolvedValue(member3), + loadJob: vi.fn().mockImplementation((guid) => Promise.resolve({ ...jobWithAsyncData, guid })), } const preloadedState = { @@ -622,12 +645,12 @@ describe('usePollMember', () => { }, 10000) it('should correctly update previousResponse and currentResponse over multiple polls', async () => { - const member1 = { ...member.member, guid: 'MBR-1' } - const member2 = { ...member.member, guid: 'MBR-2' } + const member1 = { ...member.member, guid: 'MBR-1', most_recent_job_guid: 'JOB-1' } + const member2 = { ...member.member, guid: 'MBR-2', most_recent_job_guid: 'JOB-2' } const apiValue = { loadMemberByGuid: vi.fn().mockResolvedValueOnce(member1).mockResolvedValue(member2), - loadJob: vi.fn().mockResolvedValue(JOB_DATA), + loadJob: vi.fn().mockImplementation((guid) => Promise.resolve({ ...JOB_DATA, guid })), } const preloadedState = { @@ -658,25 +681,34 @@ describe('usePollMember', () => { // First poll expect(states[0].previousResponse).toEqual({}) - expect(states[0].currentResponse).toEqual({ member: member1, job: JOB_DATA }) + expect(states[0].currentResponse).toEqual({ + member: member1, + job: { ...JOB_DATA, guid: 'JOB-1' }, + }) // Second poll - expect(states[1].previousResponse).toEqual({ member: member1, job: JOB_DATA }) - expect(states[1].currentResponse).toEqual({ member: member2, job: JOB_DATA }) + expect(states[1].previousResponse).toEqual({ + member: member1, + job: { ...JOB_DATA, guid: 'JOB-1' }, + }) + expect(states[1].currentResponse).toEqual({ + member: member2, + job: { ...JOB_DATA, guid: 'JOB-2' }, + }) subscription.unsubscribe() }, 10000) it('should preserve previousResponse and currentResponse when an intermediate poll fails', async () => { - const member1 = { ...member.member, guid: 'MBR-1' } + const member1 = { ...member.member, guid: 'MBR-1', most_recent_job_guid: 'JOB-1' } const apiValue = { loadMemberByGuid: vi .fn() .mockResolvedValueOnce(member1) .mockRejectedValueOnce(new Error('Intermediate Error')) - .mockResolvedValue(member1), - loadJob: vi.fn().mockResolvedValue(JOB_DATA), + .mockResolvedValue({ ...member1, guid: 'MBR-1-new', most_recent_job_guid: 'JOB-1-new' }), + loadJob: vi.fn().mockImplementation((guid) => Promise.resolve({ ...JOB_DATA, guid })), } const preloadedState = { @@ -707,18 +739,88 @@ describe('usePollMember', () => { // First poll: Success expect(states[0].isError).toBe(false) - expect(states[0].currentResponse).toEqual({ member: member1, job: JOB_DATA }) + expect(states[0].currentResponse).toEqual({ + member: member1, + job: { ...JOB_DATA, guid: 'JOB-1' }, + }) // Second poll: Error expect(states[1].isError).toBe(true) expect(states[1].previousResponse).toEqual({}) // Should be preserved from acc - expect(states[1].currentResponse).toEqual({ member: member1, job: JOB_DATA }) // Should be preserved from acc + expect(states[1].currentResponse).toEqual({ + member: member1, + job: { ...JOB_DATA, guid: 'JOB-1' }, + }) // Should be preserved from acc // Third poll: Success again expect(states[2].isError).toBe(false) - expect(states[2].previousResponse).toEqual({ member: member1, job: JOB_DATA }) // acc.currentResponse was preserved - expect(states[2].currentResponse).toEqual({ member: member1, job: JOB_DATA }) + expect(states[2].previousResponse).toEqual({ + member: member1, + job: { ...JOB_DATA, guid: 'JOB-1' }, + }) // acc.currentResponse was preserved + expect(states[2].currentResponse).toEqual({ + member: { ...member1, guid: 'MBR-1-new', most_recent_job_guid: 'JOB-1-new' }, + job: { ...JOB_DATA, guid: 'JOB-1-new' }, + }) subscription.unsubscribe() }, 10000) + + it('should receive updates from WebSockets when enabled', async () => { + const wsMessages$ = new Subject() + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$: wsMessages$.asObservable(), + } + + const apiValue = { + loadMemberByGuid: vi.fn().mockResolvedValue(member.member), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const preloadedState = { + experimentalFeatures: { + useWebSockets: true, + memberPollingMilliseconds: 10000, // Long interval to avoid poll interference + }, + } + + const { result } = renderHook(() => usePollMember(), { + wrapper: createWrapper(apiValue, preloadedState, mockWS), + }) + + const pollMember = result.current + const states: PollingState[] = [] + + const subscription = pollMember('MBR-123').subscribe((state: PollingState) => { + states.push(state) + }) + + // Emit from WebSocket + const wsMember = { guid: 'MBR-123', connection_status: 1 } + wsMessages$.next({ event: 'members/updated', payload: wsMember }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(0) + }, + { timeout: 4000 }, + ) + + expect(states[0].currentResponse?.member).toEqual(wsMember) + + // Emit priority data ready + wsMessages$.next({ event: 'members/priority_data_ready', payload: wsMember }) + + await waitFor( + () => { + expect(states.length).toBeGreaterThan(1) + }, + { timeout: 4000 }, + ) + + expect(states[1].initialDataReady).toBe(true) + + subscription.unsubscribe() + }) }) diff --git a/src/hooks/usePollMember.tsx b/src/hooks/usePollMember.tsx index 9c571569da..d889905eba 100644 --- a/src/hooks/usePollMember.tsx +++ b/src/hooks/usePollMember.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { DEFAULT_POLLING_STATE, handlePollingResponse } from 'src/utilities/pollers' import { useApi } from 'src/context/ApiContext' +import { useWebSocket } from 'src/context/WebSocketContext' import { useSelector } from 'react-redux' import { getExperimentalFeatures } from 'src/redux/reducers/experimentalFeaturesSlice' @@ -22,12 +23,13 @@ export interface PollingState { export function usePollMember() { const { api } = useApi() + const webSocket = useWebSocket() const clientLocale = useMemo(() => { return document.querySelector('html')?.getAttribute('lang') || 'en' }, [document.querySelector('html')?.getAttribute('lang')]) - const { optOutOfEarlyUserRelease, memberPollingMilliseconds } = + const { optOutOfEarlyUserRelease, memberPollingMilliseconds, useWebSockets } = useSelector(getExperimentalFeatures) const pollingInterval = memberPollingMilliseconds || 3000 @@ -46,7 +48,9 @@ export function usePollMember() { { pollingInterval, clientLocale, + useWebSockets, }, + webSocket, ) return updateStream$.pipe( @@ -72,7 +76,6 @@ export function usePollMember() { if ( !isError && !acc.initialDataReady && - // @ts-expect-error response might be undefined or an error response?.job?.async_account_data_ready && !optOutOfEarlyUserRelease ) { diff --git a/src/redux/reducers/__tests__/experimentalFeaturesSlice-test.ts b/src/redux/reducers/__tests__/experimentalFeaturesSlice-test.ts new file mode 100644 index 0000000000..a5eb020f39 --- /dev/null +++ b/src/redux/reducers/__tests__/experimentalFeaturesSlice-test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import reducer, { initialState, loadExperimentalFeatures } from '../experimentalFeaturesSlice' + +describe('experimentalFeaturesSlice', () => { + it('should return the initial state', () => { + expect(reducer(undefined, { type: 'unknown' })).toEqual(initialState) + }) + + it('should handle loadExperimentalFeatures', () => { + const payload = { + unavailableInstitutions: [{ guid: '123', name: 'Test' }], + optOutOfEarlyUserRelease: true, + memberPollingMilliseconds: 5000, + useWebSockets: true, + } + const nextState = reducer(initialState, loadExperimentalFeatures(payload)) + expect(nextState.unavailableInstitutions).toEqual(payload.unavailableInstitutions) + expect(nextState.optOutOfEarlyUserRelease).toBe(true) + expect(nextState.memberPollingMilliseconds).toBe(5000) + expect(nextState.useWebSockets).toBe(true) + }) + + it('should default useWebSockets to false if not provided', () => { + const payload = {} + const nextState = reducer(initialState, loadExperimentalFeatures(payload)) + expect(nextState.useWebSockets).toBe(false) + }) +}) diff --git a/src/redux/reducers/experimentalFeaturesSlice.ts b/src/redux/reducers/experimentalFeaturesSlice.ts index 5b957ff583..a1c9a063e4 100644 --- a/src/redux/reducers/experimentalFeaturesSlice.ts +++ b/src/redux/reducers/experimentalFeaturesSlice.ts @@ -5,12 +5,14 @@ type ExperimentalFeaturesSlice = { optOutOfEarlyUserRelease?: boolean unavailableInstitutions?: { guid: string; name: string }[] memberPollingMilliseconds?: number + useWebSockets?: boolean } export const initialState: ExperimentalFeaturesSlice = { optOutOfEarlyUserRelease: false, unavailableInstitutions: [], memberPollingMilliseconds: undefined, + useWebSockets: false, } const experimentalFeaturesSlice = createSlice({ @@ -21,6 +23,7 @@ const experimentalFeaturesSlice = createSlice({ state.unavailableInstitutions = action.payload?.unavailableInstitutions || [] state.optOutOfEarlyUserRelease = action.payload?.optOutOfEarlyUserRelease || false state.memberPollingMilliseconds = action.payload?.memberPollingMilliseconds || undefined + state.useWebSockets = action.payload?.useWebSockets || false }, }, }) diff --git a/src/utilities/transport/MemberUpdateTransport.ts b/src/utilities/transport/MemberUpdateTransport.ts index e81d7fc63b..bbd793976a 100644 --- a/src/utilities/transport/MemberUpdateTransport.ts +++ b/src/utilities/transport/MemberUpdateTransport.ts @@ -1,6 +1,8 @@ -import { Observable, defer, interval, of } from 'rxjs' -import { catchError, map, mergeMap, exhaustMap } from 'rxjs/operators' +import { Observable, defer, interval, of, merge } from 'rxjs' +import { catchError, map, mergeMap, exhaustMap, filter, distinctUntilChanged } from 'rxjs/operators' +import _isEqual from 'lodash/isEqual' import type { ApiContextTypes } from 'src/context/ApiContext' +import { WebSocketConnection } from 'src/context/WebSocketContext' type MemberUpdateApi = Required> @@ -12,17 +14,20 @@ export interface MemberUpdate { export interface MemberUpdateTransportOptions { pollingInterval?: number clientLocale?: string + useWebSockets?: boolean } export function createMemberUpdateTransport( api: MemberUpdateApi, memberGuid: string, options: MemberUpdateTransportOptions = {}, + webSocket?: WebSocketConnection, ): Observable { const pollingInterval = options.pollingInterval || 3000 const clientLocale = options.clientLocale || 'en' + const useWebSockets = options.useWebSockets || false - return interval(pollingInterval).pipe( + const polling$ = interval(pollingInterval).pipe( exhaustMap(() => defer(() => api.loadMemberByGuid(memberGuid, clientLocale)).pipe( mergeMap((member: MemberResponseType) => @@ -34,4 +39,52 @@ export function createMemberUpdateTransport( ), ), ) + + let transport$: Observable = polling$ + + if (useWebSockets && webSocket?.webSocketMessages$ && webSocket?.isConnected()) { + const socket$ = webSocket.webSocketMessages$.pipe( + filter( + (msg) => + (msg.event === 'members/updated' || msg.event === 'members/priority_data_ready') && + msg.payload?.guid === memberGuid, + ), + map((msg) => { + const member = msg.payload + const job = { + guid: member?.most_recent_job_guid, + async_account_data_ready: msg.event === 'members/priority_data_ready' || undefined, + } as JobResponseType + + return { member, job } + }), + // If the websocket errors out, we don't want to kill the polling stream. + // We just want to stop receiving messages from the socket and let polling continue. + catchError(() => of()), + ) + transport$ = merge(polling$, socket$) + } + + return transport$.pipe( + distinctUntilChanged((prev, curr) => { + // Don't deduplicate errors + if (prev instanceof Error || curr instanceof Error) return false + + const prevMember = prev.member + const currMember = curr.member + + // Compare the relevant fields to determine if we should emit an update + // Return true to *prevent* emitting the event + // Return false to emit the event + return ( + prevMember?.connection_status === currMember?.connection_status && + _isEqual(prevMember?.mfa, currMember?.mfa) && + prev.job?.guid === curr.job?.guid && + prev.job?.async_account_data_ready === curr.job?.async_account_data_ready && + prevMember?.is_being_aggregated === currMember?.is_being_aggregated && + prevMember?.most_recent_job_detail_code === currMember?.most_recent_job_detail_code && + prevMember?.error?.error_code === currMember?.error?.error_code + ) + }), + ) } diff --git a/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts b/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts index 32e9e59584..371f27768c 100644 --- a/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts +++ b/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts @@ -1,6 +1,11 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { vi } from 'vitest' import { take } from 'rxjs/operators' -import { createMemberUpdateTransport, MemberUpdate } from '../MemberUpdateTransport' +import { Subject } from 'rxjs' +import { + createMemberUpdateTransport, + MemberUpdate, +} from 'src/utilities/transport/MemberUpdateTransport' describe('MemberUpdateTransport', () => { const mockMemberGuid = 'MBR-123' @@ -47,7 +52,7 @@ describe('MemberUpdateTransport', () => { subscription.unsubscribe() }) - it('should continue emitting updates on each interval', async () => { + it('should continue emitting updates on each interval when data changes', async () => { const transport$ = createMemberUpdateTransport(mockApi, mockMemberGuid, { pollingInterval: 1000, clientLocale: mockClientLocale, @@ -59,12 +64,22 @@ describe('MemberUpdateTransport', () => { results.push(val) }) - // Fast-forward 3 intervals - await vi.advanceTimersByTimeAsync(3000) + // Fast-forward 1 interval + await vi.advanceTimersByTimeAsync(1000) + expect(results).toHaveLength(1) + + // Change the mock to return a different status + mockApi.loadMemberByGuid.mockResolvedValue({ ...mockMember, connection_status: 1 }) + await vi.advanceTimersByTimeAsync(1000) + expect(results).toHaveLength(2) + + // Change it back + mockApi.loadMemberByGuid.mockResolvedValue(mockMember) + await vi.advanceTimersByTimeAsync(1000) + expect(results).toHaveLength(3) expect(mockApi.loadMemberByGuid).toHaveBeenCalledTimes(3) expect(mockApi.loadJob).toHaveBeenCalledTimes(3) - expect(results).toHaveLength(3) subscription.unsubscribe() }) @@ -134,4 +149,112 @@ describe('MemberUpdateTransport', () => { subscription.unsubscribe() }) + + it('should emit WebSocket updates immediately when enabled', async () => { + const wsMessages$ = new Subject() + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$: wsMessages$.asObservable(), + } + + const transport$ = createMemberUpdateTransport( + mockApi, + mockMemberGuid, + { useWebSockets: true }, + mockWS, + ) + + const results: (MemberUpdate | Error)[] = [] + const subscription = transport$.subscribe((val) => { + results.push(val) + }) + + const wsMember = { guid: mockMemberGuid, connection_status: 1 } + wsMessages$.next({ event: 'members/updated', payload: wsMember }) + + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + member: wsMember, + job: { async_account_data_ready: undefined, guid: undefined }, + }) + + subscription.unsubscribe() + }) + + it('should signal async_account_data_ready when members/priority_data_ready is received', async () => { + const wsMessages$ = new Subject() + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$: wsMessages$.asObservable(), + } + + const transport$ = createMemberUpdateTransport( + mockApi, + mockMemberGuid, + { useWebSockets: true }, + mockWS, + ) + + const results: (MemberUpdate | Error)[] = [] + const subscription = transport$.subscribe((val) => { + results.push(val) + }) + + const wsMember = { guid: mockMemberGuid, connection_status: 1 } + wsMessages$.next({ event: 'members/priority_data_ready', payload: wsMember }) + + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + member: wsMember, + job: { async_account_data_ready: true }, + }) + + subscription.unsubscribe() + }) + + it('should deduplicate identical updates from polling and WebSockets', async () => { + const wsMessages$ = new Subject() + const mockWS = { + isConnected: vi.fn().mockReturnValue(true), + webSocketMessages$: wsMessages$.asObservable(), + } + + // Configure polling to return same data + const jobWithGuid = { ...mockJob, guid: 'JOB-123' } + mockApi.loadMemberByGuid.mockResolvedValue(mockMember) + mockApi.loadJob.mockResolvedValue(jobWithGuid) + + const transport$ = createMemberUpdateTransport( + mockApi, + mockMemberGuid, + { useWebSockets: true, pollingInterval: 1000 }, + mockWS, + ) + + const results: (MemberUpdate | Error)[] = [] + const subscription = transport$.subscribe((val) => { + results.push(val) + }) + + // 1. Trigger first poll + await vi.advanceTimersByTimeAsync(1000) + expect(results).toHaveLength(1) + + // 2. Emit identical data from WebSocket + wsMessages$.next({ event: 'members/updated', payload: mockMember }) + expect(results).toHaveLength(1) // Still 1 + + // 3. Trigger second poll + await vi.advanceTimersByTimeAsync(1000) + expect(results).toHaveLength(1) + + // 4. Emit a DIFFERENT update from WebSocket + const updatedMember = { ...mockMember, connection_status: 3 } + wsMessages$.next({ event: 'members/updated', payload: updatedMember }) + + expect(results).toHaveLength(2) + expect((results[1] as MemberUpdate).member?.connection_status).toBe(3) + + subscription.unsubscribe() + }) }) diff --git a/typings/apiTypes.d.ts b/typings/apiTypes.d.ts index 30c1e4fcc3..6919ce3f75 100644 --- a/typings/apiTypes.d.ts +++ b/typings/apiTypes.d.ts @@ -112,12 +112,12 @@ type MemberResponseType = { name?: string process_status?: number revision?: number + use_cases?: [string] | null user_guid: string - verification_is_enabled: boolean - oauth_window_uri?: string | null verification_is_enabled?: boolean + oauth_window_uri?: string | null tax_statement_is_enabled?: boolean - successfully_aggreagted_at?: number + successfully_aggregated_at?: number } // Institution types @@ -287,6 +287,7 @@ type JobResponseType = { finished_at: number started_at: number updated_at: number + async_account_data_ready?: boolean } // user types diff --git a/typings/connectProps.d.ts b/typings/connectProps.d.ts index 65a9b06c7e..86356e91f9 100644 --- a/typings/connectProps.d.ts +++ b/typings/connectProps.d.ts @@ -4,6 +4,7 @@ interface ConnectWidgetPropTypes extends ConnectProps { language?: LanguageType onPostMessage: (event: string, data?: object) => void showTooSmallDialog: boolean + webSocketConnection?: any } interface PostMessageEventOverrides { @@ -36,10 +37,12 @@ interface ConnectProps { postMessageEventOverrides?: PostMessageEventOverrides profiles: ProfilesTypes userFeatures?: object + webSocketConnection?: any experimentalFeatures?: null | { unavailableInstitutions?: { guid: string; name: string }[] optOutOfEarlyUserRelease?: boolean memberPollingMilliseconds?: number + useWebSockets?: boolean } } interface ClientConfigType { diff --git a/typings/mxTypes.d.ts b/typings/mxTypes.d.ts index f3c34fe12c..eb30575362 100644 --- a/typings/mxTypes.d.ts +++ b/typings/mxTypes.d.ts @@ -46,7 +46,8 @@ type MemberResponseType = { last_job_status?: number last_update_time?: string metadata?: { [key: string]: unknown } - most_recent_job_detail_code?: number + mfa?: MfaCredentialType | object + most_recent_job_detail_code?: number | null most_recent_job_guid?: string needs_updated_credentials?: boolean name?: string @@ -54,7 +55,10 @@ type MemberResponseType = { revision?: number use_cases?: [string] | null user_guid: string - verification_is_enabled: boolean + verification_is_enabled?: boolean + oauth_window_uri?: string | null + tax_statement_is_enabled?: boolean + successfully_aggregated_at?: number } // Institution types @@ -172,6 +176,7 @@ type JobResponseType = { job_type: number status: number finished_at: number + async_account_data_ready?: boolean } // user types