Skip to content
119 changes: 119 additions & 0 deletions app/lib/notifications/push.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type * as ExpoNotifications from 'expo-notifications';

// Mock the dependencies before importing push.ts
jest.mock('expo-notifications');
jest.mock('../store/auxStore', () => ({
store: {
getState: jest.fn()
}
}));
jest.mock('../services/restApi', () => ({
registerPushToken: jest.fn().mockResolvedValue(undefined)
}));

// Use jest.isolateModules to get a fresh copy of push.ts per test,
// resetting the module-level `configured = false` flag each time.
let pushNotificationConfigure: (onNotification: jest.Mock) => Promise<any>;
let Notifications: typeof ExpoNotifications;
let reduxStore: { getState: jest.Mock };
let registerPushToken: jest.Mock;

const baseState = {
login: { isAuthenticated: false },
server: { version: '8.0.0', server: 'https://open.rocket.chat' },
app: { background: false }
};

// Helper to set up a fresh push module within isolateModules
const setupPushModule = () => {
jest.isolateModules(() => {
jest.doMock('expo-notifications', () => ({
__esModule: true,
getDevicePushTokenAsync: jest.fn(() => Promise.resolve({ data: 'mock-token' })),
getPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'granted' })),
requestPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'granted' })),
setBadgeCountAsync: jest.fn(() => Promise.resolve(true)),
dismissAllNotificationsAsync: jest.fn(() => Promise.resolve()),
setNotificationHandler: jest.fn(),
setNotificationCategoryAsync: jest.fn(() => Promise.resolve()),
addNotificationReceivedListener: jest.fn(() => ({ remove: jest.fn() })),
addNotificationResponseReceivedListener: jest.fn(() => ({ remove: jest.fn() })),
addPushTokenListener: jest.fn(() => ({ remove: jest.fn() })),
getLastNotificationResponse: jest.fn(() => null),
DEFAULT_ACTION_IDENTIFIER: 'expo.modules.notifications.actions.DEFAULT'
}));
jest.doMock('../store/auxStore', () => ({
store: {
getState: jest.fn(() => baseState)
}
}));
jest.doMock('../services/restApi', () => ({
registerPushToken: jest.fn().mockResolvedValue(undefined)
}));

Notifications = require('expo-notifications');
reduxStore = require('../store/auxStore').store;
registerPushToken = require('../services/restApi').registerPushToken;
const pushModule = require('./push');
pushNotificationConfigure = pushModule.pushNotificationConfigure;
});
};

