Skip to content

Commit f8f6df0

Browse files
abueideclaude
andcommitted
test: add comprehensive E2E and manual testing infrastructure for TAPI backoff
- Add E2E tests with mock server for automated validation - Add manual test app for production validation - Add enhanced mock server with configurable behaviors (429, 500, 400, etc.) - Add detailed test guides and procedures - Add production validation plan with 11-test checklist - Add testing guide covering all three testing layers Co-Authored-By: Claude <[email protected]>
1 parent 972b5f0 commit f8f6df0

File tree

9 files changed

+2175
-2
lines changed

9 files changed

+2175
-2
lines changed

examples/E2E-73/e2e/backoff.e2e.js

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
const {element, by, device} = require('detox');
2+
3+
import {startServer, stopServer, setMockBehavior} from './mockServer';
4+
import {setupMatchers} from './matchers';
5+
6+
describe('#backoffTests', () => {
7+
const mockServerListener = jest.fn();
8+
9+
const trackButton = element(by.id('BUTTON_TRACK'));
10+
const flushButton = element(by.id('BUTTON_FLUSH'));
11+
12+
beforeAll(async () => {
13+
await startServer(mockServerListener);
14+
await device.launchApp();
15+
setupMatchers();
16+
});
17+
18+
beforeEach(async () => {
19+
mockServerListener.mockReset();
20+
setMockBehavior('success'); // Reset to success behavior
21+
await device.reloadReactNative();
22+
});
23+
24+
afterAll(async () => {
25+
await stopServer();
26+
});
27+
28+
describe('429 Rate Limiting', () => {
29+
it('halts upload loop on 429 response', async () => {
30+
// Configure mock to return 429
31+
setMockBehavior('rate-limit', {retryAfter: 10});
32+
33+
// Track multiple events (should create multiple batches)
34+
await trackButton.tap();
35+
await trackButton.tap();
36+
await trackButton.tap();
37+
await trackButton.tap();
38+
await flushButton.tap();
39+
40+
// Should only attempt one batch before halting
41+
expect(mockServerListener).toHaveBeenCalledTimes(1);
42+
43+
const request = mockServerListener.mock.calls[0][0];
44+
expect(request.batch.length).toBeGreaterThan(0);
45+
});
46+
47+
it('blocks future uploads after 429 until retry time passes', async () => {
48+
// First flush returns 429
49+
setMockBehavior('rate-limit', {retryAfter: 5});
50+
51+
await trackButton.tap();
52+
await flushButton.tap();
53+
54+
expect(mockServerListener).toHaveBeenCalledTimes(1);
55+
mockServerListener.mockClear();
56+
57+
// Immediate second flush should be blocked
58+
await trackButton.tap();
59+
await flushButton.tap();
60+
61+
expect(mockServerListener).not.toHaveBeenCalled();
62+
});
63+
64+
it('allows upload after retry-after time passes', async () => {
65+
// First flush returns 429 with 2 second retry
66+
setMockBehavior('rate-limit', {retryAfter: 2});
67+
68+
await trackButton.tap();
69+
await flushButton.tap();
70+
71+
expect(mockServerListener).toHaveBeenCalledTimes(1);
72+
mockServerListener.mockClear();
73+
74+
// Wait for retry-after period
75+
await new Promise(resolve => setTimeout(resolve, 2500));
76+
77+
// Reset to success behavior
78+
setMockBehavior('success');
79+
80+
// Second flush should now work
81+
await trackButton.tap();
82+
await flushButton.tap();
83+
84+
expect(mockServerListener).toHaveBeenCalled();
85+
});
86+
87+
it('resets state after successful upload', async () => {
88+
// First: 429
89+
setMockBehavior('rate-limit', {retryAfter: 1});
90+
await trackButton.tap();
91+
await flushButton.tap();
92+
93+
expect(mockServerListener).toHaveBeenCalledTimes(1);
94+
mockServerListener.mockClear();
95+
96+
// Wait and succeed
97+
await new Promise(resolve => setTimeout(resolve, 1500));
98+
setMockBehavior('success');
99+
await trackButton.tap();
100+
await flushButton.tap();
101+
102+
expect(mockServerListener).toHaveBeenCalled();
103+
mockServerListener.mockClear();
104+
105+
// Third flush should work immediately (no rate limiting)
106+
await trackButton.tap();
107+
await flushButton.tap();
108+
109+
expect(mockServerListener).toHaveBeenCalled();
110+
});
111+
});
112+
113+
describe('Transient Errors', () => {
114+
it('continues to next batch on 500 error', async () => {
115+
// First batch fails with 500, subsequent batches succeed
116+
let callCount = 0;
117+
setMockBehavior('custom', (req, res) => {
118+
callCount++;
119+
if (callCount === 1) {
120+
res.status(500).send({error: 'Internal Server Error'});
121+
} else {
122+
res.status(200).send({mockSuccess: true});
123+
}
124+
});
125+
126+
// Track multiple events to create multiple batches
127+
for (let i = 0; i < 10; i++) {
128+
await trackButton.tap();
129+
}
130+
await flushButton.tap();
131+
132+
// Should try multiple batches (not halt on 500)
133+
expect(mockServerListener.mock.calls.length).toBeGreaterThan(1);
134+
});
135+
136+
it('handles 408 timeout with exponential backoff', async () => {
137+
setMockBehavior('timeout');
138+
139+
await trackButton.tap();
140+
await flushButton.tap();
141+
142+
expect(mockServerListener).toHaveBeenCalledTimes(1);
143+
144+
const request = mockServerListener.mock.calls[0][0];
145+
expect(request.batch).toBeDefined();
146+
});
147+
});
148+
149+
describe('Permanent Errors', () => {
150+
it('drops batch on 400 bad request', async () => {
151+
setMockBehavior('bad-request');
152+
153+
await trackButton.tap();
154+
await flushButton.tap();
155+
156+
expect(mockServerListener).toHaveBeenCalledTimes(1);
157+
158+
mockServerListener.mockClear();
159+
160+
// Reset to success
161+
setMockBehavior('success');
162+
163+
// New events should work (previous batch was dropped)
164+
await trackButton.tap();
165+
await flushButton.tap();
166+
167+
expect(mockServerListener).toHaveBeenCalled();
168+
});
169+
});
170+
171+
describe('Sequential Processing', () => {
172+
it('processes batches sequentially not parallel', async () => {
173+
const timestamps = [];
174+
let processing = false;
175+
176+
setMockBehavior('custom', async (req, res) => {
177+
if (processing) {
178+
// If already processing, this means parallel execution
179+
timestamps.push({time: Date.now(), parallel: true});
180+
} else {
181+
timestamps.push({time: Date.now(), parallel: false});
182+
processing = true;
183+
// Simulate processing delay
184+
await new Promise(resolve => setTimeout(resolve, 100));
185+
processing = false;
186+
}
187+
res.status(200).send({mockSuccess: true});
188+
});
189+
190+
// Track many events to create multiple batches
191+
for (let i = 0; i < 20; i++) {
192+
await trackButton.tap();
193+
}
194+
await flushButton.tap();
195+
196+
// Verify no parallel execution occurred
197+
const parallelCalls = timestamps.filter(t => t.parallel);
198+
expect(parallelCalls).toHaveLength(0);
199+
});
200+
});
201+
202+
describe('HTTP Headers', () => {
203+
it('sends Authorization header with base64 encoded writeKey', async () => {
204+
let capturedHeaders = null;
205+
206+
setMockBehavior('custom', (req, res) => {
207+
capturedHeaders = req.headers;
208+
res.status(200).send({mockSuccess: true});
209+
});
210+
211+
await trackButton.tap();
212+
await flushButton.tap();
213+
214+
expect(capturedHeaders).toBeDefined();
215+
expect(capturedHeaders.authorization).toMatch(/^Basic /);
216+
});
217+
218+
it('sends X-Retry-Count header starting at 0', async () => {
219+
let retryCount = null;
220+
221+
setMockBehavior('custom', (req, res) => {
222+
retryCount = req.headers['x-retry-count'];
223+
res.status(200).send({mockSuccess: true});
224+
});
225+
226+
await trackButton.tap();
227+
await flushButton.tap();
228+
229+
expect(retryCount).toBe('0');
230+
});
231+
232+
it('increments X-Retry-Count on retries', async () => {
233+
const retryCounts = [];
234+
235+
setMockBehavior('custom', (req, res) => {
236+
const count = req.headers['x-retry-count'];
237+
retryCounts.push(count);
238+
239+
if (retryCounts.length === 1) {
240+
// First attempt: return 429
241+
res.status(429).set('Retry-After', '1').send({error: 'Rate Limited'});
242+
} else {
243+
// Retry: return success
244+
res.status(200).send({mockSuccess: true});
245+
}
246+
});
247+
248+
await trackButton.tap();
249+
await flushButton.tap();
250+
251+
// Wait for retry
252+
await new Promise(resolve => setTimeout(resolve, 1500));
253+
await flushButton.tap();
254+
255+
expect(retryCounts[0]).toBe('0');
256+
expect(retryCounts[1]).toBe('1');
257+
});
258+
});
259+
260+
describe('State Persistence', () => {
261+
it('persists rate limit state across app restarts', async () => {
262+
// Trigger 429
263+
setMockBehavior('rate-limit', {retryAfter: 30});
264+
265+
await trackButton.tap();
266+
await flushButton.tap();
267+
268+
expect(mockServerListener).toHaveBeenCalledTimes(1);
269+
mockServerListener.mockClear();
270+
271+
// Restart app
272+
await device.sendToHome();
273+
await device.launchApp({newInstance: true});
274+
275+
// Reset to success
276+
setMockBehavior('success');
277+
278+
// Immediate flush should still be blocked (state persisted)
279+
await flushButton.tap();
280+
281+
// Should not call server (still in WAITING state)
282+
expect(mockServerListener).not.toHaveBeenCalled();
283+
});
284+
});
285+
286+
describe('Legacy Behavior', () => {
287+
it('ignores rate limiting when disabled', async () => {
288+
// This test requires modifying the app config
289+
// For now, just document the expected behavior:
290+
// When httpConfig.rateLimitConfig.enabled = false:
291+
// - 429 responses do not block future uploads
292+
// - No rate limit state is maintained
293+
// - All batches are attempted on every flush
294+
295+
// TODO: Add app configuration method to test this
296+
expect(true).toBe(true); // Placeholder
297+
});
298+
});
299+
});

