From fd3d6d0b830a5c5c1ab5cb17f5faea51f4af6c0e Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Wed, 1 Apr 2026 12:07:58 -0600 Subject: [PATCH 1/7] feat(members): implement WebSocket message subscriptions --- README.md | 2 + src/ConnectWidget.tsx | 16 +- src/__tests__/ConnectWidget-test.tsx | 53 +++++++ src/context/WebSocketContext.tsx | 19 +++ src/hooks/__tests__/usePollMember-test.tsx | 144 +++++++++++++++--- src/hooks/usePollMember.tsx | 7 +- .../experimentalFeaturesSlice-test.ts | 28 ++++ .../reducers/experimentalFeaturesSlice.ts | 3 + .../transport/MemberUpdateTransport.ts | 59 ++++++- .../__tests__/MemberUpdateTransport-test.ts | 133 +++++++++++++++- typings/apiTypes.d.ts | 7 +- typings/connectProps.d.ts | 3 + typings/mxTypes.d.ts | 9 +- 13 files changed, 441 insertions(+), 42 deletions(-) create mode 100644 src/__tests__/ConnectWidget-test.tsx create mode 100644 src/context/WebSocketContext.tsx create mode 100644 src/redux/reducers/__tests__/experimentalFeaturesSlice-test.ts 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 From 50c08faccdccd821c2cc0ba53e2d3db4bb0ea3c1 Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Fri, 10 Apr 2026 13:12:11 -0600 Subject: [PATCH 2/7] fix: npm audit fix --- package-lock.json | 122 +++++++++++++++++++++++++++------------------- 1 file changed, 71 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5220338274..c8dc1eb303 100644 --- a/package-lock.json +++ b/package-lock.json @@ -847,6 +847,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -863,6 +864,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -879,6 +881,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -895,6 +898,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -911,6 +915,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -926,6 +931,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -942,6 +948,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -958,6 +965,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -974,6 +982,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -990,6 +999,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1006,6 +1016,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1022,6 +1033,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1038,6 +1050,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1054,6 +1067,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1070,6 +1084,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1086,6 +1101,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1102,6 +1118,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1118,6 +1135,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1134,6 +1152,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1150,6 +1169,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1166,6 +1186,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1182,6 +1203,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1198,6 +1220,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1214,6 +1237,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1230,6 +1254,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1246,6 +1271,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3234,6 +3260,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3247,6 +3274,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3260,6 +3288,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3273,6 +3302,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3286,6 +3316,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3299,6 +3330,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3312,9 +3344,7 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3328,9 +3358,7 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3344,9 +3372,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3360,9 +3386,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3376,9 +3400,7 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3392,9 +3414,7 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3408,9 +3428,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3424,9 +3442,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3440,9 +3456,7 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3456,9 +3470,7 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3472,9 +3484,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3488,9 +3498,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3504,9 +3512,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3520,6 +3526,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3533,6 +3540,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3546,6 +3554,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3559,6 +3568,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3572,6 +3582,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3585,6 +3596,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5536,13 +5548,14 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-plugin-macros": { @@ -8233,6 +8246,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -9653,6 +9667,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, "optional": true, "bin": { "jiti": "lib/jiti-cli.mjs" @@ -13278,7 +13293,7 @@ } }, "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", + "version": "4.0.3", "dev": true, "inBundle": true, "license": "MIT", @@ -14200,9 +14215,13 @@ "dev": true }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/psl": { "version": "1.15.0", @@ -16745,10 +16764,11 @@ "integrity": "sha512-m6EXlCAMetKztO1ppBhGU1/1MR3IiEevO6ESq6rcrSQ3Q77xYSW13jkfXW88o4xMrkXJhy/U7j4wFR/twMB0Eg==" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "devOptional": true, + "license": "MIT", "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -17319,7 +17339,7 @@ "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" From a6b58da2b8ee133a5b0be8bd6564bfd97e971397 Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Fri, 10 Apr 2026 13:44:43 -0600 Subject: [PATCH 3/7] fix: remove audit workflow, snyk is set up to scan our repo more wholistically --- .github/workflows/audit.yml | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 .github/workflows/audit.yml diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml deleted file mode 100644 index e81a64a2c5..0000000000 --- a/.github/workflows/audit.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: NPM Audit - -on: - pull_request: - -jobs: - NPM-Audit: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: "lts/*" - - - name: Run npm audit - run: npm audit --audit-level=high From a400c7829d53c078c009ab34a8d07c7cad30c5c2 Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Wed, 15 Apr 2026 07:23:40 -0600 Subject: [PATCH 4/7] refactor(tests): replace Connect mock with real component in ConnectWidget-test.tsx --- .../0003AutomatedTestingFrontend.md | 2 +- src/__tests__/ConnectWidget-test.tsx | 87 ++++++++++++------- 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/architectureDecisionRecords/0003AutomatedTestingFrontend.md b/architectureDecisionRecords/0003AutomatedTestingFrontend.md index 42f35724d8..713d76e956 100644 --- a/architectureDecisionRecords/0003AutomatedTestingFrontend.md +++ b/architectureDecisionRecords/0003AutomatedTestingFrontend.md @@ -18,7 +18,7 @@ We will use the following technologies to test: 1. [Vitest](https://vitest.dev/) for unit/integration tests 1. [MSW](https://mswjs.io/docs/getting-started/) for api mocking in unit/integration tests -We will mock as little as possible in our tests and [prefer integration tests over unit tests](https://kentcdodds.com/blog/write-tests). The bulk of our tests will be integration tests, because they provide the best performance to confidence ratio. +We will mock as little as possible in our tests and [prefer integration tests over unit tests](https://kentcdodds.com/blog/write-tests). The bulk of our tests will be integration tests, because they provide the best performance to confidence ratio. We also prefer to render real components rather than using mocks (e.g., `vi.mock`) in integration tests to ensure that context and side-effects are correctly wired. The purpose of our end to end tests will be to validate that the frontend is working with the backend properly, and that apis are working together properly. We don't need to test every edge case in our end to end tests, because using MSW allows us to test those edge cases in our integration tests. diff --git a/src/__tests__/ConnectWidget-test.tsx b/src/__tests__/ConnectWidget-test.tsx index 1ab6d38be6..6bb41a5ad5 100644 --- a/src/__tests__/ConnectWidget-test.tsx +++ b/src/__tests__/ConnectWidget-test.tsx @@ -1,29 +1,17 @@ import React from 'react' -import { render } from '@testing-library/react' import { describe, it, expect, vi } from 'vitest' -import { of } from 'rxjs' +import { Subject } from 'rxjs' +import { act } from '@testing-library/react' 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'}
-} +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' +import { member, JOB_DATA } from 'src/services/mockedData' +import { ReadableStatuses } from 'src/const/Statuses' -// We need to mock Connect to render the ContextChecker instead -vi.mock('src/Connect', () => ({ - default: () => , +// Mock react-confetti to avoid Canvas issues in JSDOM +vi.mock('react-confetti', () => ({ + default: () =>
, })) describe('ConnectWidget', () => { @@ -34,20 +22,61 @@ describe('ConnectWidget', () => { language: { locale: 'en', localizedContent: {} }, } - it('provides webSocketConnection to children when passed as a prop', () => { + it('renders the real Connect widget and handles WebSocket messages correctly', async () => { + const webSocketMessages$ = new Subject() const mockWS = { isConnected: vi.fn().mockReturnValue(true), - webSocketMessages$: of({}), + webSocketMessages$, } - const { getByTestId } = render() + const aggregatingMember = { + ...member.member, + is_being_aggregated: true, + connection_status: ReadableStatuses.CREATED, + } - expect(getByTestId('context-checker')).toHaveTextContent('has-ws') - }) + const mockApiValue = { + ...apiValueMock, + loadMemberByGuid: vi.fn().mockResolvedValue(aggregatingMember), + loadJob: vi.fn().mockResolvedValue(JOB_DATA), + } + + const onSuccessfulAggregation = vi.fn() + const clientConfig = { mode: 'aggregation', current_member_guid: 'MBR-123' } + + render( + , + { apiValue: mockApiValue }, + ) + + // The widget should enter the Connecting state + expect(await screen.findByText(/Connecting to/i)).toBeInTheDocument() + + // Send a WebSocket message indicating the member finished aggregating successfully + const successMember = { + ...aggregatingMember, + is_being_aggregated: false, + connection_status: ReadableStatuses.CONNECTED, + } - it('does not provide webSocketConnection when not passed', () => { - const { getByTestId } = render() + act(() => { + webSocketMessages$.next({ + event: 'members/updated', + payload: successMember, + }) + }) - expect(getByTestId('context-checker')).toHaveTextContent('no-ws') + // The widget should receive the update and trigger the success callback (by mounting Connected view) + await waitFor(() => { + expect(onSuccessfulAggregation).toHaveBeenCalledWith( + expect.objectContaining({ guid: 'MBR-123' }), + ) + }) }) }) From 72c8ac6ffb4eff7f86e70fa36f0da6e8f67a84e5 Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Wed, 15 Apr 2026 07:46:25 -0600 Subject: [PATCH 5/7] fix: add optional chaining to protect from when isConnected is not provided --- src/utilities/transport/MemberUpdateTransport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utilities/transport/MemberUpdateTransport.ts b/src/utilities/transport/MemberUpdateTransport.ts index bbd793976a..c7afc095ee 100644 --- a/src/utilities/transport/MemberUpdateTransport.ts +++ b/src/utilities/transport/MemberUpdateTransport.ts @@ -42,7 +42,7 @@ export function createMemberUpdateTransport( let transport$: Observable = polling$ - if (useWebSockets && webSocket?.webSocketMessages$ && webSocket?.isConnected()) { + if (useWebSockets && webSocket?.webSocketMessages$ && webSocket?.isConnected?.()) { const socket$ = webSocket.webSocketMessages$.pipe( filter( (msg) => From 27693a1a44ba550b02e872b666f0867cbe1e6762 Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Wed, 15 Apr 2026 15:03:09 -0600 Subject: [PATCH 6/7] fix: priority data was not correctly handled, ensure member data is available --- .../transport/MemberUpdateTransport.ts | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/utilities/transport/MemberUpdateTransport.ts b/src/utilities/transport/MemberUpdateTransport.ts index c7afc095ee..c4f492ef36 100644 --- a/src/utilities/transport/MemberUpdateTransport.ts +++ b/src/utilities/transport/MemberUpdateTransport.ts @@ -1,5 +1,13 @@ import { Observable, defer, interval, of, merge } from 'rxjs' -import { catchError, map, mergeMap, exhaustMap, filter, distinctUntilChanged } from 'rxjs/operators' +import { + catchError, + map, + mergeMap, + exhaustMap, + filter, + distinctUntilChanged, + scan, +} from 'rxjs/operators' import _isEqual from 'lodash/isEqual' import type { ApiContextTypes } from 'src/context/ApiContext' import { WebSocketConnection } from 'src/context/WebSocketContext' @@ -49,15 +57,25 @@ export function createMemberUpdateTransport( (msg.event === 'members/updated' || msg.event === 'members/priority_data_ready') && msg.payload?.guid === memberGuid, ), - map((msg) => { - const member = msg.payload + scan((previousUpdate: MemberUpdate, msg) => { + // The priority_data_ready event does not send full member data, + // so we need to use the previous member data and adjust async_account_data_ready + const isMembersUpdated = msg.event === 'members/updated' + const member = isMembersUpdated ? msg.payload : previousUpdate?.member + const job = { guid: member?.most_recent_job_guid, - async_account_data_ready: msg.event === 'members/priority_data_ready' || undefined, + async_account_data_ready: + // The priority_data_ready event fires once, keep the flag true once it is received + previousUpdate.job?.async_account_data_ready || + msg.event === 'members/priority_data_ready', } as JobResponseType return { member, job } - }), + }, {} as MemberUpdate), + // If we don't have member data yet, wait for the next members/updated event to populate it. + filter((update) => !!update.member), + // 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()), From 959d0fc9df6b8c8ac211fbdb1e7c18543e5c13ff Mon Sep 17 00:00:00 2001 From: Logan Rasmussen Date: Wed, 15 Apr 2026 16:58:17 -0600 Subject: [PATCH 7/7] fix(websockets): add tests for recent changes --- src/hooks/__tests__/usePollMember-test.tsx | 48 +++++++++ .../__tests__/MemberUpdateTransport-test.ts | 101 ++++++++++++++++-- 2 files changed, 141 insertions(+), 8 deletions(-) diff --git a/src/hooks/__tests__/usePollMember-test.tsx b/src/hooks/__tests__/usePollMember-test.tsx index 9571e2328a..024f97ad0d 100644 --- a/src/hooks/__tests__/usePollMember-test.tsx +++ b/src/hooks/__tests__/usePollMember-test.tsx @@ -823,4 +823,52 @@ describe('usePollMember', () => { subscription.unsubscribe() }) + + it('should handle members/priority_data_ready even if payload is minimal', 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, + }, + } + + 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) + }) + + // 1. Emit full member data + const fullMember = { guid: 'MBR-123', connection_status: 1, most_recent_job_guid: 'JOB-123' } + wsMessages$.next({ event: 'members/updated', payload: fullMember }) + + await waitFor(() => expect(states.length).toBe(1)) + + // 2. Emit priority_data_ready with minimal payload + wsMessages$.next({ event: 'members/priority_data_ready', payload: { guid: 'MBR-123' } }) + + await waitFor(() => expect(states.length).toBe(2)) + + expect(states[1].initialDataReady).toBe(true) + // Verify it used the member data from previous message + expect(states[1].currentResponse?.member).toEqual(fullMember) + + subscription.unsubscribe() + }) }) diff --git a/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts b/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts index 371f27768c..e65405710f 100644 --- a/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts +++ b/src/utilities/transport/__tests__/MemberUpdateTransport-test.ts @@ -169,19 +169,19 @@ describe('MemberUpdateTransport', () => { results.push(val) }) - const wsMember = { guid: mockMemberGuid, connection_status: 1 } + const wsMember = { guid: mockMemberGuid, connection_status: 1, most_recent_job_guid: 'JOB-123' } 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 }, + job: { async_account_data_ready: false, guid: 'JOB-123' }, }) subscription.unsubscribe() }) - it('should signal async_account_data_ready when members/priority_data_ready is received', async () => { + it('should signal async_account_data_ready when members/priority_data_ready is received after member data is available', async () => { const wsMessages$ = new Subject() const mockWS = { isConnected: vi.fn().mockReturnValue(true), @@ -200,13 +200,19 @@ describe('MemberUpdateTransport', () => { results.push(val) }) - const wsMember = { guid: mockMemberGuid, connection_status: 1 } - wsMessages$.next({ event: 'members/priority_data_ready', payload: wsMember }) + const wsMember = { guid: mockMemberGuid, connection_status: 1, most_recent_job_guid: 'JOB-123' } + // 1. Send member update first + wsMessages$.next({ event: 'members/updated', payload: wsMember }) expect(results).toHaveLength(1) - expect(results[0]).toEqual({ + + // 2. Send priority data ready event (payload might be minimal) + wsMessages$.next({ event: 'members/priority_data_ready', payload: { guid: mockMemberGuid } }) + + expect(results).toHaveLength(2) + expect(results[1]).toEqual({ member: wsMember, - job: { async_account_data_ready: true }, + job: { async_account_data_ready: true, guid: 'JOB-123' }, }) subscription.unsubscribe() @@ -220,7 +226,7 @@ describe('MemberUpdateTransport', () => { } // Configure polling to return same data - const jobWithGuid = { ...mockJob, guid: 'JOB-123' } + const jobWithGuid = { ...mockJob, guid: 'JOB-123', async_account_data_ready: false } mockApi.loadMemberByGuid.mockResolvedValue(mockMember) mockApi.loadJob.mockResolvedValue(jobWithGuid) @@ -257,4 +263,83 @@ describe('MemberUpdateTransport', () => { subscription.unsubscribe() }) + + it('should ignore members/priority_data_ready if no member data has been received yet', 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) + }) + + // 1. Send priority data ready event first - should be filtered out because no member data yet + wsMessages$.next({ event: 'members/priority_data_ready', payload: { guid: mockMemberGuid } }) + expect(results).toHaveLength(0) + + // 2. Send member update - should finally emit + const wsMember = { guid: mockMemberGuid, connection_status: 1, most_recent_job_guid: 'JOB-123' } + wsMessages$.next({ event: 'members/updated', payload: wsMember }) + + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + member: wsMember, + job: { async_account_data_ready: true, guid: 'JOB-123' }, + }) + + subscription.unsubscribe() + }) + + it('should keep async_account_data_ready true once it has been set', 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 wsMember1 = { + guid: mockMemberGuid, + connection_status: 1, + most_recent_job_guid: 'JOB-123', + } + wsMessages$.next({ event: 'members/updated', payload: wsMember1 }) + + wsMessages$.next({ event: 'members/priority_data_ready', payload: { guid: mockMemberGuid } }) + expect((results[1] as MemberUpdate).job?.async_account_data_ready).toBe(true) + + // Send another member update, async_account_data_ready should remain true + const wsMember2 = { + guid: mockMemberGuid, + connection_status: 2, + most_recent_job_guid: 'JOB-123', + } + wsMessages$.next({ event: 'members/updated', payload: wsMember2 }) + + expect(results).toHaveLength(3) + expect((results[2] as MemberUpdate).job?.async_account_data_ready).toBe(true) + + subscription.unsubscribe() + }) })