/** * 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 }) => {children}, }); 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 }) => {children}, }); 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 }) => {children}, }); 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 }) => {children}, }); 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 }) => {children}, }); 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 }); }); });