diff --git a/core/redis.py b/core/redis.py index 459b031..b373a43 100644 --- a/core/redis.py +++ b/core/redis.py @@ -132,3 +132,21 @@ async def get_job_status_async(job_id: str) -> dict | None: except Exception as e: logger.warning("Redis get_job_status_async failed for %s: %s", job_id, e) return None + + +async def delete_job_status_async(job_id: str) -> bool: + """Remove a job's status key from Redis (user deleted it from history). + + Best-effort like the other job-status helpers: never raises, returns + True when the key was deleted, False when Redis is unavailable or the + key did not exist. + """ + client = get_async_redis() + if not client: + return False + try: + key = f"{JOB_STATUS_KEY_PREFIX}:{job_id}" + return bool(await client.delete(key)) + except Exception as e: + logger.warning("Redis delete_job_status_async failed for %s: %s", job_id, e) + return False diff --git a/frontend/src/app/dashboard/reviews/[jobId]/page.tsx b/frontend/src/app/dashboard/reviews/[jobId]/page.tsx index 11548e8..bd78754 100644 --- a/frontend/src/app/dashboard/reviews/[jobId]/page.tsx +++ b/frontend/src/app/dashboard/reviews/[jobId]/page.tsx @@ -106,7 +106,13 @@ export default function ReviewPage() { setSegments(res.data.segments); setFileName(res.data.file_name); } catch (err) { - setLoadError(err instanceof Error ? err.message : t('reviews.error.load')); + setLoadError( + friendlyReviewError( + err instanceof Error ? err.message : undefined, + t, + 'reviews.error.load' + ) ?? t('reviews.error.load') + ); } finally { setIsLoading(false); } @@ -406,6 +412,15 @@ export default function ReviewPage() {
+ ) : segments.length === 0 && !loadError ? ( +
+

+ {t('reviews.empty.title')} +

+

+ {t('reviews.empty.desc')} +

+
) : (
diff --git a/frontend/src/app/dashboard/translate/page.tsx b/frontend/src/app/dashboard/translate/page.tsx index cfed5bb..a0cc1e8 100644 --- a/frontend/src/app/dashboard/translate/page.tsx +++ b/frontend/src/app/dashboard/translate/page.tsx @@ -8,11 +8,14 @@ import { Zap, CheckCircle2, Search, Languages, Wrench, Activity, Download, AlertTriangle, FileType, - Image as ImageIcon, + Image as ImageIcon, Trash2, } from 'lucide-react'; import { useFileUpload } from './useFileUpload'; import { useTranslationConfig } from './useTranslationConfig'; -import { useTranslationSubmit, getRecentJobs, fetchServerHistory, pushRecentJob, type RecentJob } from './useTranslationSubmit'; +import { + useTranslationSubmit, getRecentJobs, fetchServerHistory, pushRecentJob, + removeRecentJob, deleteServerJob, type RecentJob, +} from './useTranslationSubmit'; import { runTranslationJob } from './translationRunner'; import type { BatchItem } from './types'; import LanguageSelector from './LanguageSelector'; @@ -83,6 +86,8 @@ export default function TranslatePage() { const batchJobRef = useRef(null); const [elapsed, setElapsed] = useState(0); const [recentJobs, setRecentJobs] = useState([]); + // Job whose delete button is armed (first click) — cleared after 4s or on delete. + const [armedRemoveId, setArmedRemoveId] = useState(null); const timerRef = useRef | null>(null); const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false; @@ -238,6 +243,20 @@ export default function TranslatePage() { }; const handleNewTranslation = () => { batchAbortRef.current = true; setBatch(null); submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); }; + + /** Two-step delete (arm, then confirm): removes the entry locally and on the server. */ + const handleRemoveJob = (jobId: string) => { + if (armedRemoveId !== jobId) { + setArmedRemoveId(jobId); + setTimeout(() => setArmedRemoveId((cur) => (cur === jobId ? null : cur)), 4000); + return; + } + setArmedRemoveId(null); + removeRecentJob(jobId); + setRecentJobs((jobs) => jobs.filter((j) => j.jobId !== jobId)); + // Server-side cleanup is best-effort: a 404 just means it already forgot the job. + void deleteServerJob(jobId); + }; const handleDownload = async (jobId: string = submit.jobId ?? '') => { if (!jobId) return; const token = localStorage.getItem('token'); @@ -543,7 +562,7 @@ export default function TranslatePage() { onClick={() => handleDownload(job.jobId)} className="rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60 border border-black/10 dark:border-white/10 hover:bg-brand-muted/50 transition-colors" > - + {t('translate.recent.review')} + ))} diff --git a/frontend/src/app/dashboard/translate/useTranslationSubmit.ts b/frontend/src/app/dashboard/translate/useTranslationSubmit.ts index aea47a6..23d14f6 100644 --- a/frontend/src/app/dashboard/translate/useTranslationSubmit.ts +++ b/frontend/src/app/dashboard/translate/useTranslationSubmit.ts @@ -22,7 +22,7 @@ interface StoredJob { jobId: string; fileName: string | null; savedAt: number } export interface RecentJob { jobId: string; fileName: string; completedAt: number } -/** Server history (last jobs, newest first) — empty when offline/unauthenticated. */ +/** Server history (last completed jobs, newest first) — empty when offline/unauthenticated. */ export async function fetchServerHistory(perPage = 6): Promise { if (typeof window === 'undefined') return []; const token = localStorage.getItem('token'); @@ -33,14 +33,16 @@ export async function fetchServerHistory(perPage = 6): Promise { }); if (!res.ok) return []; const json = await res.json(); - return (json.data ?? []).map((j: { - id: string; file_name?: string | null; - completed_at?: string | null; created_at?: string | null; - }) => ({ - jobId: j.id, - fileName: j.file_name ?? '', - completedAt: Date.parse(j.completed_at ?? j.created_at ?? '') || Date.now(), - })); + return (json.data ?? []) + .filter((j: { status?: string | null }) => (j.status ?? 'completed') === 'completed') + .map((j: { + id: string; file_name?: string | null; status?: string | null; + completed_at?: string | null; created_at?: string | null; + }) => ({ + jobId: j.id, + fileName: j.file_name ?? '', + completedAt: Date.parse(j.completed_at ?? j.created_at ?? '') || Date.now(), + })); } catch { return []; } @@ -56,6 +58,31 @@ export function getRecentJobs(): RecentJob[] { } } +/** Remove a job from this browser's local history. */ +export function removeRecentJob(jobId: string) { + try { + const list = getRecentJobs().filter(j => j.jobId !== jobId); + localStorage.setItem(RECENT_JOBS_KEY, JSON.stringify(list)); + } catch { /* ignore */ } +} + +/** + * Delete a job on the server (history entry + review segments). + * Silent by design: a 404 means the server already forgot the job, which + * is exactly the state the caller wants. + */ +export async function deleteServerJob(jobId: string): Promise { + if (typeof window === 'undefined') return; + const token = localStorage.getItem('token'); + if (!token) return; + try { + await fetch(`${API_BASE}/api/v1/translations/${jobId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + } catch { /* offline — the local entry is removed regardless */ } +} + function persistActiveJob(job: StoredJob | null) { try { if (!job) localStorage.removeItem(ACTIVE_JOB_KEY); diff --git a/frontend/src/lib/i18n/messages/ar/reviews.json b/frontend/src/lib/i18n/messages/ar/reviews.json index b780720..cb427fc 100644 --- a/frontend/src/lib/i18n/messages/ar/reviews.json +++ b/frontend/src/lib/i18n/messages/ar/reviews.json @@ -47,5 +47,7 @@ "reviews.searchPlaceholder": "Search in segments...", "reviews.shortcutHint": "Ctrl + Enter to save", "reviews.approvingProgress": "Approving ({current}/{total})…", - "reviews.noMatchingSegments": "No segments match your filter." + "reviews.noMatchingSegments": "No segments match your filter.", + "reviews.empty.title": "لا توجد أجزاء لمراجعتها في هذه المهمة", + "reviews.empty.desc": "لا تحتوي هذه الترجمة على أجزاء محفوظة: إما أنها فشلت قبل ترجمة النص، أو لم يعد الخادم يحتفظ بها. يمكنك إزالتها من قائمة الترجمات الأخيرة." } diff --git a/frontend/src/lib/i18n/messages/de/reviews.json b/frontend/src/lib/i18n/messages/de/reviews.json index b780720..7bc519d 100644 --- a/frontend/src/lib/i18n/messages/de/reviews.json +++ b/frontend/src/lib/i18n/messages/de/reviews.json @@ -47,5 +47,7 @@ "reviews.searchPlaceholder": "Search in segments...", "reviews.shortcutHint": "Ctrl + Enter to save", "reviews.approvingProgress": "Approving ({current}/{total})…", - "reviews.noMatchingSegments": "No segments match your filter." + "reviews.noMatchingSegments": "No segments match your filter.", + "reviews.empty.title": "Für diesen Auftrag gibt es nichts zu prüfen", + "reviews.empty.desc": "Diese Übersetzung enthält keine gespeicherten Segmente: Entweder ist sie vor der Textübersetzung fehlgeschlagen, oder der Server bewahrt sie nicht mehr auf. Sie können sie aus der Liste der letzten Übersetzungen entfernen." } diff --git a/frontend/src/lib/i18n/messages/en/reviews.json b/frontend/src/lib/i18n/messages/en/reviews.json index b780720..df23b68 100644 --- a/frontend/src/lib/i18n/messages/en/reviews.json +++ b/frontend/src/lib/i18n/messages/en/reviews.json @@ -47,5 +47,7 @@ "reviews.searchPlaceholder": "Search in segments...", "reviews.shortcutHint": "Ctrl + Enter to save", "reviews.approvingProgress": "Approving ({current}/{total})…", - "reviews.noMatchingSegments": "No segments match your filter." + "reviews.noMatchingSegments": "No segments match your filter.", + "reviews.empty.title": "Nothing to review for this job", + "reviews.empty.desc": "This translation has no stored segments: either it failed before any text was translated, or it is no longer retained by the server. You can remove it from your recent translations list." } diff --git a/frontend/src/lib/i18n/messages/en/translate.json b/frontend/src/lib/i18n/messages/en/translate.json index e7f0b3d..4e2b8f2 100644 --- a/frontend/src/lib/i18n/messages/en/translate.json +++ b/frontend/src/lib/i18n/messages/en/translate.json @@ -96,6 +96,8 @@ "translate.downloadFailed": "The download failed. Please try again.", "translate.recent.title": "Recent translations", "translate.recent.review": "Review", + "translate.recent.remove": "Remove from history", + "translate.recent.removeConfirm": "Click again to confirm", "translate.upload.ariaDropzone": "Upload a document: drag and drop, or press Enter to browse files", "translate.cancelAction": "Cancel translation", "translate.cancelledTitle": "Translation cancelled", diff --git a/frontend/src/lib/i18n/messages/es/reviews.json b/frontend/src/lib/i18n/messages/es/reviews.json index b780720..09d4b40 100644 --- a/frontend/src/lib/i18n/messages/es/reviews.json +++ b/frontend/src/lib/i18n/messages/es/reviews.json @@ -47,5 +47,7 @@ "reviews.searchPlaceholder": "Search in segments...", "reviews.shortcutHint": "Ctrl + Enter to save", "reviews.approvingProgress": "Approving ({current}/{total})…", - "reviews.noMatchingSegments": "No segments match your filter." + "reviews.noMatchingSegments": "No segments match your filter.", + "reviews.empty.title": "Nada que revisar en este trabajo", + "reviews.empty.desc": "Esta traducción no tiene segmentos guardados: o falló antes de traducir el texto, o el servidor ya no la conserva. Puede quitarla de la lista de traducciones recientes." } diff --git a/frontend/src/lib/i18n/messages/fa/reviews.json b/frontend/src/lib/i18n/messages/fa/reviews.json index b780720..0b3f4ff 100644 --- a/frontend/src/lib/i18n/messages/fa/reviews.json +++ b/frontend/src/lib/i18n/messages/fa/reviews.json @@ -47,5 +47,7 @@ "reviews.searchPlaceholder": "Search in segments...", "reviews.shortcutHint": "Ctrl + Enter to save", "reviews.approvingProgress": "Approving ({current}/{total})…", - "reviews.noMatchingSegments": "No segments match your filter." + "reviews.noMatchingSegments": "No segments match your filter.", + "reviews.empty.title": "بخشی برای بازبینی در این کار وجود ندارد", + "reviews.empty.desc": "این ترجمه بخش ذخیره‌شده‌ای ندارد: یا پیش از ترجمهٔ متن ناموفق بوده، یا سرور دیگر آن را نگه نمی‌دارد. می‌توانید آن را از فهرست ترجمه‌های اخیر حذف کنید." } diff --git a/frontend/src/lib/i18n/messages/fr/reviews.json b/frontend/src/lib/i18n/messages/fr/reviews.json index e314dc0..38b3788 100644 --- a/frontend/src/lib/i18n/messages/fr/reviews.json +++ b/frontend/src/lib/i18n/messages/fr/reviews.json @@ -47,5 +47,7 @@ "reviews.searchPlaceholder": "Rechercher dans les segments...", "reviews.shortcutHint": "Ctrl + Entrée pour enregistrer", "reviews.approvingProgress": "Approbation ({current}/{total})…", - "reviews.noMatchingSegments": "Aucun segment ne correspond à votre filtre." + "reviews.noMatchingSegments": "Aucun segment ne correspond à votre filtre.", + "reviews.empty.title": "Rien à relire pour ce travail", + "reviews.empty.desc": "Cette traduction n'a aucun segment enregistré : soit elle a échoué avant la traduction du texte, soit elle n'est plus conservée par le serveur. Vous pouvez la retirer de la liste des traductions récentes." } diff --git a/frontend/src/lib/i18n/messages/fr/translate.json b/frontend/src/lib/i18n/messages/fr/translate.json index 8a58303..7968c29 100644 --- a/frontend/src/lib/i18n/messages/fr/translate.json +++ b/frontend/src/lib/i18n/messages/fr/translate.json @@ -96,6 +96,8 @@ "translate.downloadFailed": "Le téléchargement a échoué. Réessayez.", "translate.recent.title": "Traductions récentes", "translate.recent.review": "Relire", + "translate.recent.remove": "Retirer de l'historique", + "translate.recent.removeConfirm": "Cliquez encore pour confirmer", "translate.upload.ariaDropzone": "Déposer un document : glisser-déposer ou appuyer sur Entrée pour parcourir", "translate.cancelAction": "Annuler la traduction", "translate.cancelledTitle": "Traduction annulée", diff --git a/frontend/src/lib/i18n/messages/it/reviews.json b/frontend/src/lib/i18n/messages/it/reviews.json index b780720..786ab8a 100644 --- a/frontend/src/lib/i18n/messages/it/reviews.json +++ b/frontend/src/lib/i18n/messages/it/reviews.json @@ -47,5 +47,7 @@ "reviews.searchPlaceholder": "Search in segments...", "reviews.shortcutHint": "Ctrl + Enter to save", "reviews.approvingProgress": "Approving ({current}/{total})…", - "reviews.noMatchingSegments": "No segments match your filter." + "reviews.noMatchingSegments": "No segments match your filter.", + "reviews.empty.title": "Niente da rivedere per questo lavoro", + "reviews.empty.desc": "Questa traduzione non ha segmenti salvati: o è fallita prima di tradurre il testo, o il server non la conserva più. Puoi rimuoverla dall'elenco delle traduzioni recenti." } diff --git a/frontend/src/lib/i18n/messages/ja/reviews.json b/frontend/src/lib/i18n/messages/ja/reviews.json index b780720..7dae8de 100644 --- a/frontend/src/lib/i18n/messages/ja/reviews.json +++ b/frontend/src/lib/i18n/messages/ja/reviews.json @@ -47,5 +47,7 @@ "reviews.searchPlaceholder": "Search in segments...", "reviews.shortcutHint": "Ctrl + Enter to save", "reviews.approvingProgress": "Approving ({current}/{total})…", - "reviews.noMatchingSegments": "No segments match your filter." + "reviews.noMatchingSegments": "No segments match your filter.", + "reviews.empty.title": "このジョブに校正するセグメントはありません", + "reviews.empty.desc": "この翻訳には保存されたセグメントがありません。テキストの翻訳前に失敗したか、サーバーに保持されていません。最近の翻訳リストから削除できます。" } diff --git a/frontend/src/lib/i18n/messages/ko/reviews.json b/frontend/src/lib/i18n/messages/ko/reviews.json index b780720..303eb61 100644 --- a/frontend/src/lib/i18n/messages/ko/reviews.json +++ b/frontend/src/lib/i18n/messages/ko/reviews.json @@ -47,5 +47,7 @@ "reviews.searchPlaceholder": "Search in segments...", "reviews.shortcutHint": "Ctrl + Enter to save", "reviews.approvingProgress": "Approving ({current}/{total})…", - "reviews.noMatchingSegments": "No segments match your filter." + "reviews.noMatchingSegments": "No segments match your filter.", + "reviews.empty.title": "이 작업에 검토할 세그먼트가 없습니다", + "reviews.empty.desc": "이 번역에는 저장된 세그먼트가 없습니다. 텍스트 번역 전에 실패했거나 서버에 더 이상 보관되지 않습니다. 최근 번역 목록에서 제거할 수 있습니다." } diff --git a/frontend/src/lib/i18n/messages/nl/reviews.json b/frontend/src/lib/i18n/messages/nl/reviews.json index b780720..56ce3b0 100644 --- a/frontend/src/lib/i18n/messages/nl/reviews.json +++ b/frontend/src/lib/i18n/messages/nl/reviews.json @@ -47,5 +47,7 @@ "reviews.searchPlaceholder": "Search in segments...", "reviews.shortcutHint": "Ctrl + Enter to save", "reviews.approvingProgress": "Approving ({current}/{total})…", - "reviews.noMatchingSegments": "No segments match your filter." + "reviews.noMatchingSegments": "No segments match your filter.", + "reviews.empty.title": "Niets te controleren voor deze opdracht", + "reviews.empty.desc": "Deze vertaling heeft geen opgeslagen segmenten: of hij mislukte voordat er tekst werd vertaald, of de server bewaart hem niet meer. U kunt hem verwijderen uit de lijst met recente vertalingen." } diff --git a/frontend/src/lib/i18n/messages/pt/reviews.json b/frontend/src/lib/i18n/messages/pt/reviews.json index b780720..5861bcf 100644 --- a/frontend/src/lib/i18n/messages/pt/reviews.json +++ b/frontend/src/lib/i18n/messages/pt/reviews.json @@ -47,5 +47,7 @@ "reviews.searchPlaceholder": "Search in segments...", "reviews.shortcutHint": "Ctrl + Enter to save", "reviews.approvingProgress": "Approving ({current}/{total})…", - "reviews.noMatchingSegments": "No segments match your filter." + "reviews.noMatchingSegments": "No segments match your filter.", + "reviews.empty.title": "Nada a revisar neste trabalho", + "reviews.empty.desc": "Esta tradução não tem segmentos armazenados: ou falhou antes de qualquer texto ser traduzido, ou o servidor já não a conserva. Pode removê-la da lista de traduções recentes." } diff --git a/frontend/src/lib/i18n/messages/ru/reviews.json b/frontend/src/lib/i18n/messages/ru/reviews.json index b780720..1520a34 100644 --- a/frontend/src/lib/i18n/messages/ru/reviews.json +++ b/frontend/src/lib/i18n/messages/ru/reviews.json @@ -47,5 +47,7 @@ "reviews.searchPlaceholder": "Search in segments...", "reviews.shortcutHint": "Ctrl + Enter to save", "reviews.approvingProgress": "Approving ({current}/{total})…", - "reviews.noMatchingSegments": "No segments match your filter." + "reviews.noMatchingSegments": "No segments match your filter.", + "reviews.empty.title": "Для этого задания нет сегментов для проверки", + "reviews.empty.desc": "У этого перевода нет сохранённых сегментов: либо он завершился ошибкой до перевода текста, либо сервер его больше не хранит. Вы можете удалить его из списка недавних переводов." } diff --git a/frontend/src/lib/i18n/messages/zh/reviews.json b/frontend/src/lib/i18n/messages/zh/reviews.json index b780720..a0ba782 100644 --- a/frontend/src/lib/i18n/messages/zh/reviews.json +++ b/frontend/src/lib/i18n/messages/zh/reviews.json @@ -47,5 +47,7 @@ "reviews.searchPlaceholder": "Search in segments...", "reviews.shortcutHint": "Ctrl + Enter to save", "reviews.approvingProgress": "Approving ({current}/{total})…", - "reviews.noMatchingSegments": "No segments match your filter." + "reviews.noMatchingSegments": "No segments match your filter.", + "reviews.empty.title": "此任务没有可校对的段落", + "reviews.empty.desc": "此翻译没有已存储的段落:可能是在翻译文本之前失败,或服务器已不再保留。您可以将其从最近翻译列表中移除。" } diff --git a/routes/translate_routes.py b/routes/translate_routes.py index 1d1af41..9ba2f50 100644 --- a/routes/translate_routes.py +++ b/routes/translate_routes.py @@ -71,7 +71,7 @@ from utils.file_handler import FileHandler from middleware.metrics import record_translation, record_file_size from services.progress_tracker import ProgressTracker from services.storage_tracker import storage_tracker -from core.redis import set_job_status_async, get_job_status_async +from core.redis import set_job_status_async, get_job_status_async, delete_job_status_async from services.glossary_service import get_glossary_terms, validate_glossary_access, build_full_prompt from services.prompt_service import get_prompt_content, validate_prompt_access from utils.exceptions import GlossaryNotFoundError, PromptNotFoundError @@ -2257,6 +2257,92 @@ async def cancel_translation( return {"data": {"id": job_id, "status": "cancelled"}, "meta": {}} +@router_v1.delete( + "/translations/{job_id}", + responses={ + 200: {"description": "Job removed from the user's history (idempotent)"}, + 401: {"description": "Authentication required"}, + 404: {"description": "Job belongs to another user"}, + 409: {"description": "Job is queued or processing — cancel it first"}, + }, +) +async def delete_translation( + job_id: str, + current_user: Optional[Any] = Depends(get_authenticated_user), +): + """ + Remove a translation job from the user's history. + + Deletes the in-memory job record, its Redis status key and its stored + review segments. Idempotent: a job the server has already forgotten + (retention expiry, restart) still returns 200 so the client can clean + its own list. Only the owner may delete — the share token does not + grant deletion rights. Files on disk are left to the TTL cleanup. + """ + if current_user is None: + return JSONResponse( + status_code=401, + content={"error": "AUTH_REQUIRED", "message": "Authentication required."}, + ) + user_id = str(getattr(current_user, "id", "")) + + job = _translation_jobs.get(job_id) + if not job: + job = await get_job_status_async(job_id) + + if job: + # Do not reveal whether someone else's job exists: same 404 body. + if str(job.get("user_id", "")) != user_id: + return JSONResponse( + status_code=404, + content={ + "error": "NOT_FOUND", + "message": "Translation job not found.", + "details": {"job_id": job_id}, + }, + ) + if job.get("status") in ("queued", "processing"): + return JSONResponse( + status_code=409, + content={ + "error": "JOB_IN_PROGRESS", + "message": "Cancel the job before deleting it.", + "details": {"job_id": job_id, "status": job.get("status")}, + }, + ) + + _translation_jobs.pop(job_id, None) + await delete_job_status_async(job_id) + + def _delete_segments(): + from database.connection import get_sync_session + from database.models import TranslationSegment + + with get_sync_session() as session: + deleted = ( + session.query(TranslationSegment) + .filter(TranslationSegment.job_id == job_id) + .delete() + ) + session.commit() + return deleted + + segments_deleted = 0 + try: + segments_deleted = await asyncio.to_thread(_delete_segments) + except Exception as seg_err: + logger.warning(f"Job {job_id}: segment deletion failed: {seg_err}") + + logger.info( + f"Job {job_id}: deleted from history by user " + f"(segments removed: {segments_deleted})" + ) + return { + "data": {"id": job_id, "deleted": True, "segments_removed": segments_deleted}, + "meta": {}, + } + + @router_v1.get("/translate/health") async def translate_health(): """Health check for translation endpoint.""" diff --git a/tests/test_delete_translation_history.py b/tests/test_delete_translation_history.py new file mode 100644 index 0000000..3278f89 --- /dev/null +++ b/tests/test_delete_translation_history.py @@ -0,0 +1,144 @@ +"""DELETE /api/v1/translations/{job_id} — retrait d'un travail de l'historique. + +Contexte : un travail échoué ou oublié par le serveur (rétention 24 h en +mémoire) restait affiché dans « Traductions récentes » sans aucun moyen de +le retirer, et sa page de relecture était vide. La route supprime le +travail de la mémoire, sa clé Redis et ses segments de relecture. +""" + +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch + +from main import app +from routes.translate_routes import get_authenticated_user, _translation_jobs + + +@pytest.fixture() +def client(monkeypatch): + from middleware.rate_limiting import RateLimitMiddleware + + async def _dispatch(self, request, call_next): + return await call_next(request) + + monkeypatch.setattr(RateLimitMiddleware, "dispatch", _dispatch) + return TestClient(app) + + +class MockUser: + def __init__(self, user_id="user_123"): + self.id = user_id + self.plan = "free" + self.docs_translated_this_month = 0 + self.pages_translated_this_month = 0 + self.extra_credits = 0 + + +def _make_job(job_id="tr_del1", user_id="user_123", status="completed"): + return { + "id": job_id, + "user_id": user_id, + "status": status, + "file_name": "doc.docx", + "created_at": "2026-09-01T10:00:00", + "progress_percent": 100, + } + + +@pytest.fixture(autouse=True) +def _clean_jobs(): + saved = dict(_translation_jobs) + _translation_jobs.clear() + yield + _translation_jobs.clear() + _translation_jobs.update(saved) + + +def _seed_segments(job_id, user_id="user_123", count=2): + from database.models import Base, TranslationSegment + from database.connection import sync_engine, get_sync_session + + Base.metadata.create_all(bind=sync_engine) + with get_sync_session() as session: + for i in range(count): + session.add( + TranslationSegment( + job_id=job_id, + user_id=user_id, + segment_index=i, + source_text=f"Hello {i}", + translated_text=f"Bonjour {i}", + status="pending", + ) + ) + session.commit() + + +class TestDeleteTranslationHistory: + def test_owner_deletes_completed_job(self, client): + app.dependency_overrides[get_authenticated_user] = _async_user(MockUser()) + _translation_jobs["tr_del1"] = _make_job() + _seed_segments("tr_del1") + + res = client.delete("/api/v1/translations/tr_del1") + assert res.status_code == 200 + body = res.json()["data"] + assert body["deleted"] is True + assert body["segments_removed"] == 2 + assert "tr_del1" not in _translation_jobs + + from database.connection import get_sync_session + from database.models import TranslationSegment + + with get_sync_session() as session: + remaining = ( + session.query(TranslationSegment) + .filter(TranslationSegment.job_id == "tr_del1") + .count() + ) + assert remaining == 0 + app.dependency_overrides.clear() + + def test_delete_is_idempotent_when_job_already_gone(self, client): + app.dependency_overrides[get_authenticated_user] = _async_user(MockUser()) + # Le serveur a oublié le travail (rétention expirée / redémarrage) : + # le client veut quand même nettoyer sa liste locale. + res = client.delete("/api/v1/translations/tr_unknown") + assert res.status_code == 200 + assert res.json()["data"]["deleted"] is True + app.dependency_overrides.clear() + + def test_other_users_job_is_404(self, client): + app.dependency_overrides[get_authenticated_user] = _async_user(MockUser()) + _translation_jobs["tr_someone"] = _make_job("tr_someone", user_id="user_999") + res = client.delete("/api/v1/translations/tr_someone") + assert res.status_code == 404 + # Le travail reste intact + assert "tr_someone" in _translation_jobs + app.dependency_overrides.clear() + + def test_running_job_requires_cancel_first(self, client): + app.dependency_overrides[get_authenticated_user] = _async_user(MockUser()) + _translation_jobs["tr_run"] = _make_job("tr_run", status="processing") + res = client.delete("/api/v1/translations/tr_run") + assert res.status_code == 409 + assert res.json()["error"] == "JOB_IN_PROGRESS" + assert "tr_run" in _translation_jobs + app.dependency_overrides.clear() + + def test_unauthenticated_is_401(self, client): + async def _anonymous(): + return None + + app.dependency_overrides[get_authenticated_user] = _anonymous + _translation_jobs["tr_anon"] = _make_job() + res = client.delete("/api/v1/translations/tr_anon") + assert res.status_code == 401 + app.dependency_overrides.clear() + + +def _async_user(user): + async def _auth(): + return user + + return _auth