أعد هيكلة منطق استطلاع المهام للتعامل مع حمولات الأخطاء المنظمة وتحسين رسائل الخطأ
This commit is contained in:
@@ -3,9 +3,13 @@ import { renderHook, act } from '@testing-library/react';
|
|||||||
import { useTaskPolling } from './useTaskPolling';
|
import { useTaskPolling } from './useTaskPolling';
|
||||||
|
|
||||||
// ── mock the api module ────────────────────────────────────────────────────
|
// ── mock the api module ────────────────────────────────────────────────────
|
||||||
vi.mock('@/services/api', () => ({
|
vi.mock('@/services/api', async (importOriginal) => {
|
||||||
getTaskStatus: vi.fn(),
|
const actual = await importOriginal<typeof import('@/services/api')>();
|
||||||
}));
|
return {
|
||||||
|
...actual,
|
||||||
|
getTaskStatus: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import { getTaskStatus } from '@/services/api';
|
import { getTaskStatus } from '@/services/api';
|
||||||
const mockGetStatus = vi.mocked(getTaskStatus);
|
const mockGetStatus = vi.mocked(getTaskStatus);
|
||||||
@@ -122,6 +126,33 @@ describe('useTaskPolling', () => {
|
|||||||
expect(onError).toHaveBeenCalledWith('Ghostscript not found.');
|
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 ──────────────────────────────────────────────────────
|
// ── FAILURE state ──────────────────────────────────────────────────────
|
||||||
it('stops polling and sets error on FAILURE state', async () => {
|
it('stops polling and sets error on FAILURE state', async () => {
|
||||||
mockGetStatus.mockResolvedValueOnce({
|
mockGetStatus.mockResolvedValueOnce({
|
||||||
@@ -144,6 +175,31 @@ describe('useTaskPolling', () => {
|
|||||||
expect(onError).toHaveBeenCalledWith('Worker crashed.');
|
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 ──────────────────────────────────────────────────────
|
// ── network error ──────────────────────────────────────────────────────
|
||||||
it('stops polling and sets error on network/API exception', async () => {
|
it('stops polling and sets error on network/API exception', async () => {
|
||||||
mockGetStatus.mockRejectedValueOnce(new Error('Network error.'));
|
mockGetStatus.mockRejectedValueOnce(new Error('Network error.'));
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import i18n from '@/i18n';
|
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';
|
import { trackEvent } from '@/services/analytics';
|
||||||
|
|
||||||
interface UseTaskPollingOptions {
|
interface UseTaskPollingOptions {
|
||||||
@@ -54,6 +59,7 @@ export function useTaskPolling({
|
|||||||
if (taskStatus.state === 'SUCCESS') {
|
if (taskStatus.state === 'SUCCESS') {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
const taskResult = taskStatus.result;
|
const taskResult = taskStatus.result;
|
||||||
|
const fallbackError = i18n.t('common.errors.processingFailed');
|
||||||
|
|
||||||
if (taskResult?.status === 'completed') {
|
if (taskResult?.status === 'completed') {
|
||||||
setResult(taskResult);
|
setResult(taskResult);
|
||||||
@@ -63,7 +69,10 @@ export function useTaskPolling({
|
|||||||
trackEvent('task_completed', { task_id: taskId });
|
trackEvent('task_completed', { task_id: taskId });
|
||||||
onComplete?.(taskResult);
|
onComplete?.(taskResult);
|
||||||
} else {
|
} else {
|
||||||
const errMsg = taskResult?.error || i18n.t('common.errors.processingFailed');
|
const errMsg = getTaskErrorMessage(
|
||||||
|
taskStatus.error ?? taskResult?.user_message ?? taskResult?.error,
|
||||||
|
fallbackError
|
||||||
|
);
|
||||||
setError(errMsg);
|
setError(errMsg);
|
||||||
toast.error(errMsg);
|
toast.error(errMsg);
|
||||||
trackEvent('task_failed', { task_id: taskId, reason: 'result_failed' });
|
trackEvent('task_failed', { task_id: taskId, reason: 'result_failed' });
|
||||||
@@ -71,7 +80,10 @@ export function useTaskPolling({
|
|||||||
}
|
}
|
||||||
} else if (taskStatus.state === 'FAILURE') {
|
} else if (taskStatus.state === 'FAILURE') {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
const errMsg = taskStatus.error || i18n.t('common.errors.processingFailed');
|
const errMsg = getTaskErrorMessage(
|
||||||
|
taskStatus.error,
|
||||||
|
i18n.t('common.errors.processingFailed')
|
||||||
|
);
|
||||||
setError(errMsg);
|
setError(errMsg);
|
||||||
toast.error(errMsg);
|
toast.error(errMsg);
|
||||||
trackEvent('task_failed', { task_id: taskId, reason: 'state_failure' });
|
trackEvent('task_failed', { task_id: taskId, reason: 'state_failure' });
|
||||||
|
|||||||
@@ -187,12 +187,22 @@ export interface TaskResponse {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskErrorPayload {
|
||||||
|
error_code?: string;
|
||||||
|
user_message?: string;
|
||||||
|
task_id?: string;
|
||||||
|
trace_id?: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskStatus {
|
export interface TaskStatus {
|
||||||
task_id: string;
|
task_id: string;
|
||||||
state: 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE';
|
state: 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE';
|
||||||
progress?: string;
|
progress?: string;
|
||||||
result?: TaskResult;
|
result?: TaskResult;
|
||||||
error?: string;
|
error?: string | TaskErrorPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskResult {
|
export interface TaskResult {
|
||||||
@@ -200,6 +210,10 @@ export interface TaskResult {
|
|||||||
download_url?: string;
|
download_url?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
error_code?: string;
|
||||||
|
user_message?: string;
|
||||||
|
task_id?: string;
|
||||||
|
trace_id?: string;
|
||||||
original_size?: number;
|
original_size?: number;
|
||||||
compressed_size?: number;
|
compressed_size?: number;
|
||||||
reduction_percent?: number;
|
reduction_percent?: number;
|
||||||
@@ -229,6 +243,33 @@ export interface TaskResult {
|
|||||||
tables_found?: number;
|
tables_found?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTaskErrorPayload(value: unknown): value is TaskErrorPayload {
|
||||||
|
return Boolean(value) && typeof value === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (typeof error === 'string' && error.trim()) {
|
||||||
|
return error.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTaskErrorPayload(error)) {
|
||||||
|
const candidates = [
|
||||||
|
error.user_message,
|
||||||
|
error.message,
|
||||||
|
error.error,
|
||||||
|
error.detail,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (typeof candidate === 'string' && candidate.trim()) {
|
||||||
|
return candidate.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email: string;
|
||||||
|
|||||||
@@ -28,23 +28,55 @@ if [ ! -f ".env" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}1/5 — Pulling latest code...${NC}"
|
echo -e "${YELLOW}1/7 — Pulling latest code...${NC}"
|
||||||
git pull origin main 2>/dev/null || echo "Not a git repo or no remote, skipping pull."
|
git pull origin main 2>/dev/null || echo "Not a git repo or no remote, skipping pull."
|
||||||
|
|
||||||
echo -e "${YELLOW}2/5 — Building Docker images...${NC}"
|
echo -e "${YELLOW}2/7 — Building Docker images...${NC}"
|
||||||
docker compose -f docker-compose.prod.yml build --no-cache
|
docker compose -f docker-compose.prod.yml build --no-cache
|
||||||
|
|
||||||
echo -e "${YELLOW}3/5 — Stopping old containers...${NC}"
|
echo -e "${YELLOW}3/7 — Stopping old containers...${NC}"
|
||||||
docker compose -f docker-compose.prod.yml down --remove-orphans
|
docker compose -f docker-compose.prod.yml down --remove-orphans
|
||||||
|
|
||||||
echo -e "${YELLOW}4/5 — Starting services...${NC}"
|
echo -e "${YELLOW}4/7 — Starting services...${NC}"
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
echo -e "${YELLOW}5/5 — Waiting for health check...${NC}"
|
if [ "${SKIP_AI_RUNTIME_CHECKS:-0}" != "1" ]; then
|
||||||
|
echo -e "${YELLOW}5/7 — Verifying AI runtime in backend + worker...${NC}"
|
||||||
|
for service in backend celery_worker; do
|
||||||
|
if ! docker compose -f docker-compose.prod.yml exec -T "$service" python - <<'PY'
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
api_key = os.getenv("OPENROUTER_API_KEY", "").strip()
|
||||||
|
if not api_key or api_key.startswith("replace-with-"):
|
||||||
|
issues.append("OPENROUTER_API_KEY is missing or still set to a placeholder")
|
||||||
|
if importlib.util.find_spec("vtracer") is None:
|
||||||
|
issues.append("vtracer is not installed in this container")
|
||||||
|
|
||||||
|
if issues:
|
||||||
|
print("; ".join(issues))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("AI runtime OK")
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
echo -e "${RED}✗ AI runtime check failed in ${service}.${NC}"
|
||||||
|
echo " Fix the container env/dependencies, then redeploy backend and celery_worker."
|
||||||
|
echo " Do not redeploy the frontend alone when backend routes, Celery tasks, or AI dependencies changed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}5/7 — Skipping AI runtime checks (SKIP_AI_RUNTIME_CHECKS=1).${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}6/7 — Waiting for health check...${NC}"
|
||||||
sleep 10
|
sleep 10
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
if curl -sf http://localhost/health > /dev/null 2>&1; then
|
if curl -sf http://localhost/api/health > /dev/null 2>&1; then
|
||||||
echo -e "${GREEN}✓ Deployment successful! Service is healthy.${NC}"
|
echo -e "${GREEN}✓ Deployment successful! Service is healthy.${NC}"
|
||||||
else
|
else
|
||||||
echo -e "${RED}✗ Health check failed. Check logs:${NC}"
|
echo -e "${RED}✗ Health check failed. Check logs:${NC}"
|
||||||
@@ -52,6 +84,9 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}7/7 — Current containers:${NC}"
|
||||||
|
docker compose -f docker-compose.prod.yml ps
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}Deployment complete!${NC}"
|
echo -e "${GREEN}Deployment complete!${NC}"
|
||||||
echo " App: http://localhost"
|
echo " App: http://localhost"
|
||||||
|
|||||||
Reference in New Issue
Block a user