Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 95 additions & 3 deletions app/sagas/__tests__/deepLinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,15 @@ jest.mock('../../lib/methods/helpers', () => ({
// ─── Real imports (after mocks) ───────────────────────────────────────────────

import { deepLinkingOpen, deepLinkingClickCallPush } from '../../actions/deepLinking';
import { loginSuccess } from '../../actions/login';
import { selectServerSuccess } from '../../actions/server';
import { loginFailure, loginSuccess } from '../../actions/login';
import { selectServerFailure, selectServerSuccess } from '../../actions/server';
import { appStart } from '../../actions/app';
import { APP, SERVER } from '../../actions/actionsTypes';
import { APP, LOGOUT, SERVER } from '../../actions/actionsTypes';
import { RootEnum } from '../../definitions';
import deepLinkingRoot from '../deepLinking';
import UserPreferences from '../../lib/methods/userPreferences';
import { getServerById } from '../../lib/database/services/Server';
import { localAuthenticate } from '../../lib/methods/helpers/localAuthentication';
import { canOpenRoom } from '../../lib/methods/canOpenRoom';
import { getServerInfo } from '../../lib/methods/getServerInfo';
import { goRoom, navigateToRoom } from '../../lib/methods/helpers/goRoom';
Expand Down Expand Up @@ -601,3 +602,94 @@ describe('deepLinking saga — unknown host hands off to the add-server flow', (
emit.mockRestore();
});
});

describe('deepLinking saga — handleShareExtension user-facing roots', () => {
beforeEach(() => {
jest.mocked(UserPreferences.getString).mockReset();
jest.mocked(getServerById).mockReset();
jest.mocked(UserPreferences.getString).mockImplementation((key: string) => {
if (key === 'currentServer') return HOST;
return makeStoredUser();
});
jest.mocked(sdk).current.client.host = '';
});

afterEach(() => {
cancelSagaTasks();
jest.mocked(sdk).current.client.host = '';
});

it('lands on ROOT_OUTSIDE, not the loading root, when the server record is missing', async () => {
jest.mocked(getServerById).mockResolvedValue(null as any);
const { store } = setupStore();

store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any));
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE);
});

it('lands on ROOT_OUTSIDE when the login that the share sheet waits on fails', async () => {
jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any);
const { store } = setupStore();

store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any));
await flushSagaMicrotasks();
expect(store.getState().app.root).toBe(RootEnum.ROOT_LOADING_SHARE_EXTENSION);

store.dispatch(loginFailure({ message: 'connect failed' }));
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE);
});

it('lands on ROOT_OUTSIDE when selecting the server fails while the share sheet waits', async () => {
jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any);
const { store } = setupStore();

store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any));
await flushSagaMicrotasks();

store.dispatch(selectServerFailure());
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE);
});

it('lands on ROOT_OUTSIDE when the server logs the share sheet out instead of failing the login', async () => {
jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any);
const { store } = setupStore();

store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any));
await flushSagaMicrotasks();

store.dispatch({ type: LOGOUT });
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE);
});

it('lands on ROOT_OUTSIDE when local authentication throws', async () => {
jest.mocked(localAuthenticate).mockRejectedValueOnce(new Error('biometrics unavailable'));
jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any);
const { store } = setupStore();

store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any));
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE);
});

it('still reaches ROOT_SHARE_EXTENSION when the login succeeds', async () => {
jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any);
const { store } = setupStore();

store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any));
await flushSagaMicrotasks();

store.dispatch(loginSuccess({ id: 'user-1', token: TOKEN } as any));
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_SHARE_EXTENSION);
});
});
201 changes: 201 additions & 0 deletions app/sagas/__tests__/init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
jest.mock('../../lib/methods/userPreferences', () => ({
__esModule: true,
default: {
getString: jest.fn()
}
}));

jest.mock('../../lib/database/services/Server', () => ({
getServerById: jest.fn()
}));

jest.mock('../../lib/methods/helpers/localAuthentication', () => ({
localAuthenticate: jest.fn()
}));

jest.mock('../../lib/methods/userPreferencesMethods', () => ({
getSortPreferences: jest.fn(() => ({}))
}));

jest.mock('react-native-bootsplash', () => ({
__esModule: true,
default: { hide: jest.fn(() => Promise.resolve()) }
}));

