"""
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)
from middleware.tier_quota import TierQuotaService
async def _check_quota_allow(self, user_id, tier):
from middleware.tier_quota import QuotaResult
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
tomorrow = now.date() + timedelta(days=1)
reset_at = datetime(
tomorrow.year, tomorrow.month, tomorrow.day, tzinfo=timezone.utc
)
return QuotaResult(
allowed=True, remaining=5, reset_at_utc=reset_at, current_usage=0, limit=5
)
async def _increment_noop(self, user_id):
pass
monkeypatch.setattr(TierQuotaService, "check_quota", _check_quota_allow)
monkeypatch.setattr(TierQuotaService, "increment_on_success", _increment_noop)
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