Skip to content

Commit 5328d3f

Browse files
adhorodyskiclaude
andcommitted
Add tests for foreground recovery, reachability confirmation, and stale failure window
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent acd6d9b commit 5328d3f

2 files changed

Lines changed: 132 additions & 0 deletions

File tree

tests/unit/FailureTrackerTest.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,40 @@ describe('FailureTracker', () => {
136136
recordSuccess();
137137
expect(listener).not.toHaveBeenCalled();
138138
});
139+
140+
test('slow-trickle failures trigger sustained failure once both thresholds are met', () => {
141+
jest.useFakeTimers();
142+
const listener = jest.fn();
143+
const unsubscribe = onSustainedFailureChange(listener);
144+
145+
// Trickle failures in slowly — one every 5 seconds
146+
recordFailure(); // failure 1 at t=0
147+
jest.advanceTimersByTime(5000);
148+
recordFailure(); // failure 2 at t=5s
149+
jest.advanceTimersByTime(5000);
150+
// At t=10s, elapsed >= SUSTAINED_FAILURE_WINDOW_MS (10s) and count will reach threshold
151+
recordFailure(); // failure 3 at t=10s
152+
153+
expect(listener).toHaveBeenCalledWith(true);
154+
155+
unsubscribe();
156+
jest.useRealTimers();
157+
});
158+
159+
test('failures arriving just before window threshold do not trigger sustained failure', () => {
160+
jest.useFakeTimers();
161+
const listener = jest.fn();
162+
const unsubscribe = onSustainedFailureChange(listener);
163+
164+
// All 3 failures land within the window — count met but elapsed too short
165+
recordFailure();
166+
jest.advanceTimersByTime(CONST.NETWORK.SUSTAINED_FAILURE_WINDOW_MS - 1);
167+
recordFailure();
168+
recordFailure();
169+
170+
expect(listener).not.toHaveBeenCalledWith(true);
171+
172+
unsubscribe();
173+
jest.useRealTimers();
174+
});
139175
});

tests/unit/ReconnectTest.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,35 @@
11
import Onyx from 'react-native-onyx';
22
import {openApp, reconnectApp} from '@libs/actions/App';
33
import {reconnect} from '@libs/actions/Reconnect';
4+
import type AppStateMonitorType from '@libs/AppStateMonitor';
45
import {flush} from '@libs/Network/SequentialQueue';
6+
import type * as NetworkStateType from '@libs/NetworkState';
57
import {getIsOffline, setHasRadio, setSustainedFailures} from '@libs/NetworkState';
8+
import CONST from '@src/CONST';
69
import ONYXKEYS from '@src/ONYXKEYS';
710
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
811

912
jest.mock('@libs/Log');
1013
jest.mock('@libs/Network/SequentialQueue', () => ({flush: jest.fn()}));
1114
jest.mock('@libs/actions/App', () => ({openApp: jest.fn(), reconnectApp: jest.fn(), confirmReadyToOpenApp: jest.fn()}));
15+
jest.mock('@libs/AppStateMonitor', () => ({
16+
// eslint-disable-next-line @typescript-eslint/naming-convention -- required by Jest for ES module interop
17+
__esModule: true,
18+
default: {
19+
addBecameActiveListener: jest.fn(() => jest.fn()),
20+
},
21+
}));
22+
23+
// Capture the foreground callback registered by Reconnect.ts at module load time.
24+
// Must be extracted before any beforeEach clears mock call history.
25+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- extracting callback captured during module load
26+
const AppStateMonitor: typeof AppStateMonitorType = require('@libs/AppStateMonitor').default;
27+
28+
const firstCall = jest.mocked(AppStateMonitor.addBecameActiveListener).mock.calls.at(0);
29+
if (!firstCall) {
30+
throw new Error('AppStateMonitor.addBecameActiveListener was not called during Reconnect.ts module load');
31+
}
32+
const becameActiveCallback: () => void = firstCall[0];
1233

1334
describe('Reconnect', () => {
1435
beforeAll(() => {
@@ -75,4 +96,79 @@ describe('Reconnect', () => {
7596

7697
expect(jest.mocked(flush)).toHaveBeenCalledTimes(1);
7798
});
99+
100+
test('sustained failure recovery notifies reachability listeners', () => {
101+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- accessing the module for the subscription API
102+
const {onReachabilityConfirmed} = require<typeof NetworkStateType>('@libs/NetworkState');
103+
const listener = jest.fn();
104+
const unsub = onReachabilityConfirmed(listener);
105+
106+
setSustainedFailures(true);
107+
listener.mockClear();
108+
109+
// Fake timers required: setSustainedFailures(false) schedules notifyReconnectListeners
110+
// via setTimeout with random jitter (0–5s). setupAfterEnv restores real timers by default.
111+
jest.useFakeTimers();
112+
setSustainedFailures(false);
113+
jest.advanceTimersByTime(CONST.NETWORK.RECONNECT_STAMPEDE_JITTER_MS);
114+
jest.useRealTimers();
115+
116+
expect(listener).toHaveBeenCalledTimes(1);
117+
unsub();
118+
});
119+
120+
test('reachability confirmed triggers reconnect when sustained failures clear', async () => {
121+
await Onyx.merge(ONYXKEYS.SESSION, {accountID: 1234, email: 'test@test.com'});
122+
await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false);
123+
await waitForBatchedUpdates();
124+
125+
setSustainedFailures(true);
126+
jest.mocked(reconnectApp).mockClear();
127+
128+
// Fake timers required: the jitter setTimeout must be advanced manually
129+
jest.useFakeTimers();
130+
setSustainedFailures(false);
131+
jest.advanceTimersByTime(CONST.NETWORK.RECONNECT_STAMPEDE_JITTER_MS);
132+
jest.useRealTimers();
133+
134+
expect(jest.mocked(reconnectApp)).toHaveBeenCalledTimes(1);
135+
});
136+
137+
test('foreground triggers reconnect and flush when app becomes active while online', async () => {
138+
await Onyx.merge(ONYXKEYS.SESSION, {accountID: 1234, email: 'test@test.com'});
139+
await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false);
140+
await waitForBatchedUpdates();
141+
142+
expect(getIsOffline()).toBe(false);
143+
jest.mocked(reconnectApp).mockClear();
144+
jest.mocked(flush).mockClear();
145+
146+
becameActiveCallback();
147+
148+
expect(jest.mocked(reconnectApp)).toHaveBeenCalledTimes(1);
149+
expect(jest.mocked(flush)).toHaveBeenCalledTimes(1);
150+
});
151+
152+
test('foreground refreshes network state when app becomes active while offline', async () => {
153+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- accessing NetworkState for spy
154+
const NetworkState = require<typeof NetworkStateType>('@libs/NetworkState');
155+
const refreshSpy = jest.spyOn(NetworkState, 'refresh').mockImplementation(() => {});
156+
157+
await Onyx.merge(ONYXKEYS.SESSION, {accountID: 1234, email: 'test@test.com'});
158+
await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false);
159+
await waitForBatchedUpdates();
160+
161+
setHasRadio(false);
162+
expect(getIsOffline()).toBe(true);
163+
164+
refreshSpy.mockClear();
165+
jest.mocked(flush).mockClear();
166+
167+
becameActiveCallback();
168+
169+
expect(refreshSpy).toHaveBeenCalledTimes(1);
170+
expect(jest.mocked(flush)).toHaveBeenCalledTimes(1);
171+
172+
refreshSpy.mockRestore();
173+
});
78174
});

0 commit comments

Comments
 (0)