WellNuo/__tests__/utils/beneficiary-race-condition-prevention.test.ts
Sergei f6ba2a906a Fix race conditions when quickly switching beneficiaries
Implemented request tracking and cancellation to prevent stale API
responses from overwriting current beneficiary data.

Changes:
- Added loadingBeneficiaryIdRef to track which beneficiary is being loaded
- Added AbortController to cancel in-flight requests
- Validate beneficiary ID before applying state updates
- Cleanup on component unmount to prevent memory leaks

This fixes the issue where rapidly switching between beneficiaries
would show wrong data if slower requests completed after faster ones.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-29 12:33:57 -08:00

194 lines
6.2 KiB
TypeScript

/**
* Unit tests for race condition prevention logic
* Tests the core mechanism without component dependencies
*/
describe('Beneficiary Race Condition Prevention', () => {
test('should track loading beneficiary ID and ignore stale responses', async () => {
// Simulate the race condition prevention mechanism
let loadingBeneficiaryIdRef: string | null = null;
const responses: Array<{ id: string; data: any; delay: number }> = [];
// Mock API function that tracks which ID is being loaded
const mockLoadBeneficiary = async (id: string, delay: number) => {
const currentLoadingId = id;
loadingBeneficiaryIdRef = currentLoadingId;
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, delay));
// Check if this request is still current
if (loadingBeneficiaryIdRef !== currentLoadingId) {
return { ignored: true, id };
}
responses.push({ id, data: `Data for ${id}`, delay });
return { ignored: false, id, data: `Data for ${id}` };
};
// Simulate rapid switching: Load 1, then quickly switch to 2
const request1 = mockLoadBeneficiary('1', 100); // Slower
await new Promise(resolve => setTimeout(resolve, 10)); // Quick switch
const request2 = mockLoadBeneficiary('2', 20); // Faster
const [result1, result2] = await Promise.all([request1, request2]);
// Result 1 should be ignored (stale)
expect(result1.ignored).toBe(true);
// Result 2 should be accepted (current)
expect(result2.ignored).toBe(false);
// Only beneficiary 2's data should be in responses
expect(responses).toHaveLength(1);
expect(responses[0].id).toBe('2');
});
test('should cancel AbortController when new request starts', () => {
const abortControllers: Array<{ id: string; controller: AbortController }> = [];
const mockLoadWithAbort = (id: string) => {
// Cancel previous request
if (abortControllers.length > 0) {
const prev = abortControllers[abortControllers.length - 1];
prev.controller.abort();
}
// Create new AbortController
const controller = new AbortController();
abortControllers.push({ id, controller });
return controller;
};
// Simulate loading multiple beneficiaries
const controller1 = mockLoadWithAbort('1');
const controller2 = mockLoadWithAbort('2');
const controller3 = mockLoadWithAbort('3');
// First two should be aborted
expect(controller1.signal.aborted).toBe(true);
expect(controller2.signal.aborted).toBe(true);
// Last one should still be active
expect(controller3.signal.aborted).toBe(false);
expect(abortControllers).toHaveLength(3);
});
test('should handle concurrent requests with different completion times', async () => {
interface RequestResult {
id: string;
timestamp: number;
accepted: boolean;
}
const results: RequestResult[] = [];
let currentId: string | null = null;
const mockConcurrentLoad = async (id: string, delay: number) => {
const requestId = id;
currentId = requestId;
const startTime = Date.now();
await new Promise(resolve => setTimeout(resolve, delay));
const isStillCurrent = currentId === requestId;
const result: RequestResult = {
id: requestId,
timestamp: Date.now() - startTime,
accepted: isStillCurrent,
};
results.push(result);
return result;
};
// Start 3 requests with delays: 300ms, 200ms, 100ms
// They complete in reverse order: 3, 2, 1
const promises = [
mockConcurrentLoad('1', 300),
mockConcurrentLoad('2', 200),
mockConcurrentLoad('3', 100),
];
await Promise.all(promises);
// Sort by timestamp to see completion order
const sortedResults = [...results].sort((a, b) => a.timestamp - b.timestamp);
// Request 3 completes first and should be accepted
expect(sortedResults[0].id).toBe('3');
expect(sortedResults[0].accepted).toBe(true);
// Requests 1 and 2 complete later and should be rejected
expect(sortedResults[1].accepted).toBe(false);
expect(sortedResults[2].accepted).toBe(false);
});
test('should allow reloading the same beneficiary', async () => {
const loadedIds: string[] = [];
let currentId: string | null = null;
const mockReload = async (id: string, delay: number) => {
const requestId = id;
currentId = requestId;
await new Promise(resolve => setTimeout(resolve, delay));
if (currentId === requestId) {
loadedIds.push(id);
return { accepted: true, id };
}
return { accepted: false, id };
};
// Load beneficiary 1
await mockReload('1', 50);
expect(loadedIds).toEqual(['1']);
// Reload the same beneficiary (e.g., pull to refresh)
await mockReload('1', 50);
expect(loadedIds).toEqual(['1', '1']);
// Both requests should be accepted
expect(loadedIds.filter(id => id === '1')).toHaveLength(2);
});
test('should handle errors without affecting race condition logic', async () => {
let currentId: string | null = null;
const results: Array<{ id: string; error?: boolean }> = [];
const mockLoadWithError = async (id: string, shouldError: boolean, delay: number) => {
const requestId = id;
currentId = requestId;
await new Promise(resolve => setTimeout(resolve, delay));
// Check if still current
if (currentId !== requestId) {
return { ignored: true, id };
}
if (shouldError) {
results.push({ id, error: true });
throw new Error(`Failed to load ${id}`);
}
results.push({ id });
return { id, data: `Data for ${id}` };
};
// Request 1 will error after 100ms
const request1 = mockLoadWithError('1', true, 100).catch(e => ({ error: true, id: '1' }));
// Quickly switch to request 2 (succeeds after 50ms)
await new Promise(resolve => setTimeout(resolve, 10));
const request2 = mockLoadWithError('2', false, 50);
await Promise.all([request1, request2]);
// Only successful request 2 should be in results
expect(results.filter(r => !r.error)).toHaveLength(1);
expect(results.filter(r => !r.error)[0].id).toBe('2');
});
});