أعد هيكلة منطق استطلاع المهام للتعامل مع حمولات الأخطاء المنظمة وتحسين رسائل الخطأ

This commit is contained in:
Your Name
2026-03-25 16:51:38 +02:00
parent 14743c6cfe
commit aa3420281c
4 changed files with 157 additions and 13 deletions

View File

@@ -3,9 +3,13 @@ import { renderHook, act } from '@testing-library/react';
import { useTaskPolling } from './useTaskPolling';
// ── mock the api module ────────────────────────────────────────────────────
vi.mock('@/services/api', () => ({
getTaskStatus: vi.fn(),
}));
vi.mock('@/services/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/services/api')>();
return {
...actual,
getTaskStatus: vi.fn(),
};
});
import { getTaskStatus } from '@/services/api';
const mockGetStatus = vi.mocked(getTaskStatus);
@@ -122,6 +126,33 @@ describe('useTaskPolling', () => {
expect(onError).toHaveBeenCalledWith('Ghostscript not found.');
});
it('prefers a structured task error payload when SUCCESS contains a failed result', async () => {
mockGetStatus.mockResolvedValueOnce({
task_id: 'task-4b',
state: 'SUCCESS',
error: {
error_code: 'OPENROUTER_MISSING_API_KEY',
user_message: 'AI features are temporarily unavailable. Our team has been notified.',
task_id: 'task-4b',
},
result: { status: 'failed' },
});
const onError = vi.fn();
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-4b', intervalMs: 500, onError })
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.isPolling).toBe(false);
expect(result.current.result).toBeNull();
expect(result.current.error).toBe('AI features are temporarily unavailable. Our team has been notified.');
expect(onError).toHaveBeenCalledWith('AI features are temporarily unavailable. Our team has been notified.');
});
// ── FAILURE state ──────────────────────────────────────────────────────
it('stops polling and sets error on FAILURE state', async () => {
mockGetStatus.mockResolvedValueOnce({
@@ -144,6 +175,31 @@ describe('useTaskPolling', () => {
expect(onError).toHaveBeenCalledWith('Worker crashed.');
});
it('normalizes a structured FAILURE payload into a safe string message', async () => {
mockGetStatus.mockResolvedValueOnce({
task_id: 'task-5b',
state: 'FAILURE',
error: {
error_code: 'TASK_FAILURE',
user_message: 'Task processing failed. Please retry.',
task_id: 'task-5b',
},
});
const onError = vi.fn();
const { result } = renderHook(() =>
useTaskPolling({ taskId: 'task-5b', intervalMs: 500, onError })
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.isPolling).toBe(false);
expect(result.current.error).toBe('Task processing failed. Please retry.');
expect(onError).toHaveBeenCalledWith('Task processing failed. Please retry.');
});
// ── network error ──────────────────────────────────────────────────────
it('stops polling and sets error on network/API exception', async () => {
mockGetStatus.mockRejectedValueOnce(new Error('Network error.'));

View File

@@ -1,7 +1,12 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { toast } from 'sonner';
import i18n from '@/i18n';
import { getTaskStatus, type TaskStatus, type TaskResult } from '@/services/api';
import {
getTaskStatus,
getTaskErrorMessage,
type TaskStatus,
type TaskResult,
} from '@/services/api';
import { trackEvent } from '@/services/analytics';
interface UseTaskPollingOptions {
@@ -54,6 +59,7 @@ export function useTaskPolling({
if (taskStatus.state === 'SUCCESS') {
stopPolling();
const taskResult = taskStatus.result;
const fallbackError = i18n.t('common.errors.processingFailed');
if (taskResult?.status === 'completed') {
setResult(taskResult);
@@ -63,7 +69,10 @@ export function useTaskPolling({
trackEvent('task_completed', { task_id: taskId });
onComplete?.(taskResult);
} else {
const errMsg = taskResult?.error || i18n.t('common.errors.processingFailed');
const errMsg = getTaskErrorMessage(
taskStatus.error ?? taskResult?.user_message ?? taskResult?.error,
fallbackError
);
setError(errMsg);
toast.error(errMsg);
trackEvent('task_failed', { task_id: taskId, reason: 'result_failed' });
@@ -71,7 +80,10 @@ export function useTaskPolling({
}
} else if (taskStatus.state === 'FAILURE') {
stopPolling();
const errMsg = taskStatus.error || i18n.t('common.errors.processingFailed');
const errMsg = getTaskErrorMessage(
taskStatus.error,
i18n.t('common.errors.processingFailed')
);
setError(errMsg);
toast.error(errMsg);
trackEvent('task_failed', { task_id: taskId, reason: 'state_failure' });