nexo/apps/mattermost/app/managers/offline_persistence_manager/index.test.ts

693 lines
29 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppState, type AppStateStatus} from 'react-native';
import {BehaviorSubject} from 'rxjs';
import {wipeServerDatabaseWithRetry, wipeServerFiles} from '@actions/local/ephemeral_mode/wipe';
import {Screens} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import PushNotifications from '@init/push_notifications';
import WebsocketManager from '@managers/websocket_manager';
import {getServer, getServerDisplayName} from '@queries/app/servers';
import {navigateToScreen} from '@screens/navigation';
import {advanceTimers, disableFakeTimers, enableFakeTimers} from '@test/timer_helpers';
import OfflinePersistenceManager from './index';
import type ServersModel from '@typings/database/models/app/servers';
jest.mock('@actions/local/ephemeral_mode/wipe', () => ({
wipeServerDatabaseWithRetry: jest.fn().mockResolvedValue({success: true}),
wipeServerFiles: jest.fn().mockReturnValue({success: true}),
}));
jest.mock('@init/push_notifications', () => ({
__esModule: true,
default: {
removeServerNotifications: jest.fn(),
},
}));
jest.mock('@managers/websocket_manager', () => ({
__esModule: true,
default: {
observeWebsocketState: jest.fn(),
isConnected: jest.fn(),
},
}));
jest.mock('@queries/app/servers', () => {
const actual = jest.requireActual('@queries/app/servers');
return {
getServer: jest.fn(actual.getServer),
getServerDisplayName: jest.fn(),
};
});
jest.mock('@screens/navigation', () => ({
navigateToScreen: jest.fn(),
}));
jest.mock('react-native/Libraries/AppState/AppState');
jest.mock('@utils/log');
describe('OfflinePersistenceManager', () => {
const serverA = 'https://server-a.test';
const serverB = 'https://server-b.test';
const credsA = {serverUrl: serverA, token: 'token-a'} as ServerCredential;
const credsB = {serverUrl: serverB, token: 'token-b'} as ServerCredential;
let wsStates: Record<string, BehaviorSubject<WebsocketConnectedState>>;
let appStateHandlers: Array<(state: AppStateStatus) => void>;
let appStateRemoveSpies: jest.Mock[];
const seedConfigAndRow = async (
url: string,
opts: {
enabled?: boolean;
timeoutSec?: number;
purgeHours?: number;
disconnectedSince?: number;
offlineSince?: number;
lastSeenTime?: number;
},
) => {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(url);
const configs: Array<{id: string; value: string}> = [];
if (opts.enabled !== undefined) {
configs.push({id: 'MobileEphemeralModeEnabled', value: String(opts.enabled)});
}
if (opts.timeoutSec !== undefined) {
configs.push({id: 'MobileEphemeralModeDisconnectionTimeoutSeconds', value: String(opts.timeoutSec)});
}
// Default to a very large purge threshold so disconnection-only tests don't accidentally
// trip the wipe pipeline (which would tear down the server's offline subject).
const purgeHours = opts.purgeHours ?? 24 * 365;
configs.push({id: 'MobileEphemeralModeOfflinePersistenceTimerHours', value: String(purgeHours)});
if (configs.length) {
await operator.handleConfigs({configs, configsToDelete: [], prepareRecordsOnly: false});
}
const systems: Array<{id: string; value: unknown}> = [];
if (opts.disconnectedSince !== undefined) {
systems.push({id: SYSTEM_IDENTIFIERS.DISCONNECTED_SINCE, value: opts.disconnectedSince});
}
if (opts.offlineSince !== undefined) {
systems.push({id: SYSTEM_IDENTIFIERS.OFFLINE_SINCE, value: opts.offlineSince});
}
if (opts.lastSeenTime !== undefined) {
systems.push({id: SYSTEM_IDENTIFIERS.LAST_SEEN_TIME, value: opts.lastSeenTime});
}
if (systems.length) {
await operator.handleSystem({systems, prepareRecordsOnly: false});
}
};
const getPersistedValue = async <T>(url: string, key: string): Promise<T | undefined> => {
const {database} = DatabaseManager.getServerDatabaseAndOperator(url);
try {
const rec = await database.get('System').find(key);
return (rec as unknown as {value: T}).value;
} catch {
return undefined;
}
};
const getPersistedSince = (url: string) => getPersistedValue<number>(url, SYSTEM_IDENTIFIERS.DISCONNECTED_SINCE);
const getPersistedOfflineSince = (url: string) => getPersistedValue<number>(url, SYSTEM_IDENTIFIERS.OFFLINE_SINCE);
const getPersistedLastSeen = (url: string) => getPersistedValue<number>(url, SYSTEM_IDENTIFIERS.LAST_SEEN_TIME);
const setWs = (url: string, state: WebsocketConnectedState) => {
if (wsStates[url]) {
wsStates[url].next(state);
} else {
wsStates[url] = new BehaviorSubject<WebsocketConnectedState>(state);
}
};
const setAppState = (state: AppStateStatus) => {
(AppState as {currentState: AppStateStatus}).currentState = state;
appStateHandlers.forEach((h) => h(state));
};
const updateConfig = async (url: string, patch: {enabled?: boolean; timeoutSec?: number; purgeHours?: number}) => {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(url);
const configs: Array<{id: string; value: string}> = [];
if (patch.enabled !== undefined) {
configs.push({id: 'MobileEphemeralModeEnabled', value: String(patch.enabled)});
}
if (patch.timeoutSec !== undefined) {
configs.push({id: 'MobileEphemeralModeDisconnectionTimeoutSeconds', value: String(patch.timeoutSec)});
}
if (patch.purgeHours !== undefined) {
configs.push({id: 'MobileEphemeralModeOfflinePersistenceTimerHours', value: String(patch.purgeHours)});
}
await operator.handleConfigs({configs, configsToDelete: [], prepareRecordsOnly: false});
};
beforeEach(async () => {
enableFakeTimers();
wsStates = {};
appStateHandlers = [];
appStateRemoveSpies = [];
await DatabaseManager.init([serverA, serverB]);
(WebsocketManager.observeWebsocketState as jest.Mock) = jest.fn((url: string) => {
if (!wsStates[url]) {
wsStates[url] = new BehaviorSubject<WebsocketConnectedState>('not_connected');
}
return wsStates[url].asObservable();
});
(WebsocketManager.isConnected as jest.Mock) = jest.fn(
(url: string) => wsStates[url]?.getValue() === 'connected',
);
(AppState as {currentState: AppStateStatus}).currentState = 'active';
(AppState.addEventListener as jest.Mock).mockImplementation((event: string, handler: (state: AppStateStatus) => void) => {
if (event === 'change') {
appStateHandlers.push(handler);
}
const removeSpy = jest.fn();
appStateRemoveSpies.push(removeSpy);
return {remove: removeSpy};
});
});
afterEach(async () => {
await OfflinePersistenceManager.init([]);
await DatabaseManager.destroyServerDatabase(serverA).catch(() => undefined);
await DatabaseManager.destroyServerDatabase(serverB).catch(() => undefined);
disableFakeTimers();
jest.clearAllMocks();
});
it('initializing with ephemeral mode disabled skips subscriptions and clears any stale disconnected_since', async () => {
await seedConfigAndRow(serverA, {enabled: false, timeoutSec: 10, disconnectedSince: Date.now() - 100});
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
expect(WebsocketManager.observeWebsocketState).not.toHaveBeenCalled();
expect(AppState.addEventListener).not.toHaveBeenCalled();
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(false);
expect(await getPersistedSince(serverA)).toBeUndefined();
});
it('disconnecting with a zero threshold flags the server offline immediately', async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 0});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
setWs(serverA, 'not_connected');
await advanceTimers(0);
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(true);
});
it('disconnecting again after a brief reconnect restarts the timer from the new disconnect time', async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
setWs(serverA, 'not_connected');
await advanceTimers(0);
const firstSince = await getPersistedSince(serverA);
expect(firstSince).toBeGreaterThan(0);
await advanceTimers(5000);
setWs(serverA, 'connected');
await advanceTimers(0);
expect(await getPersistedSince(serverA)).toBeUndefined();
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(false);
setWs(serverA, 'not_connected');
await advanceTimers(0);
const secondSince = await getPersistedSince(serverA);
expect(secondSince).toBeGreaterThan(firstSince!);
await advanceTimers(9000);
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(false);
await advanceTimers(1000);
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(true);
});
it('closing the websocket while backgrounded does not start a timer, and returning to foreground while still disconnected does', async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
setAppState('background');
await advanceTimers(0);
setWs(serverA, 'not_connected');
await advanceTimers(60_000);
expect(await getPersistedSince(serverA)).toBeUndefined();
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(false);
setAppState('active');
await advanceTimers(0);
expect(await getPersistedSince(serverA)).toBeGreaterThan(0);
});
it('resuming after app kill with elapsed time past threshold flags the server offline on init', async () => {
const now = Date.now();
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10, disconnectedSince: now - 20_000});
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(true);
});
it('resuming after app kill with elapsed time below threshold schedules the remaining time', async () => {
const now = Date.now();
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10, disconnectedSince: now - 5000});
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(false);
await advanceTimers(5000);
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(true);
});
it('initializing while already disconnected waits for a real transition before starting a timer', async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('not_connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
expect(await getPersistedSince(serverA)).toBeUndefined();
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(false);
setWs(serverA, 'connected');
await advanceTimers(0);
setWs(serverA, 'not_connected');
await advanceTimers(0);
expect(await getPersistedSince(serverA)).toBeGreaterThan(0);
});
it('enabling ephemeral mode at runtime starts observing, and disabling it afterwards cleans everything up', async () => {
await seedConfigAndRow(serverA, {enabled: false, timeoutSec: 10});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
expect(WebsocketManager.observeWebsocketState).not.toHaveBeenCalled();
await updateConfig(serverA, {enabled: true});
await advanceTimers(0);
expect(WebsocketManager.observeWebsocketState).toHaveBeenCalledWith(serverA);
setWs(serverA, 'not_connected');
await advanceTimers(0);
expect(await getPersistedSince(serverA)).toBeGreaterThan(0);
await updateConfig(serverA, {enabled: false});
await advanceTimers(0);
expect(await getPersistedSince(serverA)).toBeUndefined();
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(false);
await advanceTimers(60_000);
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(false);
});
it('changing the threshold while disconnected reschedules the timer against the persisted disconnect time', async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 60});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
setWs(serverA, 'not_connected');
await advanceTimers(0);
await advanceTimers(30_000);
await updateConfig(serverA, {timeoutSec: 120});
await advanceTimers(0);
// With old threshold would have fired at +30s; new total threshold is 120s
await advanceTimers(30_000);
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(false);
// At a total of 120s elapsed, fires
await advanceTimers(60_000);
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(true);
});
it('disconnecting multiple servers only runs timers for those with ephemeral mode enabled', async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10});
await seedConfigAndRow(serverB, {enabled: false, timeoutSec: 10});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
wsStates[serverB] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA, credsB]);
await advanceTimers(0);
setWs(serverA, 'not_connected');
setWs(serverB, 'not_connected');
await advanceTimers(0);
expect(await getPersistedSince(serverA)).toBeGreaterThan(0);
expect(await getPersistedSince(serverB)).toBeUndefined();
await advanceTimers(10_000);
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(true);
expect(OfflinePersistenceManager.isOffline(serverB)).toBe(false);
});
it('installs the AppState listener once when the first server activates and removes it when the last one deactivates', async () => {
await seedConfigAndRow(serverA, {enabled: false, timeoutSec: 10});
await seedConfigAndRow(serverB, {enabled: false, timeoutSec: 10});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
wsStates[serverB] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA, credsB]);
await advanceTimers(0);
expect(AppState.addEventListener).not.toHaveBeenCalled();
await updateConfig(serverA, {enabled: true});
await advanceTimers(0);
expect(AppState.addEventListener).toHaveBeenCalledTimes(1);
await updateConfig(serverB, {enabled: true});
await advanceTimers(0);
expect(AppState.addEventListener).toHaveBeenCalledTimes(1);
await updateConfig(serverA, {enabled: false});
await advanceTimers(0);
expect(appStateRemoveSpies[0]).not.toHaveBeenCalled();
await updateConfig(serverB, {enabled: false});
await advanceTimers(0);
expect(appStateRemoveSpies[0]).toHaveBeenCalled();
});
it('removing a server tears down its subscriptions and ignores further websocket transitions', async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
setWs(serverA, 'not_connected');
await advanceTimers(0);
OfflinePersistenceManager.removeServer(serverA);
await advanceTimers(0);
setWs(serverA, 'connected');
setWs(serverA, 'not_connected');
await advanceTimers(20_000);
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(false);
});
describe('purge timer', () => {
const ONE_HOUR_MS = 3600_000;
it('flipping the offline flag to true schedules the purge timer without firing', async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10, purgeHours: 1});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
setWs(serverA, 'not_connected');
await advanceTimers(0);
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(false);
await advanceTimers(10_000);
expect(OfflinePersistenceManager.isOffline(serverA)).toBe(true);
expect(await getPersistedOfflineSince(serverA)).toBeDefined();
expect(wipeServerDatabaseWithRetry).not.toHaveBeenCalled();
});
it('purge timer fires after the configured threshold and triggers the wipe', async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10, purgeHours: 1});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
setWs(serverA, 'not_connected');
await advanceTimers(0);
await advanceTimers(10_000);
expect(wipeServerDatabaseWithRetry).not.toHaveBeenCalled();
await advanceTimers(ONE_HOUR_MS);
expect(wipeServerDatabaseWithRetry).toHaveBeenCalledTimes(1);
expect(wipeServerDatabaseWithRetry).toHaveBeenCalledWith(serverA);
});
it('firing the purge is synchronous when the threshold is zero', async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10, purgeHours: 0});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
setWs(serverA, 'not_connected');
await advanceTimers(0);
await advanceTimers(10_000);
expect(wipeServerDatabaseWithRetry).toHaveBeenCalledTimes(1);
});
it('reconnecting before the purge fires cancels the timer and clears persistence', async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10, purgeHours: 1});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
setWs(serverA, 'not_connected');
await advanceTimers(0);
await advanceTimers(10_000);
expect(await getPersistedOfflineSince(serverA)).toBeDefined();
setWs(serverA, 'connected');
await advanceTimers(0);
expect(await getPersistedOfflineSince(serverA)).toBeUndefined();
expect(await getPersistedLastSeen(serverA)).toBeUndefined();
await advanceTimers(ONE_HOUR_MS);
expect(wipeServerDatabaseWithRetry).not.toHaveBeenCalled();
});
it('backward clock jump while offline preserves the remaining time', async () => {
const start = 1_700_000_000_000;
jest.setSystemTime(start);
await seedConfigAndRow(serverA, {
enabled: true,
timeoutSec: 10,
purgeHours: 24,
disconnectedSince: start - ONE_HOUR_MS - 10_000,
offlineSince: start - ONE_HOUR_MS,
lastSeenTime: start - ONE_HOUR_MS,
});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('not_connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
const offlineSinceBefore = await getPersistedOfflineSince(serverA);
expect(offlineSinceBefore).toBe(start - ONE_HOUR_MS);
const halfHour = 30 * 60 * 1000;
jest.setSystemTime(start - halfHour);
setAppState('active');
await advanceTimers(0);
const offlineSinceAfter = await getPersistedOfflineSince(serverA);
expect(offlineSinceAfter).toBe((start - ONE_HOUR_MS) - halfHour);
});
it('changing the purge threshold while offline reschedules against offline_since', async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10, purgeHours: 24});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
setWs(serverA, 'not_connected');
await advanceTimers(0);
await advanceTimers(10_000);
await advanceTimers(ONE_HOUR_MS);
await updateConfig(serverA, {purgeHours: 12});
await advanceTimers(0);
await advanceTimers((11 * ONE_HOUR_MS) - 1000);
expect(wipeServerDatabaseWithRetry).not.toHaveBeenCalled();
await advanceTimers(2000);
expect(wipeServerDatabaseWithRetry).toHaveBeenCalledTimes(1);
});
it('a foreground after the wipe does not re-fire it', async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10, purgeHours: 1});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
setWs(serverA, 'not_connected');
await advanceTimers(0);
await advanceTimers(10_000);
await advanceTimers(ONE_HOUR_MS);
expect(wipeServerDatabaseWithRetry).toHaveBeenCalledTimes(1);
setAppState('background');
setAppState('active');
await advanceTimers(0);
expect(wipeServerDatabaseWithRetry).toHaveBeenCalledTimes(1);
});
});
describe('purge wipe', () => {
const ONE_HOUR_MS = 3600_000;
const otherUrl = 'https://other.test';
const displayName = 'My Server';
let updatePersistenceFlagSpy: jest.SpyInstance;
let getActiveServerUrlSpy: jest.SpyInstance;
const triggerWipeForServerA = async () => {
await seedConfigAndRow(serverA, {enabled: true, timeoutSec: 10, purgeHours: 1});
wsStates[serverA] = new BehaviorSubject<WebsocketConnectedState>('connected');
await OfflinePersistenceManager.init([credsA]);
await advanceTimers(0);
setWs(serverA, 'not_connected');
await advanceTimers(0);
await advanceTimers(10_000);
await advanceTimers(ONE_HOUR_MS);
};
beforeEach(() => {
updatePersistenceFlagSpy = jest.spyOn(DatabaseManager, 'updatePersistenceFlag');
getActiveServerUrlSpy = jest.spyOn(DatabaseManager, 'getActiveServerUrl').mockResolvedValue(serverA);
jest.mocked(getServerDisplayName).mockResolvedValue(displayName);
});
it('wipes the affected server when the purge fires', async () => {
await triggerWipeForServerA();
expect(wipeServerDatabaseWithRetry).toHaveBeenCalledWith(serverA);
});
it('navigates to the data-erased screen as a root reset when the wiped server is active', async () => {
await triggerWipeForServerA();
expect(navigateToScreen).toHaveBeenCalledWith(Screens.DATA_ERASED, {serverUrl: serverA, displayName}, true);
});
it('navigates to the data-erased screen before any data is destroyed', async () => {
await triggerWipeForServerA();
const navigateOrder = jest.mocked(navigateToScreen).mock.invocationCallOrder[0];
const wipeOrder = jest.mocked(wipeServerDatabaseWithRetry).mock.invocationCallOrder[0];
expect(navigateOrder).toBeLessThan(wipeOrder);
});
it('does not navigate when the wiped server is not active', async () => {
getActiveServerUrlSpy.mockResolvedValue(otherUrl);
await triggerWipeForServerA();
expect(navigateToScreen).not.toHaveBeenCalled();
expect(wipeServerDatabaseWithRetry).toHaveBeenCalledWith(serverA);
});
it('sets persistenceFlag to wiped so boot wipe-resumption finds it', async () => {
await triggerWipeForServerA();
expect(updatePersistenceFlagSpy).toHaveBeenCalledTimes(1);
expect(updatePersistenceFlagSpy).toHaveBeenCalledWith(serverA, 'wiped');
});
it('leaves the wiped server with an empty in-memory database so contexts can still mount', async () => {
jest.mocked(wipeServerDatabaseWithRetry).mockImplementationOnce(async (url: string) => {
await DatabaseManager.wipeServerData(url);
return {success: true};
});
await triggerWipeForServerA();
expect(DatabaseManager.serverDatabases[serverA]).toBeDefined();
});
it('removes server push notifications before wiping artifacts', async () => {
await triggerWipeForServerA();
expect(PushNotifications.removeServerNotifications).toHaveBeenCalledWith(serverA);
const pushOrder = jest.mocked(PushNotifications.removeServerNotifications).mock.invocationCallOrder[0];
const wipeFilesOrder = jest.mocked(wipeServerFiles).mock.invocationCallOrder[0];
expect(pushOrder).toBeLessThan(wipeFilesOrder);
});
it('re-attaches the server only after the database wipe completes', async () => {
let resolveDb!: (v: {success: boolean}) => void;
jest.mocked(wipeServerDatabaseWithRetry).mockReturnValueOnce(
new Promise<{success: boolean}>((res) => {
resolveDb = res;
}),
);
await triggerWipeForServerA();
expect(wipeServerDatabaseWithRetry).toHaveBeenCalledWith(serverA);
expect(wipeServerFiles).toHaveBeenCalledWith(serverA);
// addServer hasn't run yet — observeWebsocketState still called only once (from init)
expect(WebsocketManager.observeWebsocketState).toHaveBeenCalledTimes(1);
resolveDb({success: true});
await advanceTimers(0);
expect(WebsocketManager.observeWebsocketState).toHaveBeenCalledTimes(2);
});
});
describe('init wipe-resumption', () => {
it('re-runs the wipe for credentials whose server row is marked with persistenceFlag wiped', async () => {
jest.mocked(getServer).mockImplementation(async (url: string) => {
if (url === serverA) {
return {url: serverA, persistenceFlag: 'wiped'} as ServersModel;
}
return {url, persistenceFlag: ''} as ServersModel;
});
await OfflinePersistenceManager.init([credsA, credsB]);
expect(wipeServerDatabaseWithRetry).toHaveBeenCalledWith(serverA);
expect(wipeServerDatabaseWithRetry).toHaveBeenCalledTimes(1);
expect(wipeServerFiles).toHaveBeenCalledWith(serverA);
expect(wipeServerFiles).toHaveBeenCalledTimes(1);
});
it('does not re-wipe servers whose row has an empty persistenceFlag', async () => {
jest.mocked(getServer).mockResolvedValue({url: serverA, persistenceFlag: ''} as ServersModel);
await OfflinePersistenceManager.init([credsA]);
expect(wipeServerDatabaseWithRetry).not.toHaveBeenCalled();
expect(wipeServerFiles).not.toHaveBeenCalled();
});
});
});