diff --git a/app/lib/store/__tests__/appStateMiddleware.test.ts b/app/lib/store/__tests__/appStateMiddleware.test.ts new file mode 100644 index 00000000000..a6f7c633bee --- /dev/null +++ b/app/lib/store/__tests__/appStateMiddleware.test.ts @@ -0,0 +1,93 @@ +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 bootMiddleware(): { dispatch: jest.Mock; notifyAppState: (state: string) => void } { + const dispatch = jest.fn(); + const createStore = jest.fn(() => ({ dispatch })); + applyAppStateMiddleware()(createStore)(); + const [, notifyAppState] = (AppState.addEventListener as jest.Mock).mock.calls[0]; + jest.runOnlyPendingTimers(); + return { dispatch, notifyAppState }; +} + +function dispatchedTypes(dispatch: jest.Mock): string[] { + return dispatch.mock.calls.map(([action]) => action.type); +} + +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(); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.FOREGROUND]); + }); + + it('stays quiet when the app boots into an unknown state', () => { + AppState.currentState = 'unknown'; + + const { dispatch } = bootMiddleware(); + + expect(dispatchedTypes(dispatch)).toEqual([]); + }); + + it('tells the app it came to the foreground', () => { + const { dispatch, notifyAppState } = bootMiddleware(); + + notifyAppState('active'); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.FOREGROUND]); + }); + + it('tells the app it went to the background', () => { + const { dispatch, notifyAppState } = bootMiddleware(); + + notifyAppState('background'); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.BACKGROUND]); + }); + + it('keeps the foreground state through a temporary interruption', () => { + const { dispatch, notifyAppState } = bootMiddleware(); + + 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 } = bootMiddleware(); + + notifyAppState('background'); + notifyAppState('background'); + notifyAppState('active'); + notifyAppState('active'); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.BACKGROUND, APP_STATE.FOREGROUND]); + }); +}); diff --git a/app/lib/testUtils/sdkIntegration.ts b/app/lib/testUtils/sdkIntegration.ts index a08b676cb4e..b7037daf998 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 settleUntil(isSettled: () => boolean, maxRounds = 20): Promise { + for (let round = 0; round < maxRounds && !isSettled(); round++) { + 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 new file mode 100644 index 00000000000..0e2f054e049 --- /dev/null +++ b/app/sagas/__tests__/foregroundResume.integration.test.ts @@ -0,0 +1,399 @@ +jest.unmock('@rocket.chat/sdk'); + +import { applyMiddleware, createStore, type AnyAction, type Store } from 'redux'; +import createSagaMiddleware from 'redux-saga'; + +import type * as SdkIntegration from '../../lib/testUtils/sdkIntegration'; +import type { MockConnection } from '../../lib/testUtils/sdkIntegration'; + +const USER_ID = 'user-id'; +const RESUME_TOKEN = 'auth-token'; +const CLOSED = 3; +const mockConnections: MockConnection[] = []; + +jest.mock('universal-websocket-client', () => + jest.fn().mockImplementation(() => { + const sdkIntegration = jest.requireActual('../../lib/testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); + }) +); + +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/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', () => ({ + __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 sdk from '../../lib/services/sdk'; +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'; +import { + flush, + framesOn, + latestConnection, + makeCollection, + settleUntil, + 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 database = databaseModule as unknown as { + active: { get: jest.Mock; write: jest.Mock; batch: jest.Mock }; +}; + +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 typeOf(action: AnyAction): string { + return action.type; +} + +function topicsOn(connection: MockConnection): string[] { + return framesOn(connection, 'sub').map(frame => `${frame.name}:${frame.params?.[0]}`); +} + +function roomTopicsOn(connection: MockConnection): string[] { + return topicsOn(connection).filter(topic => topic.includes(ROOM_ID)); +} + +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 bootApp(): void { + 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' })); +} + +async function openSocket(): Promise { + await connect({ server: SERVER }); + await flush(); + mockConnections[0].onopen(); + await flush(); + store.dispatch(connectSuccess()); + await flush(); +} + +async function openSignedInSocket(): Promise { + await openSocket(); + store.dispatch(loginSuccess({ id: USER_ID, token: RESUME_TOKEN } as never)); + await flush(); +} + +function resumedUser(): unknown { + const resumed = dispatched.find(action => typeOf(action) === typeOf(loginSuccess({} as never))); + return resumed?.user; +} + +async function subscribeToRoom(rid: string): Promise { + 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 () => { + sdk.disconnect(); + await flush(); + jest.useRealTimers(); +}); + +describe('foreground resume over the real SDK socket', () => { + it('gets messages flowing again when the socket died silently while away', async () => { + bootApp(); + await openSignedInSocket(); + await subscribeToRoom(ROOM_ID); + const frozen = mockConnections[0]; + expect(roomTopicsOn(frozen)).toEqual(expect.arrayContaining(ROOM_TOPICS)); + + 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 = latestConnection(mockConnections); + + expect(loadMissedMessages).not.toHaveBeenCalled(); + + reopened.onopen(); + await settleUntil(() => resumedUser() !== undefined); + + 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' })); + }); + + it('lands on a reconnected, still-signed-in app instead of forcing a relaunch after the network dropped while away', async () => { + bootApp(); + await openSignedInSocket(); + const dropped = mockConnections[0]; + + 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 = latestConnection(mockConnections); + + reopened.onopen(); + await settleUntil(() => resumedUser() !== undefined); + + 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)); + expect(resumedUser()).toEqual(expect.objectContaining({ id: USER_ID, token: RESUME_TOKEN, username: 'the-user' })); + + expect(framesOn(reopened, 'connect').length).toBeGreaterThan(0); + }); + + it('keeps the live connection instead of paying for an avoidable reconnect when switching straight back', async () => { + bootApp(); + 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(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 () => { + 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(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 () => { + 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(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 () => { + 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(); + }); +});