From 6698a45c1ae9fbdb55493ed55591dc7f3abd53b3 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 20 Aug 2026 18:17:12 -0300 Subject: [PATCH 1/8] test: cover the background/foreground socket resume path Adds the AppState enhancer unit test and three real-SDK integration scenarios for the foreground resume path: a silently dead socket that reopens after a failed round trip, an actually closed transport that reconnects and resumes the session, and a healthy socket that is left alone. --- .../__tests__/appStateMiddleware.test.ts | 85 +++++ .../foregroundResume.integration.test.ts | 354 ++++++++++++++++++ 2 files changed, 439 insertions(+) create mode 100644 app/lib/store/__tests__/appStateMiddleware.test.ts create mode 100644 app/sagas/__tests__/foregroundResume.integration.test.ts diff --git a/app/lib/store/__tests__/appStateMiddleware.test.ts b/app/lib/store/__tests__/appStateMiddleware.test.ts new file mode 100644 index 00000000000..1e76843384c --- /dev/null +++ b/app/lib/store/__tests__/appStateMiddleware.test.ts @@ -0,0 +1,85 @@ +jest.mock('react-native', () => ({ + AppState: { + currentState: 'unknown', + addEventListener: jest.fn() + } +})); + +jest.mock('../../notifications', () => ({ + removeNotificationsAndBadge: jest.fn(() => Promise.resolve()) +})); + +import { AppState } from 'react-native'; + +import applyAppStateMiddleware from '../appStateMiddleware'; +import { APP_STATE } from '../../../actions/actionsTypes'; + +function setupStore() { + const dispatch = jest.fn(); + const createStore = jest.fn(() => ({ dispatch })); + applyAppStateMiddleware()(createStore)(); + const [, notifyAppState] = (AppState.addEventListener as jest.Mock).mock.calls[0]; + jest.runOnlyPendingTimers(); + dispatch.mockClear(); + return { dispatch, notifyAppState }; +} + +function dispatchedTypes(dispatch: jest.Mock) { + return dispatch.mock.calls.map(([action]) => action.type); +} + +describe('app state changes', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('tells the app it came to the foreground', () => { + const { dispatch, notifyAppState } = setupStore(); + + notifyAppState('active'); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.FOREGROUND]); + }); + + it('tells the app it went to the background', () => { + const { dispatch, notifyAppState } = setupStore(); + + notifyAppState('background'); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.BACKGROUND]); + }); + + it('stays silent while the app is only temporarily interrupted', () => { + const { dispatch, notifyAppState } = setupStore(); + + notifyAppState('inactive'); + + expect(dispatchedTypes(dispatch)).toEqual([]); + }); + + it('keeps the foreground state through a temporary interruption', () => { + const { dispatch, notifyAppState } = setupStore(); + + notifyAppState('active'); + notifyAppState('inactive'); + notifyAppState('active'); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.FOREGROUND]); + }); + + it('does not repeat the state already in effect', () => { + const { dispatch, notifyAppState } = setupStore(); + + notifyAppState('background'); + notifyAppState('background'); + notifyAppState('active'); + notifyAppState('active'); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.BACKGROUND, APP_STATE.FOREGROUND]); + }); +}); diff --git a/app/sagas/__tests__/foregroundResume.integration.test.ts b/app/sagas/__tests__/foregroundResume.integration.test.ts new file mode 100644 index 00000000000..7422c796e74 --- /dev/null +++ b/app/sagas/__tests__/foregroundResume.integration.test.ts @@ -0,0 +1,354 @@ +import { applyMiddleware, createStore, type AnyAction, type Store } from 'redux'; +import createSagaMiddleware from 'redux-saga'; + +jest.unmock('@rocket.chat/sdk'); + +jest.mock('universal-websocket-client', () => + jest.fn().mockImplementation(() => { + const connection = { + send: jest.fn((data: string) => { + const message = JSON.parse(data) as { msg: string; id?: string; method?: string }; + if (message.msg === 'connect') { + setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); + } else if (message.msg === 'ping') { + setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); + } else if (message.msg === 'sub') { + setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'ready', subs: [message.id] }) })); + } else if (message.msg === 'unsub') { + setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'nosub', id: message.id }) })); + } else if (message.msg === 'method' && message.method === 'login') { + setImmediate(() => + connection.onmessage({ + data: JSON.stringify({ msg: 'result', id: message.id, result: { id: USER_ID, token: RESUME_TOKEN } }) + }) + ); + } + }), + close: jest.fn(), + readyState: 1, + onopen: jest.fn(), + onmessage: jest.fn(), + onerror: jest.fn(), + onclose: jest.fn() + }; + mockConnections.push(connection); + return connection; + }) +); + +jest.mock('../../lib/methods/helpers/localAuthentication', () => ({ + localAuthenticate: jest.fn(), + saveLastLocalAuthenticationSession: jest.fn() +})); + +jest.mock('../../lib/services/restApi', () => ({ + setUserPresenceOnline: jest.fn(), + setUserPresenceAway: jest.fn() +})); + +jest.mock('../../lib/notifications', () => ({ + checkPendingNotification: jest.fn(() => Promise.resolve()) +})); + +jest.mock('../../lib/services/voip/MediaSessionInstance', () => ({ + mediaSessionInstance: { + reset: jest.fn(), + drainPendingHangups: jest.fn() + } +})); + +jest.mock('../../lib/services/voip/MediaSessionStore', () => ({ + mediaSessionStore: { getCurrentInstance: jest.fn(() => null) } +})); + +jest.mock('../../lib/services/voip/isInActiveVoipCall', () => ({ + isInActiveVoipCall: jest.fn(() => false) +})); + +jest.mock('../../lib/services/twoFactor', () => ({ + twoFactor: jest.fn() +})); + +jest.mock('../../lib/methods/subscribeRooms', () => ({ + subscribeRooms: jest.fn(), + unsubscribeRooms: jest.fn() +})); + +jest.mock('../../lib/methods/loadMissedMessages', () => ({ + loadMissedMessages: jest.fn(() => Promise.resolve()) +})); + +jest.mock('../../lib/methods/readMessages', () => ({ + readMessages: jest.fn(() => Promise.resolve()) +})); + +jest.mock('../../lib/methods/helpers/markMessagesRead', () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock('../../lib/methods/helpers/log', () => ({ + __esModule: true, + default: jest.fn(), + events: {}, + logEvent: jest.fn() +})); + +jest.mock('../../lib/encryption', () => ({ + Encryption: { decryptMessage: jest.fn(async (message: unknown) => message) } +})); + +jest.mock('../../lib/database/services/Message', () => ({ + getMessageById: jest.fn(() => Promise.resolve(null)) +})); + +jest.mock('../../lib/database/services/Thread', () => ({ + getThreadById: jest.fn() +})); + +jest.mock('../../lib/database/services/ThreadMessage', () => ({ + getThreadMessageById: jest.fn() +})); + +jest.mock('../../lib/database', () => ({ + __esModule: true, + default: { + setActiveDB: jest.fn(), + servers: { get: jest.fn(), write: jest.fn() }, + active: { + get: jest.fn(), + write: jest.fn(), + batch: jest.fn() + } + } +})); + +import RoomSubscription from '../../lib/methods/subscriptions/room'; +import databaseModule from '../../lib/database'; +import { connect } from '../../lib/services/connect'; +import { loadMissedMessages } from '../../lib/methods/loadMissedMessages'; +import { initStore } from '../../lib/store/auxStore'; +import { APP_STATE } from '../../actions/actionsTypes'; +import { appStart } from '../../actions/app'; +import { loginRequest, loginSuccess } from '../../actions/login'; +import { connectSuccess, disconnect } from '../../actions/connect'; +import { selectServerSuccess } from '../../actions/server'; +import { RootEnum } from '../../definitions'; +import reducers from '../../reducers'; +import loginRoot from '../login'; +import stateRoot from '../state'; + +interface MockConnection { + send: jest.Mock; + close: jest.Mock; + readyState: number; + onopen: () => void; + onmessage: (event: { data: string }) => void; + onerror: () => void; + onclose: (event?: { code?: number }) => void; +} + +interface WireFrame { + msg: string; + id?: string; + name?: string; + params?: unknown[]; +} + +const mockConnections: MockConnection[] = []; + +const SERVER = 'https://open.rocket.chat'; +const USER_ID = 'user-id'; +const RESUME_TOKEN = 'token-abc'; +const ROOM_ID = 'room-rid'; +const RECOVERY_WINDOW = 5000; + +const database = databaseModule as unknown as { + setActiveDB: jest.Mock; + active: { get: jest.Mock; write: jest.Mock; batch: jest.Mock }; +}; + +async function flush(turns = 10) { + for (let i = 0; i < turns; i++) { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(0); + } +} + +function framesOn(connection: MockConnection, msg: string) { + return connection.send.mock.calls + .map(([data]: [string]) => JSON.parse(data) as WireFrame) + .filter(message => message.msg === msg); +} + +function stopAnsweringFrames(connection: MockConnection) { + connection.send.mockImplementation(() => undefined); +} + +function makeCollection(name: string) { + return { + name, + find: jest.fn(), + query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })), + create: jest.fn(), + prepareCreate: jest.fn(), + schema: { columnArray: [] } + }; +} + +let dispatched: AnyAction[]; +let store: Store; +let collections: Record>; + +function recordDispatched() { + return () => (next: (action: AnyAction) => AnyAction) => (action: AnyAction) => { + dispatched.push(action); + return next(action); + }; +} + +function bootSignedInApp() { + dispatched = []; + const sagaMiddleware = createSagaMiddleware(); + store = createStore(reducers, applyMiddleware(recordDispatched(), sagaMiddleware)); + sagaMiddleware.run(stateRoot); + sagaMiddleware.run(loginRoot); + initStore(store); + store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); + store.dispatch(selectServerSuccess({ server: SERVER, name: 'open.rocket.chat', version: '6.0.0' })); + return store; +} + +async function openSignedInSocket() { + await connect({ server: SERVER }); + await flush(); + mockConnections[0].onopen(); + await flush(); + store.dispatch(loginSuccess({ id: USER_ID, token: RESUME_TOKEN } as never)); + store.dispatch(connectSuccess()); + await flush(); +} + +async function subscribeToRoom(rid: string) { + const room = new RoomSubscription(rid); + const subscribing = room.subscribe(); + await flush(); + await subscribing; + await flush(); + return room; +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockConnections.length = 0; + collections = {}; + database.active.get.mockReset().mockImplementation((name: string) => (collections[name] ??= makeCollection(name))); + database.active.write.mockReset().mockImplementation((fn: () => unknown) => fn()); + database.active.batch.mockReset().mockImplementation((...records: unknown[]) => Promise.resolve(records)); + global.fetch = jest.fn(() => + Promise.resolve({ + status: 200, + json: () => + Promise.resolve({ + status: 'success', + data: { userId: USER_ID, authToken: RESUME_TOKEN, me: { username: 'the-user', roles: ['user'], settings: {} } } + }) + }) + ) as unknown as typeof fetch; +}); + +afterEach(async () => { + await flush(); + jest.useRealTimers(); +}); + +describe('coming back to a conversation after the phone was locked', () => { + it('gets messages flowing again when the socket died silently while away', async () => { + bootSignedInApp(); + await openSignedInSocket(); + await subscribeToRoom(ROOM_ID); + const frozen = mockConnections[0]; + const subscribedTopics = framesOn(frozen, 'sub').map(frame => frame.name); + expect(subscribedTopics).toContain('stream-notify-logged'); + + stopAnsweringFrames(frozen); + const pingsBefore = framesOn(frozen, 'ping').length; + dispatched.length = 0; + jest.mocked(loadMissedMessages).mockClear(); + + store.dispatch({ type: APP_STATE.FOREGROUND }); + await flush(); + await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); + + expect(framesOn(frozen, 'ping').length).toBeGreaterThan(pingsBefore); + expect(mockConnections).toHaveLength(2); + const reopened = mockConnections[1]; + + expect(loadMissedMessages).not.toHaveBeenCalled(); + + reopened.onopen(); + await flush(); + await flush(); + + expect(dispatched).toContainEqual(connectSuccess()); + expect(dispatched).toContainEqual(loginRequest({ resume: RESUME_TOKEN }, false)); + expect(loadMissedMessages).toHaveBeenCalledWith({ rid: ROOM_ID }); + expect(framesOn(reopened, 'sub').map(frame => frame.name)).toEqual(expect.arrayContaining(['stream-notify-logged'])); + }); +}); + +describe('coming back after the network dropped while the app was away', () => { + it('lands on a reconnected, still-signed-in app instead of forcing a relaunch', async () => { + bootSignedInApp(); + await openSignedInSocket(); + const dropped = mockConnections[0]; + + dropped.readyState = 3; + dropped.onclose({ code: 1006 }); + await flush(); + expect(dispatched).toContainEqual(disconnect()); + dispatched.length = 0; + + store.dispatch({ type: APP_STATE.FOREGROUND }); + await flush(); + await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); + + expect(mockConnections.length).toBeGreaterThan(1); + const reopened = mockConnections[mockConnections.length - 1]; + + reopened.onopen(); + await flush(); + + const connectSuccessAt = dispatched.findIndex(action => action.type === connectSuccess().type); + const loginRequestAt = dispatched.findIndex(action => action.type === loginRequest({ resume: RESUME_TOKEN }, false).type); + expect(connectSuccessAt).toBeGreaterThanOrEqual(0); + expect(loginRequestAt).toBeGreaterThan(connectSuccessAt); + expect(dispatched[loginRequestAt]).toEqual(loginRequest({ resume: RESUME_TOKEN }, false)); + + expect(framesOn(reopened, 'connect').length).toBeGreaterThan(0); + }); +}); + +describe('switching away and straight back to the app', () => { + it('keeps the live connection instead of paying for an avoidable reconnect', async () => { + bootSignedInApp(); + await openSignedInSocket(); + await subscribeToRoom(ROOM_ID); + const alive = mockConnections[0]; + const pingsBefore = framesOn(alive, 'ping').length; + const connectFramesBefore = framesOn(alive, 'connect').length; + const connectionsBefore = mockConnections.length; + dispatched.length = 0; + + store.dispatch({ type: APP_STATE.FOREGROUND }); + await flush(); + await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); + + expect(framesOn(alive, 'ping').length).toBeGreaterThan(pingsBefore); + expect(mockConnections).toHaveLength(connectionsBefore); + expect(framesOn(alive, 'connect')).toHaveLength(connectFramesBefore); + expect(dispatched.map(action => action.type)).not.toContain(connectSuccess().type); + expect(dispatched.map(action => action.type)).not.toContain(loginRequest({ resume: RESUME_TOKEN }, false).type); + }); +}); From d02ffb6332f030f79408ee9e4a6feae3c2e2b654 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 14:14:35 -0300 Subject: [PATCH 2/8] test: share the websocket mock and prove foreground drives the reconnect --- .../__tests__/connect.integration.test.ts | 65 +------- .../services/__tests__/mockWebSocketClient.ts | 73 +++++++++ .../socketHealth.integration.test.ts | 79 ++-------- .../__tests__/appStateMiddleware.test.ts | 2 +- .../foregroundResume.integration.test.ts | 147 ++++++------------ jest.config.js | 3 +- 6 files changed, 149 insertions(+), 220 deletions(-) create mode 100644 app/lib/services/__tests__/mockWebSocketClient.ts diff --git a/app/lib/services/__tests__/connect.integration.test.ts b/app/lib/services/__tests__/connect.integration.test.ts index c9653a43f65..d2eb98059d8 100644 --- a/app/lib/services/__tests__/connect.integration.test.ts +++ b/app/lib/services/__tests__/connect.integration.test.ts @@ -12,56 +12,14 @@ import { updateSettings } from '../../../actions/settings'; import { updatePermission } from '../../../actions/permissions'; import { _activeUsers, _setUserTimer } from '../../methods/setUser'; import type { IApplicationState } from '../../../definitions'; - -interface MockConnection { - send: jest.Mock; - close: jest.Mock; - readyState: number; - onopen: () => void; - onmessage: (event: { data: string }) => void; - onerror: () => void; - onclose: (event?: { code?: number }) => void; -} - -interface WireFrame { - msg: string; - id?: string; - name?: string; - method?: string; - params?: unknown[]; -} - -const mockConnections: MockConnection[] = []; +import { flush, framesOn, mockConnections, resetConnections, type MockConnection } from './mockWebSocketClient'; const DDP_LOGIN_RESULT = { id: 'user-id', token: 'auth-token' }; jest.mock('universal-websocket-client', () => - jest.fn().mockImplementation(() => { - const connection = { - send: jest.fn((data: string) => { - const message = JSON.parse(data) as { msg: string; id?: string; method?: string }; - if (message.msg === 'connect') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); - } else if (message.msg === 'ping') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); - } else if (message.msg === 'sub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'ready', subs: [message.id] }) })); - } else if (message.msg === 'method' && message.method === 'login') { - setImmediate(() => - connection.onmessage({ data: JSON.stringify({ msg: 'result', id: message.id, result: DDP_LOGIN_RESULT }) }) - ); - } - }), - close: jest.fn(), - readyState: 1, - onopen: jest.fn(), - onmessage: jest.fn(), - onerror: jest.fn(), - onclose: jest.fn() - }; - mockConnections.push(connection); - return connection; - }) + require('./mockWebSocketClient').createWebSocketClientMock((frame: { msg: string; id?: string; method?: string }) => + frame.msg === 'method' && frame.method === 'login' ? { msg: 'result', id: frame.id, result: DDP_LOGIN_RESULT } : undefined + ) ); jest.mock('../voip/MediaSessionInstance', () => ({ @@ -148,19 +106,6 @@ function makeReduxStore() { }; } -async function flush(turns = 10) { - for (let i = 0; i < turns; i++) { - await Promise.resolve(); - await jest.advanceTimersByTimeAsync(0); - } -} - -function framesOn(connection: MockConnection, msg: string) { - return connection.send.mock.calls - .map(([data]: [string]) => JSON.parse(data) as WireFrame) - .filter(message => message.msg === msg); -} - function receiveFrame(connection: MockConnection, frame: Record) { connection.onmessage({ data: JSON.stringify(frame) }); } @@ -182,7 +127,7 @@ let collections: Record>; beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - mockConnections.length = 0; + resetConnections(); collections = {}; redux = makeReduxStore(); initStore(redux.store); diff --git a/app/lib/services/__tests__/mockWebSocketClient.ts b/app/lib/services/__tests__/mockWebSocketClient.ts new file mode 100644 index 00000000000..db6642bfe53 --- /dev/null +++ b/app/lib/services/__tests__/mockWebSocketClient.ts @@ -0,0 +1,73 @@ +export interface MockConnection { + send: jest.Mock; + close: jest.Mock; + readyState: number; + onopen: () => void; + onmessage: (event: { data: string }) => void; + onerror: () => void; + onclose: (event?: { code?: number }) => void; +} + +export interface WireFrame { + msg: string; + id?: string; + name?: string; + method?: string; + params?: unknown[]; +} + +export type FrameResponder = (frame: WireFrame) => Record | undefined; + +export const CLOSED = 3; + +export const mockConnections: MockConnection[] = []; + +export function latestConnection() { + return mockConnections[mockConnections.length - 1]; +} + +export function resetConnections() { + mockConnections.length = 0; +} + +function defaultReply(frame: WireFrame) { + if (frame.msg === 'connect') return { msg: 'connected', session: 'session-id' }; + if (frame.msg === 'ping') return { msg: 'pong' }; + if (frame.msg === 'sub') return { msg: 'ready', subs: [frame.id] }; + return undefined; +} + +export function createWebSocketClientMock(respond?: FrameResponder) { + return jest.fn().mockImplementation(() => { + const connection: MockConnection = { + send: jest.fn((data: string) => { + const frame = JSON.parse(data) as WireFrame; + const reply = respond?.(frame) ?? defaultReply(frame); + if (reply) setImmediate(() => connection.onmessage({ data: JSON.stringify(reply) })); + }), + close: jest.fn(), + readyState: 1, + onopen: jest.fn(), + onmessage: jest.fn(), + onerror: jest.fn(), + onclose: jest.fn() + }; + mockConnections.push(connection); + return connection; + }); +} + +export function framesOn(connection: MockConnection, msg: string) { + return connection.send.mock.calls.map(([data]: [string]) => JSON.parse(data) as WireFrame).filter(frame => frame.msg === msg); +} + +export function stopAnsweringFrames(connection: MockConnection) { + connection.send.mockImplementation(() => undefined); +} + +export async function flush(microtaskRounds = 10) { + for (let i = 0; i < microtaskRounds; i++) { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(0); + } +} diff --git a/app/lib/services/__tests__/socketHealth.integration.test.ts b/app/lib/services/__tests__/socketHealth.integration.test.ts index 13fef8a84dc..cc7fc808c53 100644 --- a/app/lib/services/__tests__/socketHealth.integration.test.ts +++ b/app/lib/services/__tests__/socketHealth.integration.test.ts @@ -1,28 +1,21 @@ import sdk from '../sdk'; import { recoverSocket } from '../socketHealth'; +import { + CLOSED, + framesOn, + latestConnection, + mockConnections, + resetConnections, + stopAnsweringFrames +} from './mockWebSocketClient'; + +jest.mock('universal-websocket-client', () => require('./mockWebSocketClient').createWebSocketClientMock()); // eslint-disable-next-line @typescript-eslint/no-var-requires const { Driver } = require('@rocket.chat/sdk/lib/drivers/driver') as { Driver: new (options: { host: string; logger: unknown }) => SdkDriver; }; -interface MockConnection { - send: jest.Mock; - close: jest.Mock; - readyState: number; - onopen: () => void; - onmessage: (event: { data: string }) => void; - onerror: () => void; - onclose: () => void; -} - -interface WireFrame { - msg: string; - id?: string; - name?: string; - params?: string[]; -} - interface SdkDriver { userId: string; pingInterval: number; @@ -38,33 +31,6 @@ interface SdkDriver { }; } -const mockConnections: MockConnection[] = []; - -jest.mock('universal-websocket-client', () => - jest.fn().mockImplementation(() => { - const connection = { - send: jest.fn((data: string) => { - const message = JSON.parse(data) as { msg: string; id?: string }; - if (message.msg === 'connect') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); - } else if (message.msg === 'ping') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); - } else if (message.msg === 'sub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'ready', subs: [message.id] }) })); - } - }), - close: jest.fn(), - readyState: 1, - onopen: jest.fn(), - onmessage: jest.fn(), - onerror: jest.fn(), - onclose: jest.fn() - }; - mockConnections.push(connection); - return connection; - }) -); - jest.mock('../sdk', () => ({ __esModule: true, default: { current: undefined } @@ -72,7 +38,6 @@ jest.mock('../sdk', () => ({ const USER_ID = 'user-id'; const PING_INTERVAL = 10000; -const CLOSED = 3; const logger = { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }; @@ -102,23 +67,13 @@ function backdateLastPing(driver: SdkDriver, ageMs: number) { driver.ddp.lastPing = Date.now() - ageMs; } -function stopAnsweringFrames(connection: MockConnection) { - connection.send.mockImplementation(() => undefined); -} - -function framesOn(connection: MockConnection, msg: string) { - return connection.send.mock.calls - .map(([data]: [string]) => JSON.parse(data) as WireFrame) - .filter(message => message.msg === msg); -} - describe('recoverSocket against the real SDK socket', () => { let driver: SdkDriver; beforeEach(async () => { jest.clearAllMocks(); jest.useFakeTimers(); - mockConnections.length = 0; + resetConnections(); driver = await buildConnectedDriver(); (sdk as unknown as { current: { ddp: SdkDriver } }).current = { ddp: driver }; }); @@ -153,7 +108,7 @@ describe('recoverSocket against the real SDK socket', () => { expect(framesOn(mockConnections[0], 'ping').length).toBeGreaterThan(0); expect(mockConnections).toHaveLength(2); - mockConnections[1].onopen(); + latestConnection().onopen(); await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); @@ -167,7 +122,7 @@ describe('recoverSocket against the real SDK socket', () => { expect(framesOn(mockConnections[0], 'ping').length).toBeGreaterThan(0); expect(mockConnections).toHaveLength(2); - mockConnections[1].onopen(); + latestConnection().onopen(); await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); @@ -182,7 +137,7 @@ describe('recoverSocket against the real SDK socket', () => { expect(mockConnections).toHaveLength(2); expect(framesOn(mockConnections[0], 'ping')).toHaveLength(0); - mockConnections[1].onopen(); + latestConnection().onopen(); await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); @@ -196,7 +151,7 @@ describe('recoverSocket against the real SDK socket', () => { await jest.advanceTimersByTimeAsync(0); expect(mockConnections).toHaveLength(2); - mockConnections[1].onopen(); + latestConnection().onopen(); await jest.advanceTimersByTimeAsync(0); await directReopen; @@ -258,7 +213,7 @@ describe('recoverSocket against the real SDK socket', () => { expect(mockConnections).toHaveLength(2); expect(framesOn(mockConnections[0], 'ping')).toHaveLength(0); - mockConnections[1].onopen(); + latestConnection().onopen(); await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); @@ -313,7 +268,7 @@ describe('recoverSocket against the real SDK socket', () => { await jest.advanceTimersByTimeAsync(0); expect(mockConnections).toHaveLength(2); - mockConnections[1].onopen(); + latestConnection().onopen(); await jest.advanceTimersByTimeAsync(0); await expect(first).resolves.toBe('reopened'); diff --git a/app/lib/store/__tests__/appStateMiddleware.test.ts b/app/lib/store/__tests__/appStateMiddleware.test.ts index 1e76843384c..af043a929ac 100644 --- a/app/lib/store/__tests__/appStateMiddleware.test.ts +++ b/app/lib/store/__tests__/appStateMiddleware.test.ts @@ -28,7 +28,7 @@ function dispatchedTypes(dispatch: jest.Mock) { return dispatch.mock.calls.map(([action]) => action.type); } -describe('app state changes', () => { +describe('appStateMiddleware', () => { beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); diff --git a/app/sagas/__tests__/foregroundResume.integration.test.ts b/app/sagas/__tests__/foregroundResume.integration.test.ts index 7422c796e74..032b1c90249 100644 --- a/app/sagas/__tests__/foregroundResume.integration.test.ts +++ b/app/sagas/__tests__/foregroundResume.integration.test.ts @@ -1,39 +1,21 @@ +jest.unmock('@rocket.chat/sdk'); + import { applyMiddleware, createStore, type AnyAction, type Store } from 'redux'; import createSagaMiddleware from 'redux-saga'; -jest.unmock('@rocket.chat/sdk'); +const USER_ID = 'user-id'; +const RESUME_TOKEN = 'token-abc'; jest.mock('universal-websocket-client', () => - jest.fn().mockImplementation(() => { - const connection = { - send: jest.fn((data: string) => { - const message = JSON.parse(data) as { msg: string; id?: string; method?: string }; - if (message.msg === 'connect') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); - } else if (message.msg === 'ping') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); - } else if (message.msg === 'sub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'ready', subs: [message.id] }) })); - } else if (message.msg === 'unsub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'nosub', id: message.id }) })); - } else if (message.msg === 'method' && message.method === 'login') { - setImmediate(() => - connection.onmessage({ - data: JSON.stringify({ msg: 'result', id: message.id, result: { id: USER_ID, token: RESUME_TOKEN } }) - }) - ); - } - }), - close: jest.fn(), - readyState: 1, - onopen: jest.fn(), - onmessage: jest.fn(), - onerror: jest.fn(), - onclose: jest.fn() - }; - mockConnections.push(connection); - return connection; - }) + require('../../lib/services/__tests__/mockWebSocketClient').createWebSocketClientMock( + (frame: { msg: string; id?: string; method?: string }) => { + if (frame.msg === 'unsub') return { msg: 'nosub', id: frame.id }; + if (frame.msg === 'method' && frame.method === 'login') { + return { msg: 'result', id: frame.id, result: { id: USER_ID, token: RESUME_TOKEN } }; + } + return undefined; + } + ) ); jest.mock('../../lib/methods/helpers/localAuthentication', () => ({ @@ -61,10 +43,6 @@ jest.mock('../../lib/services/voip/MediaSessionStore', () => ({ mediaSessionStore: { getCurrentInstance: jest.fn(() => null) } })); -jest.mock('../../lib/services/voip/isInActiveVoipCall', () => ({ - isInActiveVoipCall: jest.fn(() => false) -})); - jest.mock('../../lib/services/twoFactor', () => ({ twoFactor: jest.fn() })); @@ -102,14 +80,6 @@ jest.mock('../../lib/database/services/Message', () => ({ getMessageById: jest.fn(() => Promise.resolve(null)) })); -jest.mock('../../lib/database/services/Thread', () => ({ - getThreadById: jest.fn() -})); - -jest.mock('../../lib/database/services/ThreadMessage', () => ({ - getThreadMessageById: jest.fn() -})); - jest.mock('../../lib/database', () => ({ __esModule: true, default: { @@ -126,6 +96,7 @@ jest.mock('../../lib/database', () => ({ import RoomSubscription from '../../lib/methods/subscriptions/room'; import databaseModule from '../../lib/database'; import { connect } from '../../lib/services/connect'; +import sdk from '../../lib/services/sdk'; import { loadMissedMessages } from '../../lib/methods/loadMissedMessages'; import { initStore } from '../../lib/store/auxStore'; import { APP_STATE } from '../../actions/actionsTypes'; @@ -137,62 +108,46 @@ import { RootEnum } from '../../definitions'; import reducers from '../../reducers'; import loginRoot from '../login'; import stateRoot from '../state'; - -interface MockConnection { - send: jest.Mock; - close: jest.Mock; - readyState: number; - onopen: () => void; - onmessage: (event: { data: string }) => void; - onerror: () => void; - onclose: (event?: { code?: number }) => void; -} - -interface WireFrame { - msg: string; - id?: string; - name?: string; - params?: unknown[]; -} - -const mockConnections: MockConnection[] = []; +import { + CLOSED, + flush, + framesOn, + latestConnection, + mockConnections, + resetConnections, + stopAnsweringFrames, + type MockConnection +} from '../../lib/services/__tests__/mockWebSocketClient'; const SERVER = 'https://open.rocket.chat'; -const USER_ID = 'user-id'; -const RESUME_TOKEN = 'token-abc'; const ROOM_ID = 'room-rid'; const RECOVERY_WINDOW = 5000; +const LOGIN_REPLY_WINDOW = 100; const database = databaseModule as unknown as { - setActiveDB: jest.Mock; active: { get: jest.Mock; write: jest.Mock; batch: jest.Mock }; }; -async function flush(turns = 10) { - for (let i = 0; i < turns; i++) { - await Promise.resolve(); - await jest.advanceTimersByTimeAsync(0); - } -} +const ROOM_TOPICS = [ + `stream-room-messages:${ROOM_ID}`, + `stream-notify-room:${ROOM_ID}/user-activity`, + `stream-notify-room:${ROOM_ID}/deleteMessage`, + `stream-notify-room:${ROOM_ID}/deleteMessageBulk`, + `stream-notify-room:${ROOM_ID}/messagesRead` +]; -function framesOn(connection: MockConnection, msg: string) { - return connection.send.mock.calls - .map(([data]: [string]) => JSON.parse(data) as WireFrame) - .filter(message => message.msg === msg); +function topicsOn(connection: MockConnection) { + return framesOn(connection, 'sub').map(frame => `${frame.name}:${frame.params?.[0]}`); } -function stopAnsweringFrames(connection: MockConnection) { - connection.send.mockImplementation(() => undefined); +function roomTopicsOn(connection: MockConnection) { + return topicsOn(connection).filter(topic => topic.includes(ROOM_ID)); } function makeCollection(name: string) { return { name, - find: jest.fn(), - query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })), - create: jest.fn(), - prepareCreate: jest.fn(), - schema: { columnArray: [] } + query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })) }; } @@ -216,7 +171,6 @@ function bootSignedInApp() { initStore(store); store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); store.dispatch(selectServerSuccess({ server: SERVER, name: 'open.rocket.chat', version: '6.0.0' })); - return store; } async function openSignedInSocket() { @@ -241,7 +195,7 @@ async function subscribeToRoom(rid: string) { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - mockConnections.length = 0; + resetConnections(); collections = {}; database.active.get.mockReset().mockImplementation((name: string) => (collections[name] ??= makeCollection(name))); database.active.write.mockReset().mockImplementation((fn: () => unknown) => fn()); @@ -259,18 +213,18 @@ beforeEach(() => { }); afterEach(async () => { + sdk.disconnect(); await flush(); jest.useRealTimers(); }); -describe('coming back to a conversation after the phone was locked', () => { +describe('foreground resume over the real SDK socket', () => { it('gets messages flowing again when the socket died silently while away', async () => { bootSignedInApp(); await openSignedInSocket(); await subscribeToRoom(ROOM_ID); const frozen = mockConnections[0]; - const subscribedTopics = framesOn(frozen, 'sub').map(frame => frame.name); - expect(subscribedTopics).toContain('stream-notify-logged'); + expect(roomTopicsOn(frozen)).toEqual(expect.arrayContaining(ROOM_TOPICS)); stopAnsweringFrames(frozen); const pingsBefore = framesOn(frozen, 'ping').length; @@ -289,35 +243,38 @@ describe('coming back to a conversation after the phone was locked', () => { reopened.onopen(); await flush(); + await jest.advanceTimersByTimeAsync(LOGIN_REPLY_WINDOW); await flush(); expect(dispatched).toContainEqual(connectSuccess()); expect(dispatched).toContainEqual(loginRequest({ resume: RESUME_TOKEN }, false)); expect(loadMissedMessages).toHaveBeenCalledWith({ rid: ROOM_ID }); - expect(framesOn(reopened, 'sub').map(frame => frame.name)).toEqual(expect.arrayContaining(['stream-notify-logged'])); + expect(roomTopicsOn(reopened)).toEqual(expect.arrayContaining(ROOM_TOPICS)); }); -}); -describe('coming back after the network dropped while the app was away', () => { - it('lands on a reconnected, still-signed-in app instead of forcing a relaunch', async () => { + it('lands on a reconnected, still-signed-in app instead of forcing a relaunch after the network dropped while away', async () => { bootSignedInApp(); await openSignedInSocket(); const dropped = mockConnections[0]; - dropped.readyState = 3; + dropped.readyState = CLOSED; dropped.onclose({ code: 1006 }); await flush(); expect(dispatched).toContainEqual(disconnect()); dispatched.length = 0; + await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); + expect(mockConnections).toHaveLength(1); + store.dispatch({ type: APP_STATE.FOREGROUND }); await flush(); await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); expect(mockConnections.length).toBeGreaterThan(1); - const reopened = mockConnections[mockConnections.length - 1]; + const reopened = latestConnection(); reopened.onopen(); + expect(dispatched.map(action => action.type)).not.toContain(connectSuccess().type); await flush(); const connectSuccessAt = dispatched.findIndex(action => action.type === connectSuccess().type); @@ -328,10 +285,8 @@ describe('coming back after the network dropped while the app was away', () => { expect(framesOn(reopened, 'connect').length).toBeGreaterThan(0); }); -}); -describe('switching away and straight back to the app', () => { - it('keeps the live connection instead of paying for an avoidable reconnect', async () => { + it('keeps the live connection instead of paying for an avoidable reconnect when switching straight back', async () => { bootSignedInApp(); await openSignedInSocket(); await subscribeToRoom(ROOM_ID); diff --git a/jest.config.js b/jest.config.js index defea788104..3d82d76632b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,7 +5,8 @@ module.exports = { 'node_modules', '/.*worktrees/', '/__tests__/testHelpers\\.tsx$', - '/__tests__/mockedWatermelonDB\\.tsx$' + '/__tests__/mockedWatermelonDB\\.tsx$', + '/__tests__/mockWebSocketClient\\.ts$' ], transformIgnorePatterns: [ 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@rocket.chat/ui-kit|@rocket.chat/sdk|@rocket.chat/message-parser|tiny-events)' From df9ac761c7194b5768823976ea7dc7fe70a510cf Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 15:27:12 -0300 Subject: [PATCH 3/8] test: make the foreground-resume suite prove what it claims Complete the resume-login round trip in the mock harness so the login actually succeeds and the scenarios assert the resumed user, drop the vacuous ordering and app-state assertions, and cover the foreground and background guards plus the session save and away-presence update. --- .../__tests__/appStateMiddleware.test.ts | 8 -- .../foregroundResume.integration.test.ts | 121 ++++++++++++++++-- 2 files changed, 113 insertions(+), 16 deletions(-) diff --git a/app/lib/store/__tests__/appStateMiddleware.test.ts b/app/lib/store/__tests__/appStateMiddleware.test.ts index af043a929ac..1fa42dcf0eb 100644 --- a/app/lib/store/__tests__/appStateMiddleware.test.ts +++ b/app/lib/store/__tests__/appStateMiddleware.test.ts @@ -54,14 +54,6 @@ describe('appStateMiddleware', () => { expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.BACKGROUND]); }); - it('stays silent while the app is only temporarily interrupted', () => { - const { dispatch, notifyAppState } = setupStore(); - - notifyAppState('inactive'); - - expect(dispatchedTypes(dispatch)).toEqual([]); - }); - it('keeps the foreground state through a temporary interruption', () => { const { dispatch, notifyAppState } = setupStore(); diff --git a/app/sagas/__tests__/foregroundResume.integration.test.ts b/app/sagas/__tests__/foregroundResume.integration.test.ts index 0c278117688..b59f455486d 100644 --- a/app/sagas/__tests__/foregroundResume.integration.test.ts +++ b/app/sagas/__tests__/foregroundResume.integration.test.ts @@ -1,3 +1,8 @@ +// What this suite proves: the saga wiring around app-state changes — which actions +// the app dispatches, in which order, and under which guards, over the real SDK +// driver talking to a mock websocket. +// What it does not prove: that the real SDK's resume login behaves the way the +// mock harness answers it. That is a separate question, out of scope here. jest.unmock('@rocket.chat/sdk'); import { applyMiddleware, createStore, type AnyAction, type Store } from 'redux'; @@ -7,7 +12,7 @@ import type * as SdkIntegration from '../../lib/testUtils/sdkIntegration'; import type { MockConnection } from '../../lib/testUtils/sdkIntegration'; const USER_ID = 'user-id'; -const RESUME_TOKEN = 'token-abc'; +const RESUME_TOKEN = 'auth-token'; const CLOSED = 3; const mockConnections: MockConnection[] = []; @@ -117,6 +122,8 @@ import reducers from '../../reducers'; import loginRoot from '../login'; import stateRoot from '../state'; import { flush, framesOn, stopAnsweringFrames } from '../../lib/testUtils/sdkIntegration'; +import { saveLastLocalAuthenticationSession } from '../../lib/methods/helpers/localAuthentication'; +import { setUserPresenceAway } from '../../lib/services/restApi'; const SERVER = 'https://open.rocket.chat'; const ROOM_ID = 'room-rid'; @@ -161,7 +168,7 @@ function recordDispatched() { }; } -function bootSignedInApp() { +function bootApp() { dispatched = []; const sagaMiddleware = createSagaMiddleware(); store = createStore(reducers, applyMiddleware(recordDispatched(), sagaMiddleware)); @@ -172,16 +179,26 @@ function bootSignedInApp() { store.dispatch(selectServerSuccess({ server: SERVER, name: 'open.rocket.chat', version: '6.0.0' })); } -async function openSignedInSocket() { +async function openSocket() { await connect({ server: SERVER }); await flush(); mockConnections[0].onopen(); await flush(); - store.dispatch(loginSuccess({ id: USER_ID, token: RESUME_TOKEN } as never)); store.dispatch(connectSuccess()); await flush(); } +async function openSignedInSocket() { + await openSocket(); + store.dispatch(loginSuccess({ id: USER_ID, token: RESUME_TOKEN } as never)); + await flush(); +} + +function resumedUser() { + const resumed = dispatched.find(action => action.type === loginSuccess({} as never).type); + return resumed?.user; +} + async function subscribeToRoom(rid: string) { const room = new RoomSubscription(rid); const subscribing = room.subscribe(); @@ -219,7 +236,7 @@ afterEach(async () => { describe('foreground resume over the real SDK socket', () => { it('gets messages flowing again when the socket died silently while away', async () => { - bootSignedInApp(); + bootApp(); await openSignedInSocket(); await subscribeToRoom(ROOM_ID); const frozen = mockConnections[0]; @@ -249,10 +266,12 @@ describe('foreground resume over the real SDK socket', () => { expect(dispatched).toContainEqual(loginRequest({ resume: RESUME_TOKEN }, false)); expect(loadMissedMessages).toHaveBeenCalledWith({ rid: ROOM_ID }); expect(roomTopicsOn(reopened)).toEqual(expect.arrayContaining(ROOM_TOPICS)); + expect(resumedUser()).toEqual(expect.objectContaining({ id: USER_ID, token: RESUME_TOKEN, username: 'the-user' })); + expect(store.getState().login.isAuthenticated).toBe(true); }); it('lands on a reconnected, still-signed-in app instead of forcing a relaunch after the network dropped while away', async () => { - bootSignedInApp(); + bootApp(); await openSignedInSocket(); const dropped = mockConnections[0]; @@ -273,7 +292,8 @@ describe('foreground resume over the real SDK socket', () => { const reopened = latestConnection(); reopened.onopen(); - expect(dispatched.map(action => action.type)).not.toContain(connectSuccess().type); + await flush(); + await jest.advanceTimersByTimeAsync(LOGIN_REPLY_WINDOW); await flush(); const connectSuccessAt = dispatched.findIndex(action => action.type === connectSuccess().type); @@ -281,12 +301,14 @@ describe('foreground resume over the real SDK socket', () => { expect(connectSuccessAt).toBeGreaterThanOrEqual(0); expect(loginRequestAt).toBeGreaterThan(connectSuccessAt); expect(dispatched[loginRequestAt]).toEqual(loginRequest({ resume: RESUME_TOKEN }, false)); + expect(resumedUser()).toEqual(expect.objectContaining({ id: USER_ID, token: RESUME_TOKEN, username: 'the-user' })); + expect(store.getState().login.isAuthenticated).toBe(true); expect(framesOn(reopened, 'connect').length).toBeGreaterThan(0); }); it('keeps the live connection instead of paying for an avoidable reconnect when switching straight back', async () => { - bootSignedInApp(); + bootApp(); await openSignedInSocket(); await subscribeToRoom(ROOM_ID); const alive = mockConnections[0]; @@ -305,4 +327,87 @@ describe('foreground resume over the real SDK socket', () => { expect(dispatched.map(action => action.type)).not.toContain(connectSuccess().type); expect(dispatched.map(action => action.type)).not.toContain(loginRequest({ resume: RESUME_TOKEN }, false).type); }); + + it('leaves the socket alone when the app returns to the foreground before anyone is signed in', async () => { + bootApp(); + await openSocket(); + const frozen = mockConnections[0]; + stopAnsweringFrames(frozen); + dispatched.length = 0; + + store.dispatch({ type: APP_STATE.FOREGROUND }); + await flush(); + await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); + + expect(framesOn(frozen, 'ping')).toHaveLength(0); + expect(mockConnections).toHaveLength(1); + expect(dispatched.map(action => action.type)).not.toContain(loginRequest({ resume: RESUME_TOKEN }, false).type); + }); + + it('leaves the socket alone when the app returns to the foreground on the Outside Stack', async () => { + bootApp(); + await openSignedInSocket(); + const frozen = mockConnections[0]; + stopAnsweringFrames(frozen); + store.dispatch(appStart({ root: RootEnum.ROOT_OUTSIDE })); + await flush(); + const pingsBefore = framesOn(frozen, 'ping').length; + dispatched.length = 0; + + store.dispatch({ type: APP_STATE.FOREGROUND }); + await flush(); + await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); + + expect(framesOn(frozen, 'ping')).toHaveLength(pingsBefore); + expect(mockConnections).toHaveLength(1); + expect(dispatched.map(action => action.type)).not.toContain(loginRequest({ resume: RESUME_TOKEN }, false).type); + }); + + it('saves the local authentication session and goes away when the app leaves for the background', async () => { + bootApp(); + await openSignedInSocket(); + + store.dispatch({ type: APP_STATE.BACKGROUND }); + await flush(); + + expect(saveLastLocalAuthenticationSession).toHaveBeenCalledWith(SERVER); + expect(setUserPresenceAway).toHaveBeenCalled(); + }); + + it('stays quiet on the background transition when nobody is signed in', async () => { + bootApp(); + await openSocket(); + + store.dispatch({ type: APP_STATE.BACKGROUND }); + await flush(); + + expect(saveLastLocalAuthenticationSession).not.toHaveBeenCalled(); + expect(setUserPresenceAway).not.toHaveBeenCalled(); + }); + + it('stays quiet on the background transition while the socket is down', async () => { + bootApp(); + await openSignedInSocket(); + store.dispatch(disconnect()); + await flush(); + + store.dispatch({ type: APP_STATE.BACKGROUND }); + await flush(); + + expect(saveLastLocalAuthenticationSession).not.toHaveBeenCalled(); + expect(setUserPresenceAway).not.toHaveBeenCalled(); + }); + + it('stays quiet on the background transition while on the Outside Stack', async () => { + bootApp(); + await openSignedInSocket(); + store.dispatch(appStart({ root: RootEnum.ROOT_OUTSIDE })); + await flush(); + + store.dispatch({ type: APP_STATE.BACKGROUND }); + await flush(); + + expect(saveLastLocalAuthenticationSession).not.toHaveBeenCalled(); + expect(setUserPresenceAway).not.toHaveBeenCalled(); + }); }); From ce45e061a7e786ad154f9ed0a6ca7dc947bf425d Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 15:36:32 -0300 Subject: [PATCH 4/8] test: drop the arbitrary sleep and the assertion that could not fail The resume scenarios waited a fixed 100ms for the login round trip, and closed by asserting isAuthenticated, which openSignedInSocket had already set. The wait is now a bounded drain of the pending timer queue, and the surviving assertion is the post-reset loginSuccess payload. Adds the boot-time app-state dispatch the middleware suite previously discarded, reuses the shared makeCollection and a shared latestConnection, and reverts the socketHealth index-to-helper churn. --- .../socketHealth.integration.test.ts | 16 +++---- .../__tests__/appStateMiddleware.test.ts | 21 +++++++-- app/lib/testUtils/sdkIntegration.ts | 11 +++++ .../foregroundResume.integration.test.ts | 44 +++++-------------- 4 files changed, 47 insertions(+), 45 deletions(-) diff --git a/app/lib/services/__tests__/socketHealth.integration.test.ts b/app/lib/services/__tests__/socketHealth.integration.test.ts index 2966bb8da42..90eadc278e0 100644 --- a/app/lib/services/__tests__/socketHealth.integration.test.ts +++ b/app/lib/services/__tests__/socketHealth.integration.test.ts @@ -28,10 +28,6 @@ const USER_ID = 'user-id'; const PING_INTERVAL = 10000; const CLOSED = 3; -function latestConnection() { - return mockConnections[mockConnections.length - 1]; -} - describe('recoverSocket against the real SDK socket', () => { let driver: ISdkDriver; @@ -73,7 +69,7 @@ describe('recoverSocket against the real SDK socket', () => { expect(framesOn(mockConnections[0], 'ping').length).toBeGreaterThan(0); expect(mockConnections).toHaveLength(2); - latestConnection().onopen(); + mockConnections[1].onopen(); await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); @@ -87,7 +83,7 @@ describe('recoverSocket against the real SDK socket', () => { expect(framesOn(mockConnections[0], 'ping').length).toBeGreaterThan(0); expect(mockConnections).toHaveLength(2); - latestConnection().onopen(); + mockConnections[1].onopen(); await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); @@ -102,7 +98,7 @@ describe('recoverSocket against the real SDK socket', () => { expect(mockConnections).toHaveLength(2); expect(framesOn(mockConnections[0], 'ping')).toHaveLength(0); - latestConnection().onopen(); + mockConnections[1].onopen(); await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); @@ -116,7 +112,7 @@ describe('recoverSocket against the real SDK socket', () => { await jest.advanceTimersByTimeAsync(0); expect(mockConnections).toHaveLength(2); - latestConnection().onopen(); + mockConnections[1].onopen(); await jest.advanceTimersByTimeAsync(0); await directReopen; @@ -178,7 +174,7 @@ describe('recoverSocket against the real SDK socket', () => { expect(mockConnections).toHaveLength(2); expect(framesOn(mockConnections[0], 'ping')).toHaveLength(0); - latestConnection().onopen(); + mockConnections[1].onopen(); await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); @@ -233,7 +229,7 @@ describe('recoverSocket against the real SDK socket', () => { await jest.advanceTimersByTimeAsync(0); expect(mockConnections).toHaveLength(2); - latestConnection().onopen(); + mockConnections[1].onopen(); await jest.advanceTimersByTimeAsync(0); await expect(first).resolves.toBe('reopened'); diff --git a/app/lib/store/__tests__/appStateMiddleware.test.ts b/app/lib/store/__tests__/appStateMiddleware.test.ts index 1fa42dcf0eb..268ba659e3b 100644 --- a/app/lib/store/__tests__/appStateMiddleware.test.ts +++ b/app/lib/store/__tests__/appStateMiddleware.test.ts @@ -14,16 +14,21 @@ import { AppState } from 'react-native'; import applyAppStateMiddleware from '../appStateMiddleware'; import { APP_STATE } from '../../../actions/actionsTypes'; -function setupStore() { +function bootMiddleware() { const dispatch = jest.fn(); const createStore = jest.fn(() => ({ dispatch })); applyAppStateMiddleware()(createStore)(); const [, notifyAppState] = (AppState.addEventListener as jest.Mock).mock.calls[0]; - jest.runOnlyPendingTimers(); - dispatch.mockClear(); return { dispatch, notifyAppState }; } +function setupStore() { + const booted = bootMiddleware(); + jest.runOnlyPendingTimers(); + expect(dispatchedTypes(booted.dispatch)).toEqual([]); + return booted; +} + function dispatchedTypes(dispatch: jest.Mock) { return dispatch.mock.calls.map(([action]) => action.type); } @@ -32,12 +37,22 @@ describe('appStateMiddleware', () => { beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); + AppState.currentState = 'unknown'; }); afterEach(() => { jest.useRealTimers(); }); + it('reports the state the app booted into', () => { + AppState.currentState = 'active'; + const { dispatch } = bootMiddleware(); + + jest.runOnlyPendingTimers(); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.FOREGROUND]); + }); + it('tells the app it came to the foreground', () => { const { dispatch, notifyAppState } = setupStore(); diff --git a/app/lib/testUtils/sdkIntegration.ts b/app/lib/testUtils/sdkIntegration.ts index a08b676cb4e..78d0abec458 100644 --- a/app/lib/testUtils/sdkIntegration.ts +++ b/app/lib/testUtils/sdkIntegration.ts @@ -58,6 +58,10 @@ export interface ISdkDriver { }; } +export function latestConnection(connections: MockConnection[]): MockConnection { + return connections[connections.length - 1]; +} + export function framesOn(connection: MockConnection, msg: string): IDdpMessage[] { return connection.send.mock.calls .map(([frame]: [string]) => JSON.parse(frame) as IDdpMessage) @@ -129,6 +133,13 @@ export async function flush(turns = 10): Promise { } } +export async function settle(rounds = 5): Promise { + for (let i = 0; i < rounds && jest.getTimerCount() > 0; i++) { + await jest.runOnlyPendingTimersAsync(); + await flush(); + } +} + export interface IMockReduxState { meteor: { connected: boolean }; login: { user: Record | null; isAuthenticated: boolean }; diff --git a/app/sagas/__tests__/foregroundResume.integration.test.ts b/app/sagas/__tests__/foregroundResume.integration.test.ts index b59f455486d..f4b859b325e 100644 --- a/app/sagas/__tests__/foregroundResume.integration.test.ts +++ b/app/sagas/__tests__/foregroundResume.integration.test.ts @@ -1,8 +1,3 @@ -// What this suite proves: the saga wiring around app-state changes — which actions -// the app dispatches, in which order, and under which guards, over the real SDK -// driver talking to a mock websocket. -// What it does not prove: that the real SDK's resume login behaves the way the -// mock harness answers it. That is a separate question, out of scope here. jest.unmock('@rocket.chat/sdk'); import { applyMiddleware, createStore, type AnyAction, type Store } from 'redux'; @@ -16,14 +11,6 @@ const RESUME_TOKEN = 'auth-token'; const CLOSED = 3; const mockConnections: MockConnection[] = []; -function latestConnection() { - return mockConnections[mockConnections.length - 1]; -} - -function resetConnections() { - mockConnections.length = 0; -} - jest.mock('universal-websocket-client', () => jest.fn().mockImplementation(() => { const sdkIntegration = jest.requireActual('../../lib/testUtils/sdkIntegration'); @@ -121,14 +108,20 @@ import { RootEnum } from '../../definitions'; import reducers from '../../reducers'; import loginRoot from '../login'; import stateRoot from '../state'; -import { flush, framesOn, stopAnsweringFrames } from '../../lib/testUtils/sdkIntegration'; +import { + flush, + framesOn, + latestConnection, + makeCollection, + settle, + stopAnsweringFrames +} from '../../lib/testUtils/sdkIntegration'; import { saveLastLocalAuthenticationSession } from '../../lib/methods/helpers/localAuthentication'; import { setUserPresenceAway } from '../../lib/services/restApi'; const SERVER = 'https://open.rocket.chat'; const ROOM_ID = 'room-rid'; const RECOVERY_WINDOW = 5000; -const LOGIN_REPLY_WINDOW = 100; const database = databaseModule as unknown as { active: { get: jest.Mock; write: jest.Mock; batch: jest.Mock }; @@ -150,13 +143,6 @@ function roomTopicsOn(connection: MockConnection) { return topicsOn(connection).filter(topic => topic.includes(ROOM_ID)); } -function makeCollection(name: string) { - return { - name, - query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })) - }; -} - let dispatched: AnyAction[]; let store: Store; let collections: Record>; @@ -211,7 +197,7 @@ async function subscribeToRoom(rid: string) { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - resetConnections(); + mockConnections.length = 0; collections = {}; database.active.get.mockReset().mockImplementation((name: string) => (collections[name] ??= makeCollection(name))); database.active.write.mockReset().mockImplementation((fn: () => unknown) => fn()); @@ -258,16 +244,13 @@ describe('foreground resume over the real SDK socket', () => { expect(loadMissedMessages).not.toHaveBeenCalled(); reopened.onopen(); - await flush(); - await jest.advanceTimersByTimeAsync(LOGIN_REPLY_WINDOW); - await flush(); + await settle(); expect(dispatched).toContainEqual(connectSuccess()); expect(dispatched).toContainEqual(loginRequest({ resume: RESUME_TOKEN }, false)); expect(loadMissedMessages).toHaveBeenCalledWith({ rid: ROOM_ID }); expect(roomTopicsOn(reopened)).toEqual(expect.arrayContaining(ROOM_TOPICS)); expect(resumedUser()).toEqual(expect.objectContaining({ id: USER_ID, token: RESUME_TOKEN, username: 'the-user' })); - expect(store.getState().login.isAuthenticated).toBe(true); }); it('lands on a reconnected, still-signed-in app instead of forcing a relaunch after the network dropped while away', async () => { @@ -289,12 +272,10 @@ describe('foreground resume over the real SDK socket', () => { await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); expect(mockConnections.length).toBeGreaterThan(1); - const reopened = latestConnection(); + const reopened = latestConnection(mockConnections); reopened.onopen(); - await flush(); - await jest.advanceTimersByTimeAsync(LOGIN_REPLY_WINDOW); - await flush(); + await settle(); const connectSuccessAt = dispatched.findIndex(action => action.type === connectSuccess().type); const loginRequestAt = dispatched.findIndex(action => action.type === loginRequest({ resume: RESUME_TOKEN }, false).type); @@ -302,7 +283,6 @@ describe('foreground resume over the real SDK socket', () => { expect(loginRequestAt).toBeGreaterThan(connectSuccessAt); expect(dispatched[loginRequestAt]).toEqual(loginRequest({ resume: RESUME_TOKEN }, false)); expect(resumedUser()).toEqual(expect.objectContaining({ id: USER_ID, token: RESUME_TOKEN, username: 'the-user' })); - expect(store.getState().login.isAuthenticated).toBe(true); expect(framesOn(reopened, 'connect').length).toBeGreaterThan(0); }); From 418049cc9cb971caa8fd882661a055177e51e3e7 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 15:40:02 -0300 Subject: [PATCH 5/8] test: wait for the resume to land instead of a fixed number of rounds settle() stopped early only when every timer had drained, which never happens on a connected socket - the SDK keeps its ping interval alive - so it always ran its full round count and was the arbitrary wait it replaced. settleUntil() takes the condition each call site is actually waiting for and keeps the round count as a cap. Also uses latestConnection consistently, names the app-state helper after what it boots, and reads action types through one helper. --- .../__tests__/appStateMiddleware.test.ts | 10 +++---- app/lib/testUtils/sdkIntegration.ts | 4 +-- .../foregroundResume.integration.test.ts | 28 +++++++++++-------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/app/lib/store/__tests__/appStateMiddleware.test.ts b/app/lib/store/__tests__/appStateMiddleware.test.ts index 268ba659e3b..3c064691bb9 100644 --- a/app/lib/store/__tests__/appStateMiddleware.test.ts +++ b/app/lib/store/__tests__/appStateMiddleware.test.ts @@ -22,7 +22,7 @@ function bootMiddleware() { return { dispatch, notifyAppState }; } -function setupStore() { +function bootMiddlewareFromUnknownState() { const booted = bootMiddleware(); jest.runOnlyPendingTimers(); expect(dispatchedTypes(booted.dispatch)).toEqual([]); @@ -54,7 +54,7 @@ describe('appStateMiddleware', () => { }); it('tells the app it came to the foreground', () => { - const { dispatch, notifyAppState } = setupStore(); + const { dispatch, notifyAppState } = bootMiddlewareFromUnknownState(); notifyAppState('active'); @@ -62,7 +62,7 @@ describe('appStateMiddleware', () => { }); it('tells the app it went to the background', () => { - const { dispatch, notifyAppState } = setupStore(); + const { dispatch, notifyAppState } = bootMiddlewareFromUnknownState(); notifyAppState('background'); @@ -70,7 +70,7 @@ describe('appStateMiddleware', () => { }); it('keeps the foreground state through a temporary interruption', () => { - const { dispatch, notifyAppState } = setupStore(); + const { dispatch, notifyAppState } = bootMiddlewareFromUnknownState(); notifyAppState('active'); notifyAppState('inactive'); @@ -80,7 +80,7 @@ describe('appStateMiddleware', () => { }); it('does not repeat the state already in effect', () => { - const { dispatch, notifyAppState } = setupStore(); + const { dispatch, notifyAppState } = bootMiddlewareFromUnknownState(); notifyAppState('background'); notifyAppState('background'); diff --git a/app/lib/testUtils/sdkIntegration.ts b/app/lib/testUtils/sdkIntegration.ts index 78d0abec458..b7037daf998 100644 --- a/app/lib/testUtils/sdkIntegration.ts +++ b/app/lib/testUtils/sdkIntegration.ts @@ -133,8 +133,8 @@ export async function flush(turns = 10): Promise { } } -export async function settle(rounds = 5): Promise { - for (let i = 0; i < rounds && jest.getTimerCount() > 0; i++) { +export async function settleUntil(isSettled: () => boolean, maxRounds = 20): Promise { + for (let round = 0; round < maxRounds && !isSettled(); round++) { await jest.runOnlyPendingTimersAsync(); await flush(); } diff --git a/app/sagas/__tests__/foregroundResume.integration.test.ts b/app/sagas/__tests__/foregroundResume.integration.test.ts index f4b859b325e..c979a4825d2 100644 --- a/app/sagas/__tests__/foregroundResume.integration.test.ts +++ b/app/sagas/__tests__/foregroundResume.integration.test.ts @@ -113,7 +113,7 @@ import { framesOn, latestConnection, makeCollection, - settle, + settleUntil, stopAnsweringFrames } from '../../lib/testUtils/sdkIntegration'; import { saveLastLocalAuthenticationSession } from '../../lib/methods/helpers/localAuthentication'; @@ -135,6 +135,10 @@ const ROOM_TOPICS = [ `stream-notify-room:${ROOM_ID}/messagesRead` ]; +function typeOf(action: AnyAction) { + return action.type; +} + function topicsOn(connection: MockConnection) { return framesOn(connection, 'sub').map(frame => `${frame.name}:${frame.params?.[0]}`); } @@ -181,7 +185,7 @@ async function openSignedInSocket() { } function resumedUser() { - const resumed = dispatched.find(action => action.type === loginSuccess({} as never).type); + const resumed = dispatched.find(action => typeOf(action) === typeOf(loginSuccess({} as never))); return resumed?.user; } @@ -239,12 +243,12 @@ describe('foreground resume over the real SDK socket', () => { expect(framesOn(frozen, 'ping').length).toBeGreaterThan(pingsBefore); expect(mockConnections).toHaveLength(2); - const reopened = mockConnections[1]; + const reopened = latestConnection(mockConnections); expect(loadMissedMessages).not.toHaveBeenCalled(); reopened.onopen(); - await settle(); + await settleUntil(() => resumedUser() !== undefined); expect(dispatched).toContainEqual(connectSuccess()); expect(dispatched).toContainEqual(loginRequest({ resume: RESUME_TOKEN }, false)); @@ -275,10 +279,12 @@ describe('foreground resume over the real SDK socket', () => { const reopened = latestConnection(mockConnections); reopened.onopen(); - await settle(); + await settleUntil(() => resumedUser() !== undefined); - const connectSuccessAt = dispatched.findIndex(action => action.type === connectSuccess().type); - const loginRequestAt = dispatched.findIndex(action => action.type === loginRequest({ resume: RESUME_TOKEN }, false).type); + const connectSuccessAt = dispatched.findIndex(action => typeOf(action) === typeOf(connectSuccess())); + const loginRequestAt = dispatched.findIndex( + action => typeOf(action) === typeOf(loginRequest({ resume: RESUME_TOKEN }, false)) + ); expect(connectSuccessAt).toBeGreaterThanOrEqual(0); expect(loginRequestAt).toBeGreaterThan(connectSuccessAt); expect(dispatched[loginRequestAt]).toEqual(loginRequest({ resume: RESUME_TOKEN }, false)); @@ -304,8 +310,8 @@ describe('foreground resume over the real SDK socket', () => { expect(framesOn(alive, 'ping').length).toBeGreaterThan(pingsBefore); expect(mockConnections).toHaveLength(connectionsBefore); expect(framesOn(alive, 'connect')).toHaveLength(connectFramesBefore); - expect(dispatched.map(action => action.type)).not.toContain(connectSuccess().type); - expect(dispatched.map(action => action.type)).not.toContain(loginRequest({ resume: RESUME_TOKEN }, false).type); + expect(dispatched.map(typeOf)).not.toContain(typeOf(connectSuccess())); + expect(dispatched.map(typeOf)).not.toContain(typeOf(loginRequest({ resume: RESUME_TOKEN }, false))); }); it('leaves the socket alone when the app returns to the foreground before anyone is signed in', async () => { @@ -321,7 +327,7 @@ describe('foreground resume over the real SDK socket', () => { expect(framesOn(frozen, 'ping')).toHaveLength(0); expect(mockConnections).toHaveLength(1); - expect(dispatched.map(action => action.type)).not.toContain(loginRequest({ resume: RESUME_TOKEN }, false).type); + expect(dispatched.map(typeOf)).not.toContain(typeOf(loginRequest({ resume: RESUME_TOKEN }, false))); }); it('leaves the socket alone when the app returns to the foreground on the Outside Stack', async () => { @@ -340,7 +346,7 @@ describe('foreground resume over the real SDK socket', () => { expect(framesOn(frozen, 'ping')).toHaveLength(pingsBefore); expect(mockConnections).toHaveLength(1); - expect(dispatched.map(action => action.type)).not.toContain(loginRequest({ resume: RESUME_TOKEN }, false).type); + expect(dispatched.map(typeOf)).not.toContain(typeOf(loginRequest({ resume: RESUME_TOKEN }, false))); }); it('saves the local authentication session and goes away when the app leaves for the background', async () => { From 8726ae17df7b7dafeaecc234daf30bab28f76cd9 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 15:42:54 -0300 Subject: [PATCH 6/8] test: name the quiet boot instead of asserting it inside a helper bootMiddlewareFromUnknownState asserted a silent boot behind a name that only promised to boot. Booting now always runs the boot timer, and the quiet case is a test of its own. --- .../__tests__/appStateMiddleware.test.ts | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/app/lib/store/__tests__/appStateMiddleware.test.ts b/app/lib/store/__tests__/appStateMiddleware.test.ts index 3c064691bb9..c43eac879ad 100644 --- a/app/lib/store/__tests__/appStateMiddleware.test.ts +++ b/app/lib/store/__tests__/appStateMiddleware.test.ts @@ -19,14 +19,8 @@ function bootMiddleware() { const createStore = jest.fn(() => ({ dispatch })); applyAppStateMiddleware()(createStore)(); const [, notifyAppState] = (AppState.addEventListener as jest.Mock).mock.calls[0]; - return { dispatch, notifyAppState }; -} - -function bootMiddlewareFromUnknownState() { - const booted = bootMiddleware(); jest.runOnlyPendingTimers(); - expect(dispatchedTypes(booted.dispatch)).toEqual([]); - return booted; + return { dispatch, notifyAppState }; } function dispatchedTypes(dispatch: jest.Mock) { @@ -46,15 +40,20 @@ describe('appStateMiddleware', () => { it('reports the state the app booted into', () => { AppState.currentState = 'active'; - const { dispatch } = bootMiddleware(); - jest.runOnlyPendingTimers(); + const { dispatch } = bootMiddleware(); expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.FOREGROUND]); }); + it('stays quiet when the app boots into a state the OS cannot name', () => { + const { dispatch } = bootMiddleware(); + + expect(dispatchedTypes(dispatch)).toEqual([]); + }); + it('tells the app it came to the foreground', () => { - const { dispatch, notifyAppState } = bootMiddlewareFromUnknownState(); + const { dispatch, notifyAppState } = bootMiddleware(); notifyAppState('active'); @@ -62,7 +61,7 @@ describe('appStateMiddleware', () => { }); it('tells the app it went to the background', () => { - const { dispatch, notifyAppState } = bootMiddlewareFromUnknownState(); + const { dispatch, notifyAppState } = bootMiddleware(); notifyAppState('background'); @@ -70,7 +69,7 @@ describe('appStateMiddleware', () => { }); it('keeps the foreground state through a temporary interruption', () => { - const { dispatch, notifyAppState } = bootMiddlewareFromUnknownState(); + const { dispatch, notifyAppState } = bootMiddleware(); notifyAppState('active'); notifyAppState('inactive'); @@ -80,7 +79,7 @@ describe('appStateMiddleware', () => { }); it('does not repeat the state already in effect', () => { - const { dispatch, notifyAppState } = bootMiddlewareFromUnknownState(); + const { dispatch, notifyAppState } = bootMiddleware(); notifyAppState('background'); notifyAppState('background'); From 14b907db7a31c266069af318ec5988592547abf3 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 15:45:04 -0300 Subject: [PATCH 7/8] test: annotate the return types of the new test helpers --- .../store/__tests__/appStateMiddleware.test.ts | 4 ++-- .../foregroundResume.integration.test.ts | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/lib/store/__tests__/appStateMiddleware.test.ts b/app/lib/store/__tests__/appStateMiddleware.test.ts index c43eac879ad..9888b51640f 100644 --- a/app/lib/store/__tests__/appStateMiddleware.test.ts +++ b/app/lib/store/__tests__/appStateMiddleware.test.ts @@ -14,7 +14,7 @@ import { AppState } from 'react-native'; import applyAppStateMiddleware from '../appStateMiddleware'; import { APP_STATE } from '../../../actions/actionsTypes'; -function bootMiddleware() { +function bootMiddleware(): { dispatch: jest.Mock; notifyAppState: (state: string) => void } { const dispatch = jest.fn(); const createStore = jest.fn(() => ({ dispatch })); applyAppStateMiddleware()(createStore)(); @@ -23,7 +23,7 @@ function bootMiddleware() { return { dispatch, notifyAppState }; } -function dispatchedTypes(dispatch: jest.Mock) { +function dispatchedTypes(dispatch: jest.Mock): string[] { return dispatch.mock.calls.map(([action]) => action.type); } diff --git a/app/sagas/__tests__/foregroundResume.integration.test.ts b/app/sagas/__tests__/foregroundResume.integration.test.ts index c979a4825d2..0e2f054e049 100644 --- a/app/sagas/__tests__/foregroundResume.integration.test.ts +++ b/app/sagas/__tests__/foregroundResume.integration.test.ts @@ -135,15 +135,15 @@ const ROOM_TOPICS = [ `stream-notify-room:${ROOM_ID}/messagesRead` ]; -function typeOf(action: AnyAction) { +function typeOf(action: AnyAction): string { return action.type; } -function topicsOn(connection: MockConnection) { +function topicsOn(connection: MockConnection): string[] { return framesOn(connection, 'sub').map(frame => `${frame.name}:${frame.params?.[0]}`); } -function roomTopicsOn(connection: MockConnection) { +function roomTopicsOn(connection: MockConnection): string[] { return topicsOn(connection).filter(topic => topic.includes(ROOM_ID)); } @@ -158,7 +158,7 @@ function recordDispatched() { }; } -function bootApp() { +function bootApp(): void { dispatched = []; const sagaMiddleware = createSagaMiddleware(); store = createStore(reducers, applyMiddleware(recordDispatched(), sagaMiddleware)); @@ -169,7 +169,7 @@ function bootApp() { store.dispatch(selectServerSuccess({ server: SERVER, name: 'open.rocket.chat', version: '6.0.0' })); } -async function openSocket() { +async function openSocket(): Promise { await connect({ server: SERVER }); await flush(); mockConnections[0].onopen(); @@ -178,18 +178,18 @@ async function openSocket() { await flush(); } -async function openSignedInSocket() { +async function openSignedInSocket(): Promise { await openSocket(); store.dispatch(loginSuccess({ id: USER_ID, token: RESUME_TOKEN } as never)); await flush(); } -function resumedUser() { +function resumedUser(): unknown { const resumed = dispatched.find(action => typeOf(action) === typeOf(loginSuccess({} as never))); return resumed?.user; } -async function subscribeToRoom(rid: string) { +async function subscribeToRoom(rid: string): Promise { const room = new RoomSubscription(rid); const subscribing = room.subscribe(); await flush(); From ebd52ef69048bd227170b6bbe5af38dc74dc1efd Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 15:46:24 -0300 Subject: [PATCH 8/8] test: state the boot input in the test that depends on it --- app/lib/store/__tests__/appStateMiddleware.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/lib/store/__tests__/appStateMiddleware.test.ts b/app/lib/store/__tests__/appStateMiddleware.test.ts index 9888b51640f..a6f7c633bee 100644 --- a/app/lib/store/__tests__/appStateMiddleware.test.ts +++ b/app/lib/store/__tests__/appStateMiddleware.test.ts @@ -46,7 +46,9 @@ describe('appStateMiddleware', () => { expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.FOREGROUND]); }); - it('stays quiet when the app boots into a state the OS cannot name', () => { + it('stays quiet when the app boots into an unknown state', () => { + AppState.currentState = 'unknown'; + const { dispatch } = bootMiddleware(); expect(dispatchedTypes(dispatch)).toEqual([]);