wellnua-lite/contexts/__tests__/VoiceContext.test.tsx
Sergei a1e30939a6 Fix race condition with AbortController in VoiceContext
Problem:
- Multiple rapid calls to sendTranscript() created race conditions
- Old requests continued using local abortController variable
- Responses from superseded requests could still be processed
- Session stop didn't reliably prevent pending responses

Solution:
- Changed abort checks from `abortController.signal.aborted` to
  `abortControllerRef.current !== abortController`
- Ensures request checks if it's still the active one, not just aborted
- Added checks at 4 critical points: before API call, after API call,
  before retry, and after retry

Changes:
- VoiceContext.tsx:268 - Check before initial API call
- VoiceContext.tsx:308 - Check after API response
- VoiceContext.tsx:344 - Check before retry
- VoiceContext.tsx:359 - Check after retry response

Testing:
- Added Jest test configuration
- Added test suite with 5 race condition scenarios
- Added manual testing documentation
- Verified with TypeScript linting (no new errors)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-29 11:03:08 -08:00

305 lines
9.0 KiB
TypeScript

/**
* Tests for VoiceContext race condition fix
*
* These tests verify that the AbortController race condition is properly handled:
* - When a new request supersedes an old one, the old request is properly aborted
* - The old request's response is discarded if it arrives after being superseded
* - Session stop properly cancels in-flight requests
*/
// Mock dependencies before imports
jest.mock('../../services/api', () => ({
api: {
getVoiceApiType: jest.fn(),
getDeploymentId: jest.fn(),
setVoiceApiType: jest.fn(),
},
}));
jest.mock('expo-speech', () => ({
speak: jest.fn(),
stop: jest.fn(),
isSpeakingAsync: jest.fn(),
}));
jest.mock('../VoiceTranscriptContext', () => ({
useVoiceTranscript: () => ({
addTranscriptEntry: jest.fn(),
}),
VoiceTranscriptProvider: ({ children }: any) => children,
}));
// Mock fetch
global.fetch = jest.fn();
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { VoiceProvider, useVoice } from '../VoiceContext';
import { api } from '../../services/api';
import * as Speech from 'expo-speech';
import React from 'react';
describe('VoiceContext - AbortController Race Condition', () => {
beforeEach(() => {
jest.clearAllMocks();
(api.getVoiceApiType as jest.Mock).mockResolvedValue('ask_wellnuo_ai');
(api.getDeploymentId as jest.Mock).mockResolvedValue('21');
(Speech.speak as jest.Mock).mockImplementation((text, options) => {
setTimeout(() => options?.onDone?.(), 0);
});
});
const mockApiResponse = (token: string, responseText: string, delay = 0) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
json: async () => ({
ok: true,
response: { body: responseText },
}),
});
}, delay);
});
};
const mockTokenResponse = () => ({
json: async () => ({
status: '200 OK',
access_token: 'test-token',
}),
});
it('should abort old request when new request comes in', async () => {
const abortedRequests: AbortSignal[] = [];
(global.fetch as jest.Mock).mockImplementation((url, options) => {
const signal = options?.signal;
if (signal) {
abortedRequests.push(signal);
}
// First call - token request
if (url.includes('function=credentials')) {
return Promise.resolve(mockTokenResponse());
}
// Subsequent calls - API requests
return mockApiResponse('test-token', 'Response', 100);
});
const { result } = renderHook(() => useVoice(), {
wrapper: ({ children }) => <VoiceProvider>{children}</VoiceProvider>,
});
await act(async () => {
result.current.startSession();
});
// Send first request
act(() => {
result.current.sendTranscript('First message');
});
// Wait a bit, then send second request (should abort first)
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
result.current.sendTranscript('Second message');
});
// Wait for requests to complete
await waitFor(() => {
expect(abortedRequests.length).toBeGreaterThan(0);
}, { timeout: 3000 });
// Verify that at least one request signal was aborted
const hasAbortedSignal = abortedRequests.some(signal => signal.aborted);
expect(hasAbortedSignal).toBe(true);
});
it('should discard response from superseded request', async () => {
let requestCount = 0;
const responses = ['First response', 'Second response'];
(global.fetch as jest.Mock).mockImplementation((url, options) => {
// Token request
if (url.includes('function=credentials')) {
return Promise.resolve(mockTokenResponse());
}
// API requests - first one is slower
const currentRequest = requestCount++;
const delay = currentRequest === 0 ? 200 : 50; // First request is slower
return mockApiResponse('test-token', responses[currentRequest], delay);
});
const { result } = renderHook(() => useVoice(), {
wrapper: ({ children }) => <VoiceProvider>{children}</VoiceProvider>,
});
await act(async () => {
result.current.startSession();
});
// Send first request (will be slow)
const firstPromise = act(async () => {
return await result.current.sendTranscript('First message');
});
// Immediately send second request (will be fast and supersede first)
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 10));
await result.current.sendTranscript('Second message');
});
await firstPromise;
// Wait for all promises to settle
await waitFor(() => {
// The lastResponse should be from the second request only
// because the first request's response should be discarded
expect(result.current.lastResponse).toBe('Second response');
}, { timeout: 3000 });
});
it('should abort request when session is stopped', async () => {
let abortedSignal: AbortSignal | null = null;
(global.fetch as jest.Mock).mockImplementation((url, options) => {
const signal = options?.signal;
if (signal && !url.includes('function=credentials')) {
abortedSignal = signal;
}
// Token request
if (url.includes('function=credentials')) {
return Promise.resolve(mockTokenResponse());
}
// API request - slow
return mockApiResponse('test-token', 'Response', 200);
});
const { result } = renderHook(() => useVoice(), {
wrapper: ({ children }) => <VoiceProvider>{children}</VoiceProvider>,
});
await act(async () => {
result.current.startSession();
});
// Send request
act(() => {
result.current.sendTranscript('Test message');
});
// Stop session while request is in flight
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
result.current.stopSession();
});
// Wait a bit for abort to process
await waitFor(() => {
expect(abortedSignal?.aborted).toBe(true);
}, { timeout: 3000 });
// Session should be idle
expect(result.current.status).toBe('idle');
});
it('should not speak response if session stopped during API call', async () => {
(global.fetch as jest.Mock).mockImplementation((url, options) => {
// Token request
if (url.includes('function=credentials')) {
return Promise.resolve(mockTokenResponse());
}
// API request - slow
return mockApiResponse('test-token', 'Response text', 100);
});
const { result } = renderHook(() => useVoice(), {
wrapper: ({ children }) => <VoiceProvider>{children}</VoiceProvider>,
});
await act(async () => {
result.current.startSession();
});
// Send request
const transcriptPromise = act(async () => {
return await result.current.sendTranscript('Test message');
});
// Stop session while API call is in flight
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 30));
result.current.stopSession();
});
await transcriptPromise;
// Wait for any pending operations
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 200));
});
// Speech.speak should not have been called with the response
// (might be called with error message, but not with "Response text")
const speakCalls = (Speech.speak as jest.Mock).mock.calls;
const hasResponseText = speakCalls.some(call => call[0] === 'Response text');
expect(hasResponseText).toBe(false);
});
it('should handle retry with proper abort controller check', async () => {
let requestCount = 0;
(global.fetch as jest.Mock).mockImplementation((url, options) => {
// Token request
if (url.includes('function=credentials')) {
return Promise.resolve(mockTokenResponse());
}
// First API request - return 401 to trigger retry
if (requestCount === 0) {
requestCount++;
return Promise.resolve({
json: async () => ({
status: '401 Unauthorized',
ok: false,
}),
});
}
// Retry request - slow
return mockApiResponse('test-token', 'Retry response', 100);
});
const { result } = renderHook(() => useVoice(), {
wrapper: ({ children }) => <VoiceProvider>{children}</VoiceProvider>,
});
await act(async () => {
result.current.startSession();
});
// Send first request (will trigger retry)
const firstPromise = act(async () => {
return await result.current.sendTranscript('First message');
});
// Send second request during retry (should supersede first)
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
await result.current.sendTranscript('Second message');
});
await firstPromise;
// Wait for operations to complete
await waitFor(() => {
// Should not have "Retry response" because first request was superseded
expect(result.current.lastResponse).not.toBe('Retry response');
}, { timeout: 3000 });
});
});