try to make upload better using tan-react library
This commit is contained in:
133
src/hooks/useFlyerUploader.test.tsx
Normal file
133
src/hooks/useFlyerUploader.test.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useFlyerUploader } from './useFlyerUploader';
|
||||
import * as aiApiClient from '../services/aiApiClient';
|
||||
import * as checksumUtil from '../utils/checksum';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../services/aiApiClient');
|
||||
vi.mock('../utils/checksum');
|
||||
vi.mock('../services/logger.client', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedAiApiClient = vi.mocked(aiApiClient);
|
||||
const mockedChecksumUtil = vi.mocked(checksumUtil);
|
||||
|
||||
// Helper to wrap the hook with QueryClientProvider, which is required by react-query
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false, // Disable retries for tests for predictable behavior
|
||||
},
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('useFlyerUploader Hook with React Query', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedChecksumUtil.generateFileChecksum.mockResolvedValue('mock-checksum');
|
||||
});
|
||||
|
||||
it('should handle a successful upload and polling flow', async () => {
|
||||
// Arrange
|
||||
const mockJobId = 'job-123';
|
||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: mockJobId });
|
||||
mockedAiApiClient.getJobStatus
|
||||
.mockResolvedValueOnce({
|
||||
// First poll: active
|
||||
id: mockJobId,
|
||||
state: 'active',
|
||||
progress: { message: 'Processing...' },
|
||||
returnValue: null,
|
||||
failedReason: null,
|
||||
} as aiApiClient.JobStatus)
|
||||
.mockResolvedValueOnce({
|
||||
// Second poll: completed
|
||||
id: mockJobId,
|
||||
state: 'completed',
|
||||
progress: { message: 'Complete!' },
|
||||
returnValue: { flyerId: 777 },
|
||||
failedReason: null,
|
||||
} as aiApiClient.JobStatus);
|
||||
|
||||
const { result } = renderHook(() => useFlyerUploader(), { wrapper: createWrapper() });
|
||||
const mockFile = new File([''], 'flyer.pdf');
|
||||
|
||||
// Act
|
||||
await act(async () => {
|
||||
result.current.upload(mockFile);
|
||||
});
|
||||
|
||||
// Assert initial upload state
|
||||
await waitFor(() => expect(result.current.processingState).toBe('polling'));
|
||||
expect(result.current.jobId).toBe(mockJobId);
|
||||
|
||||
// Assert polling state
|
||||
await waitFor(() => expect(result.current.statusMessage).toBe('Processing...'));
|
||||
|
||||
// Assert completed state
|
||||
await waitFor(() => expect(result.current.processingState).toBe('completed'));
|
||||
expect(result.current.flyerId).toBe(777);
|
||||
});
|
||||
|
||||
it('should handle an upload failure', async () => {
|
||||
// Arrange
|
||||
const uploadError = {
|
||||
status: 409,
|
||||
body: { message: 'Duplicate flyer detected.', flyerId: 99 },
|
||||
};
|
||||
mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue(uploadError);
|
||||
|
||||
const { result } = renderHook(() => useFlyerUploader(), { wrapper: createWrapper() });
|
||||
const mockFile = new File([''], 'flyer.pdf');
|
||||
|
||||
// Act
|
||||
await act(async () => {
|
||||
result.current.upload(mockFile);
|
||||
});
|
||||
|
||||
// Assert error state
|
||||
await waitFor(() => expect(result.current.processingState).toBe('error'));
|
||||
expect(result.current.errorMessage).toBe('Duplicate flyer detected.');
|
||||
expect(result.current.duplicateFlyerId).toBe(99);
|
||||
expect(result.current.jobId).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle a job failure during polling', async () => {
|
||||
// Arrange
|
||||
const mockJobId = 'job-456';
|
||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: mockJobId });
|
||||
|
||||
// Mock getJobStatus to throw a JobFailedError
|
||||
const jobFailedError = new aiApiClient.JobFailedError(
|
||||
'AI validation failed.',
|
||||
'AI_VALIDATION_FAILED',
|
||||
);
|
||||
mockedAiApiClient.getJobStatus.mockRejectedValue(jobFailedError);
|
||||
|
||||
const { result } = renderHook(() => useFlyerUploader(), { wrapper: createWrapper() });
|
||||
const mockFile = new File([''], 'flyer.pdf');
|
||||
|
||||
// Act
|
||||
await act(async () => {
|
||||
result.current.upload(mockFile);
|
||||
});
|
||||
|
||||
// Assert error state after polling fails
|
||||
await waitFor(() => expect(result.current.processingState).toBe('error'));
|
||||
expect(result.current.errorMessage).toBe('Polling failed: AI validation failed.');
|
||||
expect(result.current.flyerId).toBeNull();
|
||||
});
|
||||
});
|
||||
123
src/hooks/useFlyerUploader.ts
Normal file
123
src/hooks/useFlyerUploader.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// src/hooks/useFlyerUploader.ts
|
||||
// src/hooks/useFlyerUploader.ts
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
uploadAndProcessFlyer,
|
||||
getJobStatus,
|
||||
type JobStatus,
|
||||
JobFailedError,
|
||||
} from '../services/aiApiClient';
|
||||
import { logger } from '../services/logger.client';
|
||||
import { generateFileChecksum } from '../utils/checksum';
|
||||
import type { ProcessingStage } from '../types';
|
||||
|
||||
export type ProcessingState = 'idle' | 'uploading' | 'polling' | 'completed' | 'error';
|
||||
|
||||
export const useFlyerUploader = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [currentFile, setCurrentFile] = useState<string | null>(null);
|
||||
|
||||
// Mutation for the initial file upload
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
setCurrentFile(file.name);
|
||||
const checksum = await generateFileChecksum(file);
|
||||
return uploadAndProcessFlyer(file, checksum);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// When upload is successful, we get a jobId and can start polling.
|
||||
setJobId(data.jobId);
|
||||
},
|
||||
// onError is handled automatically by react-query and exposed in `uploadMutation.error`
|
||||
});
|
||||
|
||||
// Query for polling the job status
|
||||
const { data: jobStatus, error: pollError } = useQuery({
|
||||
queryKey: ['jobStatus', jobId],
|
||||
queryFn: () => {
|
||||
if (!jobId) throw new Error('No job ID to poll');
|
||||
return getJobStatus(jobId);
|
||||
},
|
||||
// Only run this query if there is a jobId
|
||||
enabled: !!jobId,
|
||||
// Polling logic: react-query handles the interval
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
// Stop polling if the job is completed or has failed
|
||||
if (data?.state === 'completed' || data?.state === 'failed') {
|
||||
return false;
|
||||
}
|
||||
// Otherwise, poll every 3 seconds
|
||||
return 3000;
|
||||
},
|
||||
refetchOnWindowFocus: false, // No need to refetch on focus, interval is enough
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry for our custom JobFailedError, as it's a terminal state.
|
||||
if (error instanceof JobFailedError) {
|
||||
return false;
|
||||
}
|
||||
// For other errors (like network issues), retry up to 3 times.
|
||||
return failureCount < 3;
|
||||
},
|
||||
});
|
||||
|
||||
const upload = useCallback(
|
||||
(file: File) => {
|
||||
// Reset previous state before a new upload
|
||||
setJobId(null);
|
||||
setCurrentFile(null);
|
||||
queryClient.removeQueries({ queryKey: ['jobStatus'] });
|
||||
uploadMutation.mutate(file);
|
||||
},
|
||||
[uploadMutation, queryClient],
|
||||
);
|
||||
|
||||
const resetUploaderState = useCallback(() => {
|
||||
setJobId(null);
|
||||
setCurrentFile(null);
|
||||
uploadMutation.reset();
|
||||
queryClient.removeQueries({ queryKey: ['jobStatus'] });
|
||||
}, [uploadMutation, queryClient]);
|
||||
|
||||
// Consolidate state for the UI from the react-query hooks
|
||||
const processingState = ((): ProcessingState => {
|
||||
if (uploadMutation.isPending) return 'uploading';
|
||||
if (jobStatus && (jobStatus.state === 'active' || jobStatus.state === 'waiting'))
|
||||
return 'polling';
|
||||
if (jobStatus?.state === 'completed') return 'completed';
|
||||
if (uploadMutation.isError || jobStatus?.state === 'failed' || pollError) return 'error';
|
||||
return 'idle';
|
||||
})();
|
||||
|
||||
const getErrorMessage = () => {
|
||||
const uploadError = uploadMutation.error as any;
|
||||
if (uploadMutation.isError) {
|
||||
return uploadError?.body?.message || uploadError?.message || 'Upload failed.';
|
||||
}
|
||||
if (pollError) return `Polling failed: ${pollError.message}`;
|
||||
if (jobStatus?.state === 'failed') {
|
||||
return `Processing failed: ${jobStatus.progress?.message || jobStatus.failedReason}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const errorMessage = getErrorMessage();
|
||||
const duplicateFlyerId = (uploadMutation.error as any)?.body?.flyerId ?? null;
|
||||
const flyerId = jobStatus?.state === 'completed' ? jobStatus.returnValue?.flyerId : null;
|
||||
|
||||
return {
|
||||
processingState,
|
||||
statusMessage: uploadMutation.isPending ? 'Uploading file...' : jobStatus?.progress?.message,
|
||||
errorMessage,
|
||||
duplicateFlyerId,
|
||||
processingStages: jobStatus?.progress?.stages || [],
|
||||
estimatedTime: jobStatus?.progress?.estimatedTimeRemaining || 0,
|
||||
currentFile,
|
||||
flyerId,
|
||||
upload,
|
||||
resetUploaderState,
|
||||
jobId,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user