""" Tests pour GET /api/v1/download/{job_id} Couvre les AC 1-6 de la story 2.12 : Telechargement Fichier Traduit """ import io from pathlib import Path from unittest.mock import patch, MagicMock from zipfile import ZipFile import pytest from fastapi.testclient import TestClient TRANSLATE_URL = "/api/v1/translate" STATUS_URL = "/api/v1/translations" DOWNLOAD_URL = "/api/v1/download" REGISTER_URL = "/api/v1/auth/register" LOGIN_URL = "/api/v1/auth/login" VALID_USER = { "email": "download@example.com", "password": "Password123!", "name": "Download User", } def create_valid_excel() -> bytes: """Create a minimal valid .xlsx file (ZIP with office content).""" buf = io.BytesIO() with ZipFile(buf, "w") as zf: zf.writestr( "[Content_Types].xml", '', ) zf.writestr( "_rels/.rels", '', ) zf.writestr( "xl/workbook.xml", '', ) buf.seek(0) return buf.read() def create_valid_docx() -> bytes: """Create a minimal valid .docx file.""" buf = io.BytesIO() with ZipFile(buf, "w") as zf: zf.writestr( "[Content_Types].xml", '', ) zf.writestr( "_rels/.rels", '', ) zf.writestr( "word/document.xml", '', ) buf.seek(0) return buf.read() def create_valid_pptx() -> bytes: """Create a minimal valid .pptx file.""" buf = io.BytesIO() with ZipFile(buf, "w") as zf: zf.writestr( "[Content_Types].xml", '', ) zf.writestr( "_rels/.rels", '', ) zf.writestr( "ppt/presentation.xml", '', ) buf.seek(0) return buf.read() @pytest.fixture() def users_file(tmp_path: Path) -> Path: """Fichier de stockage JSON isole pour les tests.""" return tmp_path / "users.json" @pytest.fixture() def client(users_file: Path, monkeypatch): """TestClient avec stockage JSON isole et rate limiting desactive.""" import services.auth_service as auth_svc monkeypatch.setattr(auth_svc, "USERS_FILE", users_file) monkeypatch.setattr(auth_svc, "USE_DATABASE", False) monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False) from middleware.rate_limiting import RateLimitManager async def _check_request_allow(self, request): return True, "ok", "test" async def _check_translation_allow(self, request, file_size_mb=0): return True, "ok" monkeypatch.setattr(RateLimitManager, "check_request", _check_request_allow) monkeypatch.setattr(RateLimitManager, "check_translation", _check_translation_allow) def _check_usage_limits_allow(user): return { "can_translate": True, "docs_used": 0, "docs_limit": 5, "docs_remaining": 5, "pages_used": 0, "extra_credits": 0, "max_pages_per_doc": 50, "max_file_size_mb": 10, "allowed_providers": ["google", "deepl"], } monkeypatch.setattr( "routes.translate_routes.check_usage_limits", _check_usage_limits_allow ) from main import app return TestClient(app, raise_server_exceptions=True) @pytest.fixture() def authenticated_client(client): """Client avec un utilisateur enregistre et authentifie.""" client.post(REGISTER_URL, json=VALID_USER) response = client.post( LOGIN_URL, json={ "email": VALID_USER["email"], "password": VALID_USER["password"], }, ) token = response.json()["data"]["access_token"] client.headers["Authorization"] = f"Bearer {token}" return client # --------------------------------------------------------------------------- # AC1: Download Endpoint # --------------------------------------------------------------------------- class TestDownloadEndpoint: """AC1: GET /api/v1/download/{id} returns translated file as binary download""" def test_returns_400_for_invalid_job_id_format(self, authenticated_client): """Invalid job_id format returns 400 with INVALID_JOB_ID""" response = authenticated_client.get(f"{DOWNLOAD_URL}/invalid_format") assert response.status_code == 400 body = response.json() assert body["error"] == "INVALID_JOB_ID" def test_returns_400_for_job_id_with_special_chars(self, authenticated_client): """Job ID with special chars returns 400""" response = authenticated_client.get(f"{DOWNLOAD_URL}/tr_invalid@#$%") assert response.status_code == 400 body = response.json() assert body["error"] == "INVALID_JOB_ID" def test_returns_404_for_non_existent_job(self, authenticated_client): """AC4: Non-existent job returns 404 with FILE_EXPIRED""" response = authenticated_client.get(f"{DOWNLOAD_URL}/tr_nonexistent123") assert response.status_code == 404 body = response.json() assert body["error"] == "FILE_EXPIRED" def test_returns_404_for_job_without_output_path(self, authenticated_client): """AC4: Job without output_path returns 404 with FILE_EXPIRED""" from routes import translate_routes job_id = "tr_test_no_output" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "completed", "file_name": "test.xlsx", "output_path": None, } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 404 body = response.json() assert body["error"] == "FILE_EXPIRED" def test_returns_404_for_file_deleted_from_disk( self, authenticated_client, tmp_path ): """AC4: Job with output_path but file missing from disk returns 404""" from routes import translate_routes nonexistent_file = tmp_path / "deleted_file.xlsx" job_id = "tr_deleted_disk" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "completed", "file_name": "deleted.xlsx", "file_extension": ".xlsx", "output_path": str(nonexistent_file), } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 404 body = response.json() assert body["error"] == "FILE_EXPIRED" assert body["details"]["status"] == "file_deleted" def test_returns_404_for_non_completed_job(self, authenticated_client): """AC5: Non-completed jobs return 404 with NOT_READY""" from routes import translate_routes job_id = "tr_test_processing" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "processing", "progress_percent": 50, "file_name": "test.xlsx", } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 404 body = response.json() assert body["error"] == "NOT_READY" def test_returns_404_for_queued_job(self, authenticated_client): """AC5: Queued jobs return 404 with NOT_READY""" from routes import translate_routes job_id = "tr_test_queued" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "queued", "file_name": "test.xlsx", } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 404 body = response.json() assert body["error"] == "NOT_READY" def test_returns_404_for_failed_job(self, authenticated_client): """AC5: Failed jobs return 404 with NOT_READY""" from routes import translate_routes job_id = "tr_test_failed" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "failed", "error_message": "Something went wrong", "file_name": "test.xlsx", } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 404 body = response.json() assert body["error"] == "NOT_READY" # --------------------------------------------------------------------------- # AC2: Content-Disposition Header # --------------------------------------------------------------------------- class TestContentDisposition: """AC2: Header includes original filename with "_translated" suffix""" def test_content_disposition_has_translated_suffix( self, authenticated_client, tmp_path ): """Content-Disposition includes _translated suffix""" from routes import translate_routes output_file = tmp_path / "test_translated.xlsx" output_file.write_bytes(create_valid_excel()) job_id = "tr_test_disposition" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "completed", "file_name": "report.xlsx", "file_extension": ".xlsx", "output_path": str(output_file), } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 200 content_disp = response.headers.get("content-disposition", "") assert "attachment" in content_disp assert "report_translated.xlsx" in content_disp def test_content_disposition_for_docx(self, authenticated_client, tmp_path): """Content-Disposition works for .docx files""" from routes import translate_routes output_file = tmp_path / "doc_translated.docx" output_file.write_bytes(create_valid_docx()) job_id = "tr_test_docx" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "completed", "file_name": "document.docx", "file_extension": ".docx", "output_path": str(output_file), } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 200 content_disp = response.headers.get("content-disposition", "") assert "document_translated.docx" in content_disp def test_content_disposition_for_pptx(self, authenticated_client, tmp_path): """Content-Disposition works for .pptx files""" from routes import translate_routes output_file = tmp_path / "ppt_translated.pptx" output_file.write_bytes(create_valid_pptx()) job_id = "tr_test_pptx" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "completed", "file_name": "presentation.pptx", "file_extension": ".pptx", "output_path": str(output_file), } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 200 content_disp = response.headers.get("content-disposition", "") assert "presentation_translated.pptx" in content_disp # --------------------------------------------------------------------------- # AC3: Immediate File Deletion (tested via BackgroundTask) # --------------------------------------------------------------------------- class TestFileDeletion: """AC3: File is deleted immediately after successful download""" def test_file_deleted_after_download(self, authenticated_client, tmp_path): """File should be deleted after download completes""" from routes import translate_routes output_file = tmp_path / "to_delete.xlsx" output_file.write_bytes(create_valid_excel()) input_file = tmp_path / "input_file.xlsx" input_file.write_bytes(create_valid_excel()) job_id = "tr_test_delete" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "completed", "file_name": "to_delete.xlsx", "file_extension": ".xlsx", "output_path": str(output_file), "input_path": str(input_file), } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 200 import time time.sleep(0.5) assert not output_file.exists(), "Output file should be deleted after download" assert not input_file.exists(), "Input file should be deleted after download" # --------------------------------------------------------------------------- # AC6: Correct MIME Types # --------------------------------------------------------------------------- class TestMIMETypes: """AC6: Content-Type set correctly for each format""" def test_mime_type_xlsx(self, authenticated_client, tmp_path): """xlsx returns correct MIME type""" from routes import translate_routes output_file = tmp_path / "test.xlsx" output_file.write_bytes(create_valid_excel()) job_id = "tr_test_mime_xlsx" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "completed", "file_name": "test.xlsx", "file_extension": ".xlsx", "output_path": str(output_file), } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 200 content_type = response.headers.get("content-type", "") assert ( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" in content_type ) def test_mime_type_docx(self, authenticated_client, tmp_path): """docx returns correct MIME type""" from routes import translate_routes output_file = tmp_path / "test.docx" output_file.write_bytes(create_valid_docx()) job_id = "tr_test_mime_docx" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "completed", "file_name": "test.docx", "file_extension": ".docx", "output_path": str(output_file), } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 200 content_type = response.headers.get("content-type", "") assert ( "application/vnd.openxmlformats-officedocument.wordprocessingml.document" in content_type ) def test_mime_type_pptx(self, authenticated_client, tmp_path): """pptx returns correct MIME type""" from routes import translate_routes output_file = tmp_path / "test.pptx" output_file.write_bytes(create_valid_pptx()) job_id = "tr_test_mime_pptx" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "completed", "file_name": "test.pptx", "file_extension": ".pptx", "output_path": str(output_file), } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 200 content_type = response.headers.get("content-type", "") assert ( "application/vnd.openxmlformats-officedocument.presentationml.presentation" in content_type ) # --------------------------------------------------------------------------- # AC4: File Expired/Not Found # --------------------------------------------------------------------------- class TestFileExpired: """AC4: If translation not found, expired, or output_path missing, returns 404""" def test_file_expired_message_in_french(self, authenticated_client): """Error message should be in French""" response = authenticated_client.get(f"{DOWNLOAD_URL}/tr_nonexistent") assert response.status_code == 404 body = response.json() assert body["error"] == "FILE_EXPIRED" assert ( "non disponible" in body["message"].lower() or "expire" in body["message"].lower() ) def test_not_ready_message_in_french(self, authenticated_client): """NOT_READY error message should be in French""" from routes import translate_routes job_id = "tr_test_not_ready_msg" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "processing", "progress_percent": 30, "file_name": "test.xlsx", } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 404 body = response.json() assert body["error"] == "NOT_READY" assert "cours" in body["message"].lower() or "encore" in body["message"].lower() # --------------------------------------------------------------------------- # Integration: Full flow # --------------------------------------------------------------------------- class TestDownloadIntegration: """Integration tests for download flow""" def test_download_returns_binary_content(self, authenticated_client, tmp_path): """Download returns actual binary content""" from routes import translate_routes content = create_valid_excel() output_file = tmp_path / "binary_test.xlsx" output_file.write_bytes(content) job_id = "tr_test_binary" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "completed", "file_name": "binary_test.xlsx", "file_extension": ".xlsx", "output_path": str(output_file), "user_id": None, } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 200 assert len(response.content) > 0 assert response.content[:2] == b"PK" # --------------------------------------------------------------------------- # Error Details # --------------------------------------------------------------------------- class TestErrorDetails: """Test that error responses include proper details""" def test_file_expired_includes_job_id_in_details(self, authenticated_client): """FILE_EXPIRED includes job_id in details""" response = authenticated_client.get(f"{DOWNLOAD_URL}/tr_nonexistent999") assert response.status_code == 404 body = response.json() assert body["error"] == "FILE_EXPIRED" assert "details" in body assert body["details"]["job_id"] == "tr_nonexistent999" def test_not_ready_includes_status_in_details(self, authenticated_client): """NOT_READY includes status in details""" from routes import translate_routes job_id = "tr_test_details" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "processing", "progress_percent": 45, "file_name": "test.xlsx", } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 404 body = response.json() assert body["error"] == "NOT_READY" assert "details" in body assert body["details"]["status"] == "processing" assert body["details"]["progress_percent"] == 45 # --------------------------------------------------------------------------- # Authorization Tests # --------------------------------------------------------------------------- class TestDownloadAuthorization: """Test authorization for download endpoint""" def test_user_cannot_download_other_users_file(self, client, tmp_path): """User cannot download file belonging to another user""" from routes import translate_routes output_file = tmp_path / "other_user_file.xlsx" output_file.write_bytes(create_valid_excel()) job_id = "tr_other_user123" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "completed", "file_name": "other.xlsx", "file_extension": ".xlsx", "output_path": str(output_file), "user_id": "different_user_id_456", } client.post(REGISTER_URL, json=VALID_USER) response = client.post( LOGIN_URL, json={ "email": VALID_USER["email"], "password": VALID_USER["password"], }, ) token = response.json()["data"]["access_token"] client.headers["Authorization"] = f"Bearer {token}" response = client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 403 body = response.json() assert body["error"] == "ACCESS_DENIED" def test_user_can_download_own_file(self, authenticated_client, tmp_path): """User can download their own file""" from routes import translate_routes output_file = tmp_path / "own_file.xlsx" output_file.write_bytes(create_valid_excel()) job_id = "tr_own_file123" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "completed", "file_name": "own.xlsx", "file_extension": ".xlsx", "output_path": str(output_file), "user_id": None, } response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 200 def test_anonymous_user_can_download_public_job(self, client, tmp_path): """Anonymous users can download jobs without user_id (public)""" from routes import translate_routes output_file = tmp_path / "public_job.xlsx" output_file.write_bytes(create_valid_excel()) job_id = "tr_public_job99" translate_routes._translation_jobs[job_id] = { "id": job_id, "status": "completed", "file_name": "public.xlsx", "file_extension": ".xlsx", "output_path": str(output_file), "user_id": None, } response = client.get(f"{DOWNLOAD_URL}/{job_id}") assert response.status_code == 200