Skip to content

Commit 8b70425

Browse files
authored
fix: deeplink auth losing room navigation when entering from outside stack (#7304)
1 parent 2af4bda commit 8b70425

2 files changed

Lines changed: 361 additions & 0 deletions

File tree

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
// ─── Boundary mocks — must appear before any import that triggers the module ───
2+
3+
jest.mock('../../lib/methods/userPreferences', () => ({
4+
__esModule: true,
5+
default: {
6+
getString: jest.fn()
7+
}
8+
}));
9+
10+
jest.mock('../../lib/database/services/Server', () => ({
11+
getServerById: jest.fn()
12+
}));
13+
14+
jest.mock('../../lib/methods/canOpenRoom', () => ({
15+
canOpenRoom: jest.fn()
16+
}));
17+
18+
jest.mock('../../lib/methods/getServerInfo', () => ({
19+
getServerInfo: jest.fn()
20+
}));
21+
22+
jest.mock('../../lib/methods/helpers/goRoom', () => ({
23+
goRoom: jest.fn(),
24+
navigateToRoom: jest.fn()
25+
}));
26+
27+
jest.mock('../../lib/methods/helpers/localAuthentication', () => ({
28+
localAuthenticate: jest.fn()
29+
}));
30+
31+
jest.mock('../../lib/services/connect', () => ({
32+
loginOAuthOrSso: jest.fn()
33+
}));
34+
35+
jest.mock('../../lib/services/sdk', () => ({
36+
__esModule: true,
37+
default: {
38+
current: {
39+
client: {
40+
host: ''
41+
}
42+
}
43+
}
44+
}));
45+
46+
jest.mock('../../lib/services/restApi', () => ({
47+
notifyUser: jest.fn()
48+
}));
49+
50+
jest.mock('../../lib/methods/videoConf', () => ({
51+
videoConfJoin: jest.fn()
52+
}));
53+
54+
jest.mock('../../lib/services/voip/resetVoipState', () => ({
55+
resetVoipState: jest.fn()
56+
}));
57+
58+
jest.mock('../../lib/navigation/appNavigation', () => ({
59+
__esModule: true,
60+
default: {
61+
navigate: jest.fn(),
62+
dispatch: jest.fn(),
63+
getCurrentRoute: jest.fn(),
64+
setParams: jest.fn()
65+
},
66+
waitForNavigationReady: jest.fn(() => Promise.resolve())
67+
}));
68+
69+
jest.mock('i18n-js', () => ({
70+
__esModule: true,
71+
default: { t: (k: string) => k }
72+
}));
73+
74+
// Mock helpers to avoid auxStore (getUidDirectMessage / getRoomTitle call reduxStore.getState())
75+
jest.mock('../../lib/methods/helpers', () => ({
76+
getUidDirectMessage: jest.fn(() => null),
77+
normalizeDeepLinkingServerHost: jest.fn((host: string) => host)
78+
}));
79+
80+
// react-native-callkeep is manually mocked at __mocks__/react-native-callkeep.js
81+
82+
// ─── Real imports (after mocks) ───────────────────────────────────────────────
83+
84+
import { applyMiddleware, createStore } from 'redux';
85+
import createSagaMiddleware from 'redux-saga';
86+
87+
import { deepLinkingOpen } from '../../actions/deepLinking';
88+
import { loginSuccess } from '../../actions/login';
89+
import { selectServerSuccess } from '../../actions/server';
90+
import { appStart } from '../../actions/app';
91+
import { RootEnum } from '../../definitions';
92+
import reducers from '../../reducers';
93+
import deepLinkingRoot from '../deepLinking';
94+
import UserPreferences from '../../lib/methods/userPreferences';
95+
import { getServerById } from '../../lib/database/services/Server';
96+
import { canOpenRoom } from '../../lib/methods/canOpenRoom';
97+
import { getServerInfo } from '../../lib/methods/getServerInfo';
98+
import { goRoom } from '../../lib/methods/helpers/goRoom';
99+
import { waitForNavigationReady } from '../../lib/navigation/appNavigation';
100+
101+
// ─── Helpers ──────────────────────────────────────────────────────────────────
102+
103+
/** Drains pending saga microtasks so all synchronous saga steps complete. */
104+
async function flushSagaMicrotasks(): Promise<void> {
105+
await Promise.resolve();
106+
await Promise.resolve();
107+
}
108+
109+
type PreloadedState = Parameters<typeof createStore>[1];
110+
111+
function setupStore(preloadedState?: PreloadedState) {
112+
const sagaMiddleware = createSagaMiddleware();
113+
const store = createStore(reducers, preloadedState, applyMiddleware(sagaMiddleware));
114+
sagaMiddleware.run(deepLinkingRoot);
115+
return store;
116+
}
117+
118+
// ─── Factories ────────────────────────────────────────────────────────────────
119+
120+
const HOST = 'https://open.rocket.chat';
121+
const TOKEN = 'auth-token-abc';
122+
123+
/** Base deep-link params factory — host only. Extend per test. */
124+
const makeParams = (overrides: Record<string, any> = {}) => ({
125+
host: HOST,
126+
...overrides
127+
});
128+
129+
/** Params for the unknown-server-with-token path. */
130+
const makeParamsWithToken = (overrides: Record<string, any> = {}) =>
131+
makeParams({ token: TOKEN, path: 'channel/general', ...overrides });
132+
133+
/** Server record stub as returned by getServerById / selectServerSuccess. */
134+
const makeServerRecord = (overrides: Record<string, any> = {}) => ({
135+
id: HOST,
136+
version: '6.0.0',
137+
...overrides
138+
});
139+
140+
/** Stored user token stub as returned by UserPreferences.getString(TOKEN_KEY-host). */
141+
const makeStoredUser = () => TOKEN;
142+
143+
// ─── Regression race (new server + token + room path) ──────────────
144+
145+
describe('deepLinking saga — Regression race (new server + token + room path)', () => {
146+
beforeEach(() => {
147+
jest.useFakeTimers();
148+
149+
// Reset all mocks
150+
jest.mocked(UserPreferences.getString).mockReset();
151+
jest.mocked(getServerById).mockReset();
152+
jest.mocked(canOpenRoom).mockReset();
153+
jest.mocked(getServerInfo).mockReset();
154+
jest.mocked(goRoom).mockReset();
155+
jest.mocked(waitForNavigationReady).mockReset();
156+
157+
// Default: unknown server (no current server match, no serverRecord)
158+
// getString(CURRENT_SERVER) → different server, getString(TOKEN_KEY-host) → null
159+
jest.mocked(UserPreferences.getString).mockImplementation((key: string) => {
160+
if (key === 'currentServer') return 'https://other.server.com';
161+
// token for this host — not set (unknown server path)
162+
return null;
163+
});
164+
jest.mocked(getServerById).mockResolvedValue(null);
165+
166+
// getServerInfo succeeds → unknown-server-with-token path
167+
jest.mocked(getServerInfo).mockResolvedValue({ success: true, version: '6.0.0' } as any);
168+
169+
// canOpenRoom returns a room object
170+
jest.mocked(canOpenRoom).mockResolvedValue({ rid: 'room-1', name: 'general', t: 'c' } as any);
171+
172+
// waitForNavigationReady resolves immediately
173+
jest.mocked(waitForNavigationReady).mockResolvedValue(undefined);
174+
175+
// goRoom resolves immediately
176+
jest.mocked(goRoom).mockResolvedValue(undefined);
177+
});
178+
179+
afterEach(() => {
180+
jest.useRealTimers();
181+
});
182+
183+
/**
184+
* Regression positive: full chain, dispatch SERVER.SELECT_SUCCESS,
185+
* LOGIN.SUCCESS, then APP.START(ROOT_INSIDE). Assert goRoom called exactly
186+
* once, sequenced after the APP.START dispatch.
187+
*/
188+
it('calls goRoom exactly once after APP.START(ROOT_INSIDE) completes the chain', async () => {
189+
const store = setupStore();
190+
const params = makeParamsWithToken();
191+
192+
store.dispatch(deepLinkingOpen(params));
193+
await flushSagaMicrotasks();
194+
195+
// Advance past the delay(1000) in the saga
196+
await jest.advanceTimersByTimeAsync(1000);
197+
await flushSagaMicrotasks();
198+
199+
// Saga is now waiting for SERVER.SELECT_SUCCESS
200+
expect(jest.mocked(goRoom)).not.toHaveBeenCalled();
201+
202+
store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
203+
await flushSagaMicrotasks();
204+
205+
// Saga is now waiting for LOGIN.SUCCESS
206+
expect(jest.mocked(goRoom)).not.toHaveBeenCalled();
207+
208+
store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any));
209+
await flushSagaMicrotasks();
210+
211+
// Saga has dispatched appReady and selected state.app.root.
212+
// Root is NOT yet ROOT_INSIDE (reducer hasn't seen ROOT_INSIDE yet),
213+
// so saga is waiting for APP.START(ROOT_INSIDE).
214+
expect(jest.mocked(goRoom)).not.toHaveBeenCalled();
215+
216+
// Now dispatch APP.START(ROOT_INSIDE) — this satisfies the take.
217+
store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE }));
218+
await flushSagaMicrotasks();
219+
await flushSagaMicrotasks();
220+
221+
expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1);
222+
});
223+
224+
/**
225+
* Regression negative: dispatch SERVER.SELECT_SUCCESS, LOGIN.SUCCESS.
226+
* Flush microtasks. Assert goRoom NOT yet called.
227+
* Then dispatch APP.START(ROOT_INSIDE). Flush. Assert goRoom called once.
228+
*/
229+
it('goRoom is NOT called between LOGIN.SUCCESS and APP.START(ROOT_INSIDE)', async () => {
230+
const store = setupStore();
231+
const params = makeParamsWithToken();
232+
233+
store.dispatch(deepLinkingOpen(params));
234+
await flushSagaMicrotasks();
235+
await jest.advanceTimersByTimeAsync(1000);
236+
await flushSagaMicrotasks();
237+
238+
store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
239+
await flushSagaMicrotasks();
240+
241+
store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any));
242+
await flushSagaMicrotasks();
243+
244+
// KEY ASSERTION: goRoom must NOT have been called yet
245+
expect(jest.mocked(goRoom)).not.toHaveBeenCalled();
246+
247+
// Now release the saga by dispatching APP.START(ROOT_INSIDE)
248+
store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE }));
249+
await flushSagaMicrotasks();
250+
await flushSagaMicrotasks();
251+
252+
expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1);
253+
});
254+
255+
/**
256+
* Early-exit branch: the saga selects state.app.root after LOGIN.SUCCESS.
257+
* If root === ROOT_INSIDE at that moment, the take is skipped and goRoom fires
258+
* immediately. We achieve this by dispatching APP.START(ROOT_INSIDE) synchronously
259+
* before flushing, so the reducer updates the root before the saga's select runs.
260+
*/
261+
it('skips the APP.START take when state.app.root is already ROOT_INSIDE at select time', async () => {
262+
const store = setupStore();
263+
const params = makeParamsWithToken();
264+
265+
store.dispatch(deepLinkingOpen(params));
266+
await flushSagaMicrotasks();
267+
await jest.advanceTimersByTimeAsync(1000);
268+
await flushSagaMicrotasks();
269+
270+
store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
271+
await flushSagaMicrotasks();
272+
273+
// Dispatch LOGIN.SUCCESS AND APP.START(ROOT_INSIDE) synchronously before any flush.
274+
// The reducer processes both dispatches before the saga's select runs,
275+
// so the select sees ROOT_INSIDE and skips the take.
276+
store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any));
277+
store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE }));
278+
await flushSagaMicrotasks();
279+
await flushSagaMicrotasks();
280+
281+
// goRoom should fire immediately — the take was skipped by the select short-circuit
282+
expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1);
283+
});
284+
285+
/**
286+
* Wrong-root rejection: dispatch APP.START(ROOT_OUTSIDE) — wrong root.
287+
* Assert goRoom NOT called. Then dispatch APP.START(ROOT_INSIDE). Assert goRoom
288+
* called once.
289+
*/
290+
it('APP.START(ROOT_OUTSIDE) does not satisfy the take; APP.START(ROOT_INSIDE) does', async () => {
291+
const store = setupStore();
292+
const params = makeParamsWithToken();
293+
294+
store.dispatch(deepLinkingOpen(params));
295+
await flushSagaMicrotasks();
296+
await jest.advanceTimersByTimeAsync(1000);
297+
await flushSagaMicrotasks();
298+
299+
store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
300+
await flushSagaMicrotasks();
301+
302+
store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any));
303+
await flushSagaMicrotasks();
304+
305+
// Dispatch wrong root — saga's take predicate filters this out
306+
store.dispatch(appStart({ root: RootEnum.ROOT_OUTSIDE }));
307+
await flushSagaMicrotasks();
308+
309+
// goRoom must NOT have been called
310+
expect(jest.mocked(goRoom)).not.toHaveBeenCalled();
311+
312+
// Now dispatch correct root — satisfies the take
313+
store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE }));
314+
await flushSagaMicrotasks();
315+
await flushSagaMicrotasks();
316+
317+
expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1);
318+
});
319+
320+
/**
321+
* Multiple APP.START: after the take fires once, dispatch a second
322+
* APP.START(ROOT_INSIDE). Assert goRoom still called only once (saga is past
323+
* the take, takeLatest has not been retriggered).
324+
*/
325+
it('a second APP.START(ROOT_INSIDE) after navigation does not re-trigger goRoom', async () => {
326+
const store = setupStore();
327+
const params = makeParamsWithToken();
328+
329+
store.dispatch(deepLinkingOpen(params));
330+
await flushSagaMicrotasks();
331+
await jest.advanceTimersByTimeAsync(1000);
332+
await flushSagaMicrotasks();
333+
334+
store.dispatch(selectServerSuccess({ ...makeServerRecord(), name: 'open.rocket.chat', server: HOST }));
335+
await flushSagaMicrotasks();
336+
337+
store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any));
338+
await flushSagaMicrotasks();
339+
340+
// First APP.START(ROOT_INSIDE) — fires the take
341+
store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE }));
342+
await flushSagaMicrotasks();
343+
await flushSagaMicrotasks();
344+
345+
expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1);
346+
347+
// Second APP.START(ROOT_INSIDE) — saga is done, no re-trigger
348+
store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE }));
349+
await flushSagaMicrotasks();
350+
await flushSagaMicrotasks();
351+
352+
// Still exactly once
353+
expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1);
354+
});
355+
});

app/sagas/deepLinking.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,12 @@ const handleOpen = function* handleOpen({ params }) {
235235
yield put(loginRequest({ resume: params.token }, true));
236236
yield take(types.LOGIN.SUCCESS);
237237
yield put(appReady({}));
238+
// Wait for the login saga's appStart(ROOT_INSIDE) before navigating, so
239+
// InsideStack is mounted and goRoom dispatches into the correct stack.
240+
const currentRoot = yield select(state => state.app.root);
241+
if (currentRoot !== RootEnum.ROOT_INSIDE) {
242+
yield take(action => action.type === types.APP.START && action.root === RootEnum.ROOT_INSIDE);
243+
}
238244
yield completeDeepLinkNavigation(params);
239245
} else {
240246
yield handleInviteLink({ params, requireLogin: true });

0 commit comments

Comments
 (0)