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>
194 lines
6.2 KiB
TypeScript
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');
|
|
});
|
|
});
|