describe('push.ts auth guard', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('Notifications.addPushTokenListener callback', () => {
it('does NOT call registerPushToken when user is not authenticated', async () => {
setupPushModule();
(reduxStore.getState as jest.Mock).mockReturnValue(baseState);

await pushNotificationConfigure(jest.fn());
registerPushToken.mockClear();

const registeredCallbacks = (Notifications.addPushTokenListener as jest.Mock).mock.calls;
expect(registeredCallbacks.length).toBe(1);

const listener = registeredCallbacks[0][0];
await listener({ data: 'refreshed-token' });

// Auth guard should have blocked the call
expect(registerPushToken).not.toHaveBeenCalled();
});
});

describe('Notifications.addPushTokenListener callback (authenticated)', () => {
it('calls registerPushToken when user IS authenticated', async () => {
setupPushModule();
(reduxStore.getState as jest.Mock).mockReturnValue({
...baseState,
login: { isAuthenticated: true }
});

await pushNotificationConfigure(jest.fn());
registerPushToken.mockClear();

const registeredCallbacks = (Notifications.addPushTokenListener as jest.Mock).mock.calls;
expect(registeredCallbacks.length).toBe(1);

const listener = registeredCallbacks[0][0];
await listener({ data: 'refreshed-token' });

expect(registerPushToken).toHaveBeenCalledTimes(1);
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('initial registerForPushNotifications().then(...) branch', () => {
it('does NOT call registerPushToken when user is not authenticated (initial token acquisition)', async () => {
setupPushModule();
(reduxStore.getState as jest.Mock).mockReturnValue(baseState);

// The .then() callback fires after registerForPushNotifications() resolves;
// at that moment isAuthenticated is false, so the guard returns early.
await pushNotificationConfigure(jest.fn());

expect(registerPushToken).not.toHaveBeenCalled();
});
});
});
13 changes: 13 additions & 0 deletions app/lib/notifications/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ export const pushNotificationConfigure = (onNotification: (notification: INotifi
deviceToken = token;
console.log('[push.ts] Registered for push notifications:', token);

// Guard: only register push token if user is authenticated
if (!reduxStore.getState().login.isAuthenticated) {
console.log('[push.ts] Skipping push token registration - user not authenticated');
return;
}

registerPushToken().catch(e => {
console.log('[push.ts] Failed to register push token after initial acquisition:', e);
});
Expand All @@ -196,6 +202,13 @@ export const pushNotificationConfigure = (onNotification: (notification: INotifi
// Listen for token updates (FCM can refresh tokens at any time)
Notifications.addPushTokenListener(tokenData => {
deviceToken = tokenData.data;

// Guard: only register push token if user is authenticated
if (!reduxStore.getState().login.isAuthenticated) {
console.log('[push.ts] Skipping push token re-registration - user not authenticated');
return;
}

registerPushToken().catch(e => {
console.log('[push.ts] Failed to re-register push token after refresh:', e);
});
Expand Down
136 changes: 136 additions & 0 deletions app/lib/services/restApi.registerPushToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { store as reduxStore } from '../store/auxStore';
import sdk from './sdk';
import * as restApi from './restApi';
import NativeVoipModule from '../native/NativeVoip';
import { getDeviceToken } from '../notifications';
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const { registerPushToken } = restApi;

jest.mock('../store/auxStore', () => ({
store: {
getState: jest.fn()
}
}));

jest.mock('./sdk', () => ({
__esModule: true,
default: {
current: {},
methodCallWrapper: jest.fn().mockResolvedValue(undefined),
post: jest.fn().mockResolvedValue({ success: true })
}
}));

jest.mock('react-native-device-info', () => {
const mockFn = jest.fn(() => 'test-value');
return {
__esModule: true,
default: {
getUniqueId: mockFn,
getSystemVersion: mockFn,
getVersion: mockFn,
getBuildNumber: mockFn,
hasNotch: mockFn,
getReadableVersion: mockFn,
getBundleId: mockFn,
getModel: mockFn,
isTablet: mockFn
},
getUniqueId: mockFn,
getSystemVersion: mockFn,
getVersion: mockFn,
getBuildNumber: mockFn,
hasNotch: mockFn,
getReadableVersion: mockFn,
getBundleId: mockFn,
getModel: mockFn,
isTablet: mockFn
};
});

jest.mock('../native/NativeVoip', () => ({
__esModule: true,
default: {
getLastVoipToken: jest.fn(() => '')
}
}));

jest.mock('../notifications', () => ({
getDeviceToken: jest.fn(() => '')
}));

const baseState = {
server: { version: '8.0.0', server: 'https://open.rocket.chat' },
login: { user: { id: 'uid1', token: 'tok1' } }
};

beforeEach(() => {
jest.clearAllMocks();
(restApi as { pendingToken: string }).pendingToken = '';
(restApi as { pendingVoipToken: string }).pendingVoipToken = '';
(reduxStore.getState as jest.Mock).mockReturnValue(baseState);
(sdk.post as jest.Mock).mockResolvedValue({ success: true });
(getDeviceToken as jest.Mock).mockReturnValue('');
(NativeVoipModule.getLastVoipToken as jest.Mock).mockReturnValue('');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('registerPushToken - pendingToken cache', () => {
describe('when sdk.post rejects with 401', () => {
it('sets pendingToken so it can be replayed after auth', async () => {
(getDeviceToken as jest.Mock).mockReturnValue('auth-test-token');
(sdk.post as jest.Mock).mockRejectedValue({ status: 401 });

await registerPushToken();

expect(restApi.pendingToken).toBe('auth-test-token');
expect(restApi.pendingVoipToken).toBe('');
});
});

describe('when sdk.post rejects with 403', () => {
it('sets pendingToken so it can be replayed after auth', async () => {
(getDeviceToken as jest.Mock).mockReturnValue('auth-test-token-403');
(sdk.post as jest.Mock).mockRejectedValue({ status: 403 });

await registerPushToken();

expect(restApi.pendingToken).toBe('auth-test-token-403');
});
});

describe('when sdk.post succeeds', () => {
it('clears pendingToken', async () => {
// First, simulate a failed registration to set pendingToken
(getDeviceToken as jest.Mock).mockReturnValue('first-token');
(sdk.post as jest.Mock).mockRejectedValue({ status: 401 });
await registerPushToken();
expect(restApi.pendingToken).toBe('first-token');

// Now simulate a successful call — pendingToken should be cleared
(sdk.post as jest.Mock).mockResolvedValue({ success: true });
await registerPushToken();

expect(restApi.pendingToken).toBe('');
expect(restApi.pendingVoipToken).toBe('');
});
});

describe('short-circuit guard with pendingToken', () => {
it('does NOT short-circuit when pendingToken is set', async () => {
(getDeviceToken as jest.Mock).mockReturnValue('my-token');

// First call: 401 sets pendingToken
(sdk.post as jest.Mock).mockRejectedValue({ status: 401 });
await registerPushToken();
expect(restApi.pendingToken).toBe('my-token');

// Second call: same token, but pendingToken is set — should NOT short-circuit
// because the short-circuit condition is:
// token === lastToken && voipToken === lastVoipToken && !pendingToken
// Since !pendingToken is false (pendingToken is truthy), it proceeds
(sdk.post as jest.Mock).mockResolvedValue({ success: true });
await registerPushToken();
expect(sdk.post).toHaveBeenCalledTimes(2); // 401 call + success call
});
});
});
16 changes: 14 additions & 2 deletions app/lib/services/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1074,6 +1074,8 @@ export const editMessage = async (message: Pick<IMessage, 'id' | 'msg' | 'rid' |

let lastToken = '';
let lastVoipToken = '';
export let pendingToken = '';
export let pendingVoipToken = '';

type TRegisterPushTokenData = {
id?: string;
Expand All @@ -1091,7 +1093,8 @@ export const registerPushToken = async (): Promise<void> => {
return;
}

if (token === lastToken && voipToken === lastVoipToken) {
// Allow retry when there's a pending token to replay
if (token === lastToken && voipToken === lastVoipToken && !pendingToken) {
return;
}

Expand Down Expand Up @@ -1129,7 +1132,16 @@ export const registerPushToken = async (): Promise<void> => {
await sdk.post('push.token', data);
lastToken = token;
lastVoipToken = voipToken;
} catch (e) {
// Clear pending tokens on success
pendingToken = '';
pendingVoipToken = '';
} catch (e: any) {
// Cache token for replay on 401/403 (user not authenticated yet)
if (e?.status === 401 || e?.status === 403) {
pendingToken = token;
pendingVoipToken = voipToken;
log(`[restApi] Push token registration failed with ${e.status} - caching for replay`);
}
log(e);
}
};
Expand Down
Loading
Loading