jest.mock('@react-native-async-storage/async-storage', () => ({
__esModule: true,
default: {
getItem: jest.fn(() => Promise.resolve(null)),
removeItem: jest.fn(() => Promise.resolve(null))
}
}));

jest.mock('../../lib/database', () => ({
__esModule: true,
default: {
servers: {
get: jest.fn()
}
}
}));

import RNBootSplash from 'react-native-bootsplash';

import { appInit, appStart } from '../../actions/app';
import { RootEnum } from '../../definitions';
import initRoot from '../init';
import UserPreferences from '../../lib/methods/userPreferences';
import { getServerById } from '../../lib/database/services/Server';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { DEEP_LINKING } from '../../actions/actionsTypes';
import { TOKEN_KEY } from '../../lib/constants/keys';
import database from '../../lib/database';
import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore';
import type { RecordingStore } from '../../lib/testUtils/sagaStore';

const setupStore = (): RecordingStore => createRecordingStore(initRoot);

const HOST = 'https://open.rocket.chat';
const OTHER_HOST = 'https://other.rocket.chat';

describe('init saga — restore user-facing roots', () => {
beforeEach(() => {
jest.mocked(UserPreferences.getString).mockReset();
jest.mocked(getServerById).mockReset();
jest.mocked(RNBootSplash.hide).mockClear();
jest.mocked(AsyncStorage.getItem).mockResolvedValue(null as any);
jest.mocked(AsyncStorage.removeItem).mockClear();
jest.mocked(database.servers.get).mockReset();
jest.mocked(UserPreferences.getString).mockImplementation(() => HOST);
});

afterEach(() => {
cancelSagaTasks();
});

it('lands on ROOT_OUTSIDE and hides the splash when the stored server has no database record', async () => {
jest.mocked(getServerById).mockResolvedValue(null);
const { store } = setupStore();

store.dispatch(appInit());
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE);
expect(jest.mocked(RNBootSplash.hide)).toHaveBeenCalled();
});

it('marks the app ready when the stored server has no database record', async () => {
jest.mocked(getServerById).mockResolvedValue(null);
const { store } = setupStore();

store.dispatch(appInit());
await flushSagaMicrotasks();

expect(store.getState().app.ready).toBe(true);
});

it('lands on ROOT_OUTSIDE when no server is stored at all', async () => {
jest.mocked(UserPreferences.getString).mockImplementation(() => null);
const { store } = setupStore();

store.dispatch(appInit());
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE);
expect(store.getState().app.ready).toBe(true);
});

it('lands on ROOT_OUTSIDE when neither the stored server nor any other has a token', async () => {
jest.mocked(UserPreferences.getString).mockImplementation(key => (key.startsWith(`${TOKEN_KEY}-`) ? null : HOST));
jest.mocked(database.servers.get).mockReturnValue({
query: () => ({ fetch: () => Promise.resolve([{ id: OTHER_HOST, version: '7.0.0' }]) })
} as any);
const { store } = setupStore();

store.dispatch(appInit());
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE);
expect(store.getState().app.ready).toBe(true);
});

it('selects another logged in server with its own version when the stored server has no token', async () => {
jest.mocked(UserPreferences.getString).mockImplementation(key => {
if (key === `${TOKEN_KEY}-${OTHER_HOST}`) return 'token';
if (key.startsWith(`${TOKEN_KEY}-`)) return null;
return HOST;
});
jest.mocked(database.servers.get).mockReturnValue({
query: () => ({ fetch: () => Promise.resolve([{ id: OTHER_HOST, version: '7.0.0' }]) })
} as any);
const { store } = setupStore();

store.dispatch(appInit());
await flushSagaMicrotasks();

expect(store.getState().server.server).toBe(OTHER_HOST);
expect(store.getState().server.version).toBe('7.0.0');
expect(store.getState().app.ready).toBe(true);
});

it('delivers the pending push notification without stranding the boot', async () => {
jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any);
jest.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify({ rid: 'room-1' }) as any);
const { store, dispatchedActions } = setupStore();

store.dispatch(appInit());
await flushSagaMicrotasks();

expect(dispatchedActions).toContainEqual({ type: DEEP_LINKING.OPEN_VIDEO_CONF, params: { rid: 'room-1' } });
expect(store.getState().server.server).toBe(HOST);
});

it('keeps the selected server when the stored push notification payload is malformed', async () => {
jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any);
jest.mocked(AsyncStorage.getItem).mockResolvedValue('not json' as any);
const { store, dispatchedActions } = setupStore();

store.dispatch(appInit());
await flushSagaMicrotasks();

expect(store.getState().server.server).toBe(HOST);
expect(store.getState().app.root).not.toBe(RootEnum.ROOT_OUTSIDE);
expect(dispatchedActions).not.toContainEqual(expect.objectContaining({ type: DEEP_LINKING.OPEN_VIDEO_CONF }));
});

it('delivers the pending push notification even when the root has already moved outside', async () => {
jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any);
jest.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify({ rid: 'room-1' }) as any);
const { store, dispatchedActions } = setupStore();

store.dispatch(appInit());
store.dispatch(appStart({ root: RootEnum.ROOT_OUTSIDE }));
await flushSagaMicrotasks();

expect(dispatchedActions).toContainEqual({ type: DEEP_LINKING.OPEN_VIDEO_CONF, params: { rid: 'room-1' } });
});

it('drops the pending push notification when the boot lands on ROOT_OUTSIDE', async () => {
jest.mocked(getServerById).mockResolvedValue(null);
jest.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify({ rid: 'room-1' }) as any);
const { store, dispatchedActions } = setupStore();

store.dispatch(appInit());
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE);
expect(jest.mocked(AsyncStorage.removeItem)).toHaveBeenCalledWith('pushNotification');
expect(dispatchedActions).not.toContainEqual(expect.objectContaining({ type: DEEP_LINKING.OPEN_VIDEO_CONF }));
});

it('selects the stored server and marks the app ready when the record exists', async () => {
jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any);
const { store } = setupStore();

store.dispatch(appInit());
await flushSagaMicrotasks();

expect(store.getState().app.ready).toBe(true);
expect(store.getState().server.server).toBe(HOST);
});
});
54 changes: 54 additions & 0 deletions app/sagas/__tests__/selectServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ import { settings as RocketChatSettings } from '@rocket.chat/sdk';

import selectServerRoot from '../selectServer';
import { selectServerRequest } from '../../actions/server';
import { appStart } from '../../actions/app';
import { RootEnum } from '../../definitions';
import { SERVER } from '../../actions/actionsTypes';
import UserPreferences from '../../lib/methods/userPreferences';
import { BASIC_AUTH_KEY, setBasicAuth } from '../../lib/methods/helpers/fetch';
Expand Down Expand Up @@ -202,3 +204,55 @@ describe('selectServer saga — version and name fallback', () => {
expect(success).toMatchObject({ server: SERVER_URL, version: '6.9.0', name: 'Stored A' });
});
});

describe('selectServer saga — user-facing root after a failed switch', () => {
beforeEach(() => {
UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID);
jest.mocked(getLoggedUserById).mockRejectedValue(new Error('database unavailable'));
});

it('lands on ROOT_OUTSIDE when the switch fails during boot, before any root is set', async () => {
const { store } = setupStore();
expect(store.getState().app.root).toBeUndefined();
store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false));
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE);
});

it('lands on ROOT_OUTSIDE when the switch fails while the app is on the loading root', async () => {
const { store } = setupStore();
store.dispatch(appStart({ root: RootEnum.ROOT_LOADING }));
store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false));
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE);
});

it('lands on ROOT_OUTSIDE when the switch fails while the share sheet is on its loading root', async () => {
const { store } = setupStore();
store.dispatch(appStart({ root: RootEnum.ROOT_LOADING_SHARE_EXTENSION }));
store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false));
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE);
});

it('keeps the current root when the switch fails while the app is already inside', async () => {
const { store } = setupStore();
store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE }));
store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false));
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_INSIDE);
});

it('keeps the current root when the switch fails while the share sheet is up', async () => {
const { store } = setupStore();
store.dispatch(appStart({ root: RootEnum.ROOT_SHARE_EXTENSION }));
store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false));
await flushSagaMicrotasks();

expect(store.getState().app.root).toBe(RootEnum.ROOT_SHARE_EXTENSION);
});
});
Loading
Loading