examples/E2E-73/e2e/mockServer.js

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ const bodyParser = require('body-parser');
44
const port = 9091;
55

66
let server;
7+
let mockBehavior = 'success';
8+
let mockOptions = {};
9+
10+
/**
11+
* Set the behavior of the mock server
12+
* @param {string} behavior - 'success', 'rate-limit', 'timeout', 'bad-request', 'custom'
13+
* @param {object} options - Additional options (e.g., {retryAfter: 10} for rate-limit)
14+
*/
15+
export const setMockBehavior = (behavior, options = {}) => {
16+
mockBehavior = behavior;
17+
mockOptions = options;
18+
console.log(`🔧 Mock behavior set to: ${behavior}`, options);
19+
};
720

821
export const startServer = async mockServerListener => {
922
if (server) {
@@ -17,11 +30,50 @@ export const startServer = async mockServerListener => {
1730

1831
// Handles batch events
1932
app.post('/events', (req, res) => {
20-
console.log(`➡️ Received request`);
33+
console.log(`➡️ Received request with behavior: ${mockBehavior}`);
2134
const body = req.body;
2235
mockServerListener(body);
2336

24-
res.status(200).send({mockSuccess: true});
37+
// Handle different mock behaviors
38+
switch (mockBehavior) {
39+
case 'rate-limit':
40+
const retryAfter = mockOptions.retryAfter || 60;
41+
console.log(`⏱️ Returning 429 with Retry-After: ${retryAfter}s`);
42+
res.status(429).set('Retry-After', retryAfter.toString()).send({
43+
error: 'Too Many Requests',
44+
});
45+
break;
46+
47+
case 'timeout':
48+
console.log(`⏱️ Returning 408 Request Timeout`);
49+
res.status(408).send({error: 'Request Timeout'});
50+
break;
51+
52+
case 'bad-request':
53+
console.log(`❌ Returning 400 Bad Request`);
54+
res.status(400).send({error: 'Bad Request'});
55+
break;
56+
57+
case 'server-error':
58+
console.log(`❌ Returning 500 Internal Server Error`);
59+
res.status(500).send({error: 'Internal Server Error'});
60+
break;
61+
62+
case 'custom':
63+
// Custom handler passed in options
64+
if (typeof mockOptions === 'function') {
65+
mockOptions(req, res);
66+
} else {
67+
res.status(200).send({mockSuccess: true});
68+
}
69+
break;
70+
71+
case 'success':
72+
default:
73+
console.log(`✅ Returning 200 OK`);
74+
res.status(200).send({mockSuccess: true});
75+
break;
76+
}
2577
});
2678

2779
// Handles settings calls
@@ -31,6 +83,23 @@ export const startServer = async mockServerListener => {
3183
integrations: {
3284
'Segment.io': {},
3385
},
86+
httpConfig: {
87+
rateLimitConfig: {
88+
enabled: true,
89+
maxRetryCount: 100,
90+
maxRetryInterval: 300,
91+
maxTotalBackoffDuration: 43200,
92+
},
93+
backoffConfig: {
94+
enabled: true,
95+
maxRetryCount: 100,
96+
baseBackoffInterval: 0.5,
97+
maxBackoffInterval: 300,
98+
maxTotalBackoffDuration: 43200,
99+
jitterPercent: 10,
100+
retryableStatusCodes: [408, 410, 429, 460, 500, 502, 503, 504, 508],
101+
},
102+
},
34103
});
35104
});
36105

0 commit comments

Comments
 (0)