feat(translation): quality pipeline overhaul + new features (audit 2026-08-29)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m20s

Translation quality & format preservation:
- Word: merge adjacent same-format runs into one unit (sentence-level
  coherence like inline-tag handling); translate comments/balloons;
  dedupe textbox collection (was translated twice); RTL no longer
  overrides center/justify alignment; CJK/Arabic font hints (eastAsia/cs)
- PPTX: chart translations now actually reach the output file
  (ChartPart.blob is read-only — rewrite chart XML in the saved ZIP);
  CJK typeface hints (a:ea)
- Excel: sheet renames no longer break references — rewrite cell
  formulas (3D/quoted), defined names, data validations, cond. formats
- PDF: bold/italic honored (hebo/heit/hebi); table cells never merge;
  unchanged blocks left untouched (typography preserved, fixes duplicate
  hyperlinks); attempted/changed stats + route gate now cover PDF;
  CJK font paths; scanned PDFs via Mistral OCR (detection + admin settings)

Features:
- formality param (formal/informal) + automatic regional-variant prompts
- output_mode=bilingual docx (source above translation)
- per-user translation memory on Redis (falls back to LRU), context-hashed
- QA report + 0-100 confidence score in job status; L0 on by default
- OpenAI-compatible providers: whole chunk in ONE numbered-JSON request
  (~15x fewer calls) with per-item fallback; base prompt always present
  (custom prompt no longer replaces translation instructions)

Infra & marketing alignment:
- plan-based engine gating + vision gating (closes paid-engine leak);
  /providers/available filtered per plan; 107 languages exposed
- zh-CN/zh-TW validation fixed; libmagic disabled on Windows (native crash)
- admin: Mistral OCR settings + engine status dashboard; httpx<0.28 pin
  (TestClient breakage); Prometheus test fixture fixed
- marketing docs aligned with code (PDF+OCR, retention, engines, pricing)
- security: .env.ionos/.env.production/provider_settings.json removed

Tests: 1173 passed / 0 failed (6 network tests deselected: free Google
endpoint temporarily blocked from this machine)
This commit is contained in:
2026-08-29 18:38:09 +02:00
parent 992f13d53c
commit 526c87348f
87 changed files with 6996 additions and 1024 deletions

View File

@@ -101,7 +101,11 @@ def _get_redis_patcher(mock_redis):
@pytest.mark.asyncio
async def test_orphan_deletion(temp_dirs):
"""Test that orphaned files are deleted as per Story 2.15 (AC: #4)"""
"""Test that orphaned files are deleted as per Story 2.15 (AC: #4).
Since the security fix (audit 2026-08-26), an orphan is only deleted
after a grace period, so young in-flight files are never removed.
"""
cleanup_mod = _get_cleanup_module()
FileCleanupManager = cleanup_mod.FileCleanupManager
@@ -117,6 +121,10 @@ async def test_orphan_deletion(temp_dirs):
manager = FileCleanupManager(uploads, outputs, temp, cleanup_interval_minutes=5)
# Make the orphan older than the grace period (default 15 min)
old = time.time() - (manager.orphan_grace_seconds + 60)
os.utime(orphan_file, (old, old))
mock_redis = AsyncMock()
mock_redis.keys.return_value = ["translation:file:job1"]
mock_redis.get.return_value = json.dumps(
@@ -182,6 +190,11 @@ async def test_cleanup_resilience(temp_dirs):
f2 = uploads / "file2.txt"
f2.write_text("file2")
# Make the files older than the grace period so cleanup actually deletes them
old = time.time() - 7200
os.utime(f1, (old, old))
os.utime(f2, (old, old))
manager = FileCleanupManager(uploads, outputs, temp, max_file_age_minutes=1)
original_unlink = Path.unlink

View File

@@ -17,6 +17,8 @@ DOWNLOAD_URL = "/api/v1/download"
REGISTER_URL = "/api/v1/auth/register"
LOGIN_URL = "/api/v1/auth/login"
AUTH_USER_ID = None # set by the authenticated_client fixture
VALID_USER = {
"email": "download@example.com",
"password": "Password123!",
@@ -135,7 +137,9 @@ def client(users_file: Path, monkeypatch):
@pytest.fixture()
def authenticated_client(client):
"""Client avec un utilisateur enregistre et authentifie."""
client.post(REGISTER_URL, json=VALID_USER)
global AUTH_USER_ID
reg = client.post(REGISTER_URL, json=VALID_USER)
AUTH_USER_ID = reg.json()["data"]["id"]
response = client.post(
LOGIN_URL,
json={
@@ -184,6 +188,7 @@ class TestDownloadEndpoint:
job_id = "tr_test_no_output"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "completed",
"file_name": "test.xlsx",
"output_path": None,
@@ -205,6 +210,7 @@ class TestDownloadEndpoint:
job_id = "tr_deleted_disk"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "completed",
"file_name": "deleted.xlsx",
"file_extension": ".xlsx",
@@ -224,6 +230,7 @@ class TestDownloadEndpoint:
job_id = "tr_test_processing"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "processing",
"progress_percent": 50,
"file_name": "test.xlsx",
@@ -241,6 +248,7 @@ class TestDownloadEndpoint:
job_id = "tr_test_queued"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "queued",
"file_name": "test.xlsx",
}
@@ -257,6 +265,7 @@ class TestDownloadEndpoint:
job_id = "tr_test_failed"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "failed",
"error_message": "Something went wrong",
"file_name": "test.xlsx",
@@ -288,6 +297,7 @@ class TestContentDisposition:
job_id = "tr_test_disposition"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "completed",
"file_name": "report.xlsx",
"file_extension": ".xlsx",
@@ -310,6 +320,7 @@ class TestContentDisposition:
job_id = "tr_test_docx"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "completed",
"file_name": "document.docx",
"file_extension": ".docx",
@@ -331,6 +342,7 @@ class TestContentDisposition:
job_id = "tr_test_pptx"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "completed",
"file_name": "presentation.pptx",
"file_extension": ".pptx",
@@ -364,6 +376,7 @@ class TestFileDeletion:
job_id = "tr_test_delete"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "completed",
"file_name": "to_delete.xlsx",
"file_extension": ".xlsx",
@@ -400,6 +413,7 @@ class TestMIMETypes:
job_id = "tr_test_mime_xlsx"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "completed",
"file_name": "test.xlsx",
"file_extension": ".xlsx",
@@ -424,6 +438,7 @@ class TestMIMETypes:
job_id = "tr_test_mime_docx"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "completed",
"file_name": "test.docx",
"file_extension": ".docx",
@@ -448,6 +463,7 @@ class TestMIMETypes:
job_id = "tr_test_mime_pptx"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "completed",
"file_name": "test.pptx",
"file_extension": ".pptx",
@@ -489,6 +505,7 @@ class TestFileExpired:
job_id = "tr_test_not_ready_msg"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "processing",
"progress_percent": 30,
"file_name": "test.xlsx",
@@ -520,11 +537,12 @@ class TestDownloadIntegration:
job_id = "tr_test_binary"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "completed",
"file_name": "binary_test.xlsx",
"file_extension": ".xlsx",
"output_path": str(output_file),
"user_id": None,
"user_id": AUTH_USER_ID,
}
response = authenticated_client.get(f"{DOWNLOAD_URL}/{job_id}")
@@ -557,6 +575,7 @@ class TestErrorDetails:
job_id = "tr_test_details"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "processing",
"progress_percent": 45,
"file_name": "test.xlsx",
@@ -589,6 +608,7 @@ class TestDownloadAuthorization:
job_id = "tr_other_user123"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "completed",
"file_name": "other.xlsx",
"file_extension": ".xlsx",
@@ -622,18 +642,19 @@ class TestDownloadAuthorization:
job_id = "tr_own_file123"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": AUTH_USER_ID,
"status": "completed",
"file_name": "own.xlsx",
"file_extension": ".xlsx",
"output_path": str(output_file),
"user_id": None,
"user_id": AUTH_USER_ID,
}
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)"""
def test_anonymous_job_requires_token(self, client, tmp_path):
"""Jobs without an owner require the secret per-job token (fix 2026-08-26)"""
from routes import translate_routes
output_file = tmp_path / "public_job.xlsx"
@@ -642,12 +663,19 @@ class TestDownloadAuthorization:
job_id = "tr_public_job99"
translate_routes._translation_jobs[job_id] = {
"id": job_id,
"user_id": None,
"access_token": "secret_job_token",
"status": "completed",
"file_name": "public.xlsx",
"file_extension": ".xlsx",
"output_path": str(output_file),
"user_id": None,
}
# Without the token: denied
response = client.get(f"{DOWNLOAD_URL}/{job_id}")
assert response.status_code == 403
assert response.json()["error"] == "ACCESS_DENIED"
# With the correct token: allowed
response = client.get(f"{DOWNLOAD_URL}/{job_id}?token=secret_job_token")
assert response.status_code == 200

View File

@@ -0,0 +1,92 @@
"""Sheet-name translation must not break references.
openpyxl does not rewrite references on rename — cell formulas, defined
names and validations pointing at a translated sheet would break (#REF!).
These tests cover the full rename + rewrite path.
"""
from openpyxl import Workbook, load_workbook
from openpyxl.workbook.defined_name import DefinedName
from translators.excel_translator import ExcelTranslator
class _FrToEn:
"""Minimal legacy-style provider: French sheet name → English."""
def translate_batch(self, texts, target_language, source_language="auto"):
mapping = {
"Ventes": "Sales",
"Données": "Data",
"Rapport des ventes": "Sales report",
"Total": "Total",
}
return [mapping.get(t, t) for t in texts]
def _make_workbook(path):
wb = Workbook()
ws = wb.active
ws.title = "Ventes"
ws["A1"] = "Rapport des ventes"
ws["A2"] = "Total"
ws["A3"] = 10
ws["A4"] = 20
# Cross-sheet formula (unquoted name)
ws2 = wb.create_sheet("Données")
ws2["A1"] = "=SUM(Ventes!A3:A4)"
# Quoted name (name would need quotes if it had spaces — keep simple here)
ws2["A2"] = "=Ventes!A3+Ventes!A4"
# Defined name pointing at the renamed sheet
wb.defined_names["TotalVentes"] = DefinedName(
"TotalVentes", attr_text="'Ventes'!$A$3"
)
wb.save(path)
return wb
class TestSheetRenameReferences:
def test_cell_formulas_and_defined_names_rewritten(self, tmp_path):
from pathlib import Path
src = tmp_path / "in.xlsx"
out = tmp_path / "out.xlsx"
_make_workbook(src)
translator = ExcelTranslator(provider=_FrToEn())
translator.translate_file(Path(src), Path(out), "en", "fr")
wb = load_workbook(out)
data = wb["Data"]
assert data["A1"].value == "=SUM(Sales!A3:A4)", data["A1"].value
assert data["A2"].value == "=Sales!A3+Sales!A4", data["A2"].value
# Defined name follows the rename
dn = wb.defined_names["TotalVentes"]
assert "Sales" in (dn.attr_text or ""), dn.attr_text
assert "Ventes" not in (dn.attr_text or "")
# The renamed sheet actually exists under its new name
assert "Sales" in wb.sheetnames
assert "Ventes" not in wb.sheetnames
def test_3d_and_multiple_refs(self, tmp_path):
mapping = {"Sheet1": "Feuille1", "Sheet2": "Feuille2"}
formula = "=SUM(Sheet1!A1:Sheet2!B2)+Sheet1!C3"
out = ExcelTranslator._rewrite_sheet_refs_in_formula(formula, mapping)
assert out == "=SUM(Feuille1!A1:Feuille2!B2)+Feuille1!C3"
def test_quoted_refs_and_prefix_safety(self, tmp_path):
mapping = {"Ventes": "Sales", "Ventes 2026": "Sales 2026"}
out = ExcelTranslator._rewrite_sheet_refs_in_formula(
"=SUM('Ventes 2026'!A1:A2)+'Ventes'!B1", mapping
)
# "Ventes 2026" (longest first) keeps its quotes (name with space);
# "Sales" needs no quotes so the canonical unquoted form is emitted.
assert out == "=SUM('Sales 2026'!A1:A2)+Sales!B1"
def test_non_sheet_bang_not_touched(self, tmp_path):
mapping = {"Ventes": "Sales"}
# "Total!A1" is not a renamed sheet — must stay untouched
out = ExcelTranslator._rewrite_sheet_refs_in_formula("=Total!A1", mapping)
assert out == "=Total!A1"

View File

@@ -0,0 +1,45 @@
"""LanguageValidator — case-insensitive codes and canonical form (zh-CN fix)."""
import pytest
from middleware.validation import LanguageValidator, ValidationError
class TestLanguageValidatorCaseInsensitive:
def test_zh_cn_mixed_case_accepted(self):
assert LanguageValidator.validate("zh-CN") == "zh-CN"
def test_zh_cn_lowercase_normalized(self):
assert LanguageValidator.validate("zh-cn") == "zh-CN"
def test_zh_cn_uppercase_normalized(self):
assert LanguageValidator.validate("ZH-CN") == "zh-CN"
def test_zh_tw_accepted(self):
assert LanguageValidator.validate("zh-TW") == "zh-TW"
assert LanguageValidator.validate("zh-tw") == "zh-TW"
def test_alias_chinese(self):
assert LanguageValidator.validate("chinese") == "zh-CN"
def test_alias_tw(self):
assert LanguageValidator.validate("tw") == "zh-TW"
def test_plain_codes_unchanged(self):
assert LanguageValidator.validate("en") == "en"
assert LanguageValidator.validate("fr") == "fr"
def test_auto_accepted(self):
assert LanguageValidator.validate("auto") == "auto"
def test_unknown_code_rejected(self):
with pytest.raises(ValidationError):
LanguageValidator.validate("xx")
def test_unknown_variant_rejected(self):
with pytest.raises(ValidationError):
LanguageValidator.validate("zz-ZZ")
def test_empty_rejected(self):
with pytest.raises(ValidationError):
LanguageValidator.validate("")

View File

@@ -35,36 +35,6 @@ from prometheus_client import (
_REPO_ROOT = Path(__file__).resolve().parent.parent
def _load_metrics_module_with_registry(registry: CollectorRegistry):
"""Load middleware/metrics.py with patched Counter/Histogram to
use the supplied registry. Returns the loaded module."""
spec = importlib.util.spec_from_file_location(
"metrics_under_test",
_REPO_ROOT / "middleware" / "metrics.py",
)
mod = importlib.util.module_from_spec(spec)
# Inject the fresh registry into the module's namespace before exec
mod.__dict__["_TEST_REGISTRY"] = registry
# Patch Counter/Histogram to use the fresh registry
orig_counter = Counter
orig_histogram = Histogram
def _counter(*args, **kwargs):
kwargs.setdefault("registry", registry)
return orig_counter(*args, **kwargs)
def _histogram(*args, **kwargs):
kwargs.setdefault("registry", registry)
return orig_histogram(*args, **kwargs)
mod.__dict__["Counter"] = _counter
mod.__dict__["Histogram"] = _histogram
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="module")
def metrics():
"""Load the metrics module ONCE per test module.
@@ -80,55 +50,59 @@ def metrics():
return _load_metrics_module_with_fresh_registry()
def _load_metrics_module_with_registry(registry: CollectorRegistry):
"""Load middleware/metrics.py with ALL its counters/histograms
registered on the supplied fresh registry.
metrics.py does ``from prometheus_client import Counter, Histogram`` at
module top — injecting patched classes into the module dict BEFORE exec
does not survive that import. The only reliable interception point is
the ``prometheus_client`` module itself: we swap its Counter/Histogram
attributes for subclasses that default to ``registry``, exec the
module source, and restore the originals.
"""
import types
import prometheus_client
orig_counter = prometheus_client.Counter
orig_histogram = prometheus_client.Histogram
class _RegistryCounter(orig_counter):
def __new__(cls, *args, **kwargs):
kwargs.setdefault("registry", registry)
return super().__new__(cls)
def __init__(self, *args, **kwargs):
kwargs.setdefault("registry", registry)
super().__init__(*args, **kwargs)
class _RegistryHistogram(orig_histogram):
def __new__(cls, *args, **kwargs):
kwargs.setdefault("registry", registry)
return super().__new__(cls)
def __init__(self, *args, **kwargs):
kwargs.setdefault("registry", registry)
super().__init__(*args, **kwargs)
source = (_REPO_ROOT / "middleware" / "metrics.py").read_text(encoding="utf-8")
mod = types.ModuleType("metrics_under_test")
mod.__file__ = str(_REPO_ROOT / "middleware" / "metrics.py")
try:
prometheus_client.Counter = _RegistryCounter
prometheus_client.Histogram = _RegistryHistogram
exec(compile(source, mod.__file__, "exec"), mod.__dict__)
finally:
prometheus_client.Counter = orig_counter
prometheus_client.Histogram = orig_histogram
return mod
def _load_metrics_module_with_fresh_registry():
"""Load metrics module with its counters/histograms attached to a
fresh CollectorRegistry."""
spec = importlib.util.spec_from_file_location(
"metrics_under_test",
_REPO_ROOT / "middleware" / "metrics.py",
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
# The module just created its counters on the default REGISTRY.
# Unregister them, then re-create them on a fresh registry.
fresh = CollectorRegistry()
for name in (
"http_requests_total",
"translation_total",
"translation_duration_seconds",
"file_size_bytes",
"quality_l0_checks_total",
"quality_l1_judge_total",
"quality_l1_judge_duration_seconds",
"quality_l1_judge_cost_usd",
"translation_retry_total",
"format_elements_lost_total",
):
if not hasattr(mod, name):
continue
obj = getattr(mod, name)
try:
REGISTRY.unregister(obj)
except KeyError:
pass
# Now re-import the module so the new metrics register on `fresh`
spec = importlib.util.spec_from_file_location(
"metrics_under_test_isolated",
_REPO_ROOT / "middleware" / "metrics.py",
)
mod2 = importlib.util.module_from_spec(spec)
# We can't easily re-route Counter/Histogram in exec_module because
# they call into the global REGISTRY via the function signature.
# Instead: reload by re-importing via importlib with a wrapper
# that intercepts the Counter/Histogram constructors. We do this
# via the more direct route: use the EXISTING counters on the
# default REGISTRY, but only check RELATIVE increments.
#
# Practical approach: just use the module as-is. Tests check
# `after >= before + 1`, which is robust against other tests.
return mod
fresh CollectorRegistry (never the shared default one — the app may
already have registered the same names earlier in the session)."""
return _load_metrics_module_with_registry(CollectorRegistry())
def _counter_value(counter, **labels):

344
tests/test_new_features.py Normal file
View File

@@ -0,0 +1,344 @@
"""New feature tests: formality/regional prompts, TM, bilingual output,
QA report, OpenAI JSON batching, font hints."""
import pytest
from docx import Document
from openpyxl import Workbook
from services.glossary_service import build_full_prompt
from services.quality.qa_report import (
_number_fidelity,
_untranslated_ratio,
run_qa_report,
)
from services.translation_tm import TMScope, translate_with_tm
from services.translation_cache import reset_cache_for_tests
from translators.bilingual import make_bilingual_docx
from translators.word_translator import WordTranslator, _font_hints_for_target
# ===========================================================================
# Formality + regional variant in prompts
# ===========================================================================
class TestFormalityPrompt:
def test_formal_adds_tone_directive(self):
prompt = build_full_prompt(None, None, "fr", "en", formality="formal")
assert "TONE:" in prompt and "formal" in prompt
def test_informal_adds_tone_directive(self):
prompt = build_full_prompt(None, None, "fr", "en", formality="informal")
assert "TONE:" in prompt and "informal" in prompt
def test_no_formality_no_tone(self):
assert "TONE:" not in build_full_prompt(None, None, "fr", "en")
def test_regional_variant_auto(self):
prompt = build_full_prompt(None, None, "fr", "pt-BR")
assert "REGIONAL VARIANT" in prompt
assert "Portuguese" in prompt
def test_no_region_no_variant(self):
assert "REGIONAL VARIANT" not in build_full_prompt(None, None, "fr", "en")
# ===========================================================================
# Translation memory (per-user scoping)
# ===========================================================================
class TestTranslationMemory:
@pytest.fixture(autouse=True)
def clean_cache(self):
reset_cache_for_tests()
yield
reset_cache_for_tests()
def test_no_scope_passthrough(self):
calls = []
def fake_translate(texts):
calls.extend(texts)
return [t.upper() for t in texts]
out = translate_with_tm(
["a", "b"], "en", "fr", "fake", None, fake_translate
)
assert out == ["A", "B"]
assert calls == ["a", "b"]
def test_reuse_on_second_call_same_user(self):
from services.translation_cache import get_cache
get_cache() # init LRU backend
calls = []
def fake_translate(texts):
calls.extend(texts)
return [f"T:{t}" for t in texts]
scope = TMScope.from_prompt("user-1", "ctx")
first = translate_with_tm(["hello", "world"], "en", "fr", "fake", scope, fake_translate)
assert first == ["T:hello", "T:world"]
assert len(calls) == 2
second = translate_with_tm(["hello", "world"], "en", "fr", "fake", scope, fake_translate)
assert second == ["T:hello", "T:world"]
# Everything came from the TM — no new provider calls
assert len(calls) == 2
def test_users_are_isolated(self):
from services.translation_cache import get_cache
get_cache()
out = translate_with_tm(
["x"], "en", "fr", "fake",
TMScope.from_prompt("user-A", None), lambda ts: [f"A:{ts[0]}"],
)
assert out == ["A:x"]
out_b = translate_with_tm(
["x"], "en", "fr", "fake",
TMScope.from_prompt("user-B", None), lambda ts: [f"B:{ts[0]}"],
)
assert out_b == ["B:x"]
def test_identity_translations_not_stored(self):
from services.translation_cache import get_cache
get_cache()
scope = TMScope.from_prompt("user-1", None)
# identity provider: returns input unchanged (a provider failure)
translate_with_tm(["same"], "en", "fr", "fake", scope, lambda ts: ts)
out = translate_with_tm(
["same"], "en", "fr", "fake", scope, lambda ts: ["CALLED"]
)
# Not poisoned by the identity entry: the provider was consulted
assert out == ["CALLED"]
def test_different_prompt_context_misses(self):
from services.translation_cache import get_cache
get_cache()
s1 = TMScope.from_prompt("u", "prompt-A")
s2 = TMScope.from_prompt("u", "prompt-B")
translate_with_tm(["k"], "en", "fr", "fake", s1, lambda ts: ["v1"])
out = translate_with_tm(["k"], "en", "fr", "fake", s2, lambda ts: ["v2"])
assert out == ["v2"]
# ===========================================================================
# Bilingual output
# ===========================================================================
class TestBilingual:
def test_source_interleaved_above_translation(self, tmp_path):
src = Document()
src.add_paragraph("Bonjour le monde")
src.add_paragraph("")
src.add_paragraph("Deuxième paragraphe")
src_in = tmp_path / "src.docx"
src.save(str(src_in))
tr = Document()
tr.add_paragraph("Hello world")
tr.add_paragraph("")
tr.add_paragraph("Second paragraph")
tr_out = tmp_path / "tr.docx"
tr.save(str(tr_out))
result = make_bilingual_docx(src_in, tr_out, tmp_path / "bi.docx")
assert result is not None
doc = Document(str(result))
texts = [p.text for p in doc.paragraphs]
# Source paragraph precedes its translation
assert texts[0] == "Bonjour le monde"
assert texts[1] == "Hello world"
assert texts[3] == "Deuxième paragraphe"
assert texts[4] == "Second paragraph"
def test_structure_mismatch_returns_none(self, tmp_path):
a = Document(); a.add_paragraph("x"); a.save(str(tmp_path / "a.docx"))
b = Document(); b.add_paragraph("x"); b.add_paragraph("y")
b.save(str(tmp_path / "b.docx"))
assert make_bilingual_docx(
tmp_path / "a.docx", tmp_path / "b.docx", tmp_path / "bi.docx"
) is None
# ===========================================================================
# QA report
# ===========================================================================
class TestQAReport:
def test_number_fidelity_full(self):
r = _number_fidelity("Prix: 12,50 € sur 3 pages", "Price: 12.50 € on 3 pages")
assert r["fidelity"] >= 0.9
def test_number_fidelity_missing(self):
r = _number_fidelity("Ref 123 and 456", "Ref 123 only")
assert r["fidelity"] < 1.0
def test_untranslated_ratio_high_when_identity(self):
identity_source = (
"This document contains substantial english text about pricing, "
"delivery schedules and quarterly reporting obligations for the "
"regional sales team and their managers."
)
assert _untranslated_ratio(identity_source, identity_source) > 0.8
def test_untranslated_ratio_low_when_translated(self):
assert _untranslated_ratio(
"Ce document contient beaucoup de mots différents sur la facturation, "
"les échéances de livraison et les obligations trimestrielles.",
"This document holds many different words about invoicing, "
"delivery deadlines and quarterly obligations.",
) < 0.35
def test_run_qa_report_on_docx(self, tmp_path):
src = Document()
src.add_paragraph("Le total est de 42 euros pour la livraison.")
src.add_paragraph("Merci de votre confiance renouvelée.")
src_in = tmp_path / "in.docx"
src.save(str(src_in))
out_doc = Document()
out_doc.add_paragraph("The total is 42 euros for the delivery.")
out_doc.add_paragraph("Thank you for your renewed trust.")
out_p = tmp_path / "out.docx"
out_doc.save(str(out_p))
report = run_qa_report(src_in, out_p, "en", ".docx")
assert report is not None
assert report["score"] >= 80
assert report["numbers"]["fidelity"] == 1.0
def test_run_qa_report_scores_untranslated_low(self, tmp_path):
long_fr = (
"Ce paragraphe contient suffisamment de mots différents pour "
"satisfaire l'heuristique du rapport qualité automatisé, avec "
"des notions de facturation, livraison et obligations."
)
src = Document()
src.add_paragraph(long_fr)
src_in = tmp_path / "in.docx"
src.save(str(src_in))
import shutil
out_p = tmp_path / "out.docx"
shutil.copy(str(src_in), str(out_p)) # "translation" = original
report = run_qa_report(src_in, out_p, "en", ".docx")
assert report is not None
assert report["score"] < 50
assert report["untranslated_ratio"] > 0.5
# ===========================================================================
# OpenAI JSON batching
# ===========================================================================
class TestOpenAIBatch:
def _provider(self):
from services.providers.openai_provider import OpenAITranslationProvider
return OpenAITranslationProvider(api_key="test-key", model="gpt-test")
def _requests(self, texts):
from services.providers.schemas import TranslationRequest
return [TranslationRequest(text=t, target_language="fr") for t in texts]
def test_batch_single_request_success(self, monkeypatch):
provider = self._provider()
seen = {}
def fake_api(text, system_prompt):
import json
seen["text"] = text
items = json.loads(text)
reply = json.dumps(
[{"id": it["id"], "translation": f"FR:{it['text']}"} for it in items]
)
return reply, {}
monkeypatch.setattr(provider, "_make_api_request", fake_api)
out = provider.translate_batch(self._requests(["one", "two", "three"]))
assert [r.translated_text for r in out] == [
"FR:one", "FR:two", "FR:three",
]
# One API call for the whole batch
assert isinstance(seen.get("text"), str) and '"id"' in seen["text"]
def test_batch_falls_back_on_bad_json(self, monkeypatch):
provider = self._provider()
calls = {"batch": 0, "single": 0}
def fake_api(text, system_prompt):
if text.startswith("["):
calls["batch"] += 1
return "not valid json at all", {}
calls["single"] += 1
return f"FR:{text}", {}
monkeypatch.setattr(provider, "_make_api_request", fake_api)
out = provider.translate_batch(self._requests(["alpha", "beta"]))
assert [r.translated_text for r in out] == ["FR:alpha", "FR:beta"]
assert calls["batch"] == 1
assert calls["single"] == 2
def test_batch_falls_back_on_wrong_length(self, monkeypatch):
import json
provider = self._provider()
def fake_api(text, system_prompt):
return json.dumps([{"id": 0, "translation": "only one"}]), {}
monkeypatch.setattr(provider, "_make_api_request", fake_api)
def fail_single(req):
from services.providers.schemas import TranslationResponse
return TranslationResponse(
translated_text=f"S:{req.text}", provider_name="openai"
)
monkeypatch.setattr(provider, "translate_text", fail_single)
out = provider.translate_batch(self._requests(["a", "b"]))
assert [r.translated_text for r in out] == ["S:a", "S:b"]
# ===========================================================================
# CJK font hints (Word)
# ===========================================================================
class TestFontHints:
def test_hint_mapping(self):
assert _font_hints_for_target("zh-CN")[0] == "SimSun"
assert _font_hints_for_target("ja")[0] == "Yu Mincho"
assert _font_hints_for_target("ar")[1] == "Arial"
assert _font_hints_for_target("en") == (None, None)
def test_applied_on_translate(self, tmp_path):
class _CJK:
def get_name(self):
return "mock"
def is_available(self):
return True
def translate_batch(self, texts, target_language, source_language="auto"):
return [f"译:{t}" for t in texts]
from docx.oxml.ns import qn as _qn
doc = Document()
doc.add_paragraph("Hello")
src = tmp_path / "in.docx"
doc.save(str(src))
t = WordTranslator(provider=_CJK())
out = tmp_path / "out.docx"
t.translate_file(src, out, "zh-CN", "en")
result = Document(str(out))
para = result.paragraphs[0]
rFonts = para.runs[0]._r.find(_qn("w:rPr")).find(_qn("w:rFonts"))
assert rFonts is not None
assert rFonts.get(_qn("w:eastAsia")) == "SimSun"

86
tests/test_plan_gating.py Normal file
View File

@@ -0,0 +1,86 @@
"""Plan-based engine gating and the expanded /languages endpoint."""
import pytest
from models.subscription import PlanType
from routes.translate_routes import (
_allowed_providers_for_plan,
_image_translation_allowed_for_plan,
_plan_from_user,
)
class _FakeUser:
def __init__(self, plan):
self.plan = plan
class TestAllowedProviders:
def test_free_gets_google_only(self):
assert _allowed_providers_for_plan(PlanType.FREE) == {"google"}
def test_starter_adds_deepl(self):
assert _allowed_providers_for_plan(PlanType.STARTER) == {"google", "deepl"}
def test_pro_adds_cloud_and_openrouter(self):
allowed = _allowed_providers_for_plan(PlanType.PRO)
assert {"google_cloud", "openrouter"} <= allowed
assert "openai" not in allowed
def test_business_adds_premium_and_xai(self):
allowed = _allowed_providers_for_plan(PlanType.BUSINESS)
assert {"openrouter_premium", "openai", "zai"} <= allowed
def test_enterprise_has_all(self):
enterprise = _allowed_providers_for_plan(PlanType.ENTERPRISE)
assert enterprise >= _allowed_providers_for_plan(PlanType.BUSINESS)
class TestImageTranslationGate:
@pytest.mark.parametrize("plan", [PlanType.FREE, PlanType.STARTER])
def test_refused_below_pro(self, plan):
assert _image_translation_allowed_for_plan(plan) is False
@pytest.mark.parametrize("plan", [PlanType.PRO, PlanType.BUSINESS, PlanType.ENTERPRISE])
def test_allowed_from_pro(self, plan):
assert _image_translation_allowed_for_plan(plan) is True
class TestPlanFromUser:
def test_anonymous_is_free(self):
assert _plan_from_user(None) is PlanType.FREE
def test_enum_plan_passthrough(self):
assert _plan_from_user(_FakeUser(PlanType.PRO)) is PlanType.PRO
def test_string_plan_accepted(self):
assert _plan_from_user(_FakeUser("pro")) is PlanType.PRO
def test_garbage_falls_back_to_free(self):
assert _plan_from_user(_FakeUser("nonsense")) is PlanType.FREE
class TestLanguagesEndpoint:
@pytest.mark.asyncio
async def test_exposes_at_least_60_languages(self):
from routes.legacy_routes import get_supported_languages
response = await get_supported_languages()
langs = response["supported_languages"]
assert response["count"] >= 60
assert len(langs) >= 60
@pytest.mark.asyncio
async def test_no_auto_and_canonical_chinese(self):
from routes.legacy_routes import get_supported_languages
langs = (await get_supported_languages())["supported_languages"]
assert "auto" not in langs
assert "zh-CN" in langs and "zh-TW" in langs
@pytest.mark.asyncio
async def test_every_language_has_a_name(self):
from routes.legacy_routes import get_supported_languages
langs = (await get_supported_languages())["supported_languages"]
assert all(name and name != code.upper() for code, name in langs.items())

View File

@@ -0,0 +1,161 @@
"""
Tests for the MinimaxTranslationProvider.
Validates Bug 1 fix:
- default base_url is the public ``api.minimax.io`` host (NOT ``api.minimax.chat``)
- default model is ``MiniMax-M3``
- ``is_available()`` / ``health_check()`` tolerate a missing ``/models`` path
(Minimax does not document it) and only mark the provider down on 401/network error
- translation success, 429 retry, 401 handling
"""
import pytest
from unittest.mock import patch, MagicMock
from requests.exceptions import Timeout
from services.providers.minimax_provider import (
MinimaxTranslationProvider,
MinimaxProviderError,
MINIMAX_RATE_LIMITED,
MINIMAX_INVALID_KEY,
MINIMAX_TIMEOUT,
MINIMAX_SERVICE_ERROR,
)
from services.providers.schemas import TranslationRequest
class TestMinimaxProviderConfig:
"""Defaults must point at the real public endpoint."""
def test_default_base_url_is_public_host(self):
provider = MinimaxTranslationProvider(api_key="k", max_retries=0)
assert provider._base_url == "https://api.minimax.io/v1"
# Regression guard: the old broken host must never come back.
assert "minimax.chat" not in provider._base_url
def test_default_model_is_m3(self):
provider = MinimaxTranslationProvider(api_key="k", max_retries=0)
assert provider._model == "MiniMax-M3"
def test_custom_base_url_respected(self):
provider = MinimaxTranslationProvider(
api_key="k", base_url="https://proxy.example.com/v1", max_retries=0
)
assert provider._base_url == "https://proxy.example.com/v1"
def test_get_name(self):
provider = MinimaxTranslationProvider(api_key="k", max_retries=0)
assert provider.get_name() == "minimax"
class TestMinimaxAvailabilityProbe:
"""is_available/health_check must not fail just because /models 404s."""
@pytest.fixture
def provider(self):
return MinimaxTranslationProvider(api_key="k", max_retries=0)
def _mock_get(self, status_code: int):
mock_response = MagicMock()
mock_response.status_code = status_code
return mock_response
@patch("requests.get")
def test_available_when_models_returns_200(self, mock_get, provider):
mock_get.return_value = self._mock_get(200)
assert provider.is_available() is True
@patch("requests.get")
def test_available_when_models_404(self, mock_get, provider):
# Minimax does not document /models; a 404 must NOT mark it unavailable.
mock_get.return_value = self._mock_get(404)
assert provider.is_available() is True
@patch("requests.get")
def test_unavailable_on_401(self, mock_get, provider):
mock_get.return_value = self._mock_get(401)
assert provider.is_available() is False
@patch("requests.get")
def test_unavailable_on_network_error(self, _mock_get, provider):
def _raise(*a, **kw):
raise Timeout("boom")
with patch("requests.get", side_effect=_raise):
assert provider.is_available() is False
@patch("requests.get")
def test_health_check_tolerates_404(self, mock_get, provider):
mock_get.return_value = self._mock_get(404)
status = provider.health_check()
assert status.available is True
assert status.name == "minimax"
@patch("requests.get")
def test_health_check_marks_down_on_401(self, mock_get, provider):
mock_get.return_value = self._mock_get(401)
status = provider.health_check()
assert status.available is False
class TestMinimaxTranslateText:
@pytest.fixture
def provider(self):
return MinimaxTranslationProvider(api_key="k", model="MiniMax-M3", max_retries=0)
def _mock_post(self, payload, status_code=200):
mock_response = MagicMock()
mock_response.status_code = status_code
mock_response.json.return_value = payload
mock_response.text = ""
return mock_response
@patch("requests.post")
def test_success(self, mock_post, provider):
mock_post.return_value = self._mock_post(
{"choices": [{"message": {"content": "Bonjour"}}], "usage": {}}
)
resp = provider.translate_text(TranslationRequest(text="Hello", target_language="fr"))
assert resp.translated_text == "Bonjour"
assert resp.provider_name == "minimax"
# Verify we hit the public host on the OpenAI-compatible path.
called_url = mock_post.call_args[0][0]
assert called_url == "https://api.minimax.io/v1/chat/completions"
def test_empty_text_short_circuits(self, provider):
resp = provider.translate_text(TranslationRequest(text="", target_language="fr"))
assert resp.translated_text == ""
@patch("requests.post")
def test_invalid_key_returns_error(self, mock_post, provider):
mock_post.return_value = self._mock_post({"error": "bad key"}, status_code=401)
resp = provider.translate_text(TranslationRequest(text="Hello", target_language="fr"))
assert resp.error_code == MINIMAX_INVALID_KEY
# Original text returned on failure.
assert resp.translated_text == "Hello"
@patch("time.sleep")
@patch("requests.post")
def test_rate_limit_then_success(self, mock_post, mock_sleep):
provider = MinimaxTranslationProvider(api_key="k", max_retries=2, retry_delay=0.01)
mock_post.side_effect = [
self._mock_post({"error": "slow down"}, status_code=429),
self._mock_post({"choices": [{"message": {"content": "Hola"}}], "usage": {}}),
]
resp = provider.translate_text(TranslationRequest(text="Hello", target_language="es"))
assert resp.translated_text == "Hola"
assert mock_sleep.called # backoff happened
@patch("requests.post")
def test_service_error_when_empty_choices(self, mock_post, provider):
mock_post.return_value = self._mock_post({"choices": []})
resp = provider.translate_text(TranslationRequest(text="Hello", target_language="fr"))
assert resp.error_code == MINIMAX_SERVICE_ERROR
class TestMinimaxProviderError:
def test_error_carries_code_and_message(self):
err = MinimaxProviderError(MINIMAX_TIMEOUT, "timed out", details={"wait": 1})
assert err.code == MINIMAX_TIMEOUT
assert err.message == "timed out"
assert err.details == {"wait": 1}

View File

@@ -97,11 +97,24 @@ class TestHelperFunctions:
assert "translator" in prompt.lower()
def test_build_system_prompt_custom(self):
"""Test custom system prompt."""
"""A custom prompt AUGMENTS the base translation instructions.
The base prompt is always present — a glossary-only custom prompt
used to produce a system prompt with no translation instruction
at all (fixed 2026-08-29).
"""
custom = "Translate this text formally for business context."
prompt = _build_system_prompt("English", "French", custom)
assert prompt == custom
# Base translation instructions survive
assert "English" in prompt
assert "French" in prompt
assert "translator" in prompt.lower()
# Custom content is appended, not replacing
assert custom in prompt
assert "ADDITIONAL CONTEXT AND INSTRUCTIONS" in prompt
# The base part comes first
assert prompt.index("French") < prompt.index(custom)
class TestOpenAITranslationProvider:

View File

@@ -0,0 +1,192 @@
"""Scanned PDF detection and the Mistral OCR translation path."""
import fitz
import pytest
from config import config
from services.mistral_ocr import MistralOCRClient
from services.providers.base import TranslationProvider
from services.providers.schemas import TranslationRequest, TranslationResponse
from translators.pdf_translator import PDFTranslator
FAKE_OCR_PAGES = [
"# Facture\n\nMontant total : 1 250 euros\n\n![logo](img.png)",
"Le paiement est du sous 30 jours.",
]
# Minimal French → English mapping for the fake provider; anything else is
# prefixed so tests can assert replacement happened.
TRANSLATIONS = {
"Facture": "Invoice",
"Montant total : 1 250 euros": "Total amount: 1,250 euros",
"Le paiement est du sous 30 jours.": "Payment is due within 30 days.",
}
class FakeProvider(TranslationProvider):
"""New-style provider with canned translations (no network)."""
def get_name(self) -> str:
return "fake"
def is_available(self) -> bool:
return True
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
# Pages arrive as multi-line text: translate line by line so the
# canned mapping applies regardless of how lines are grouped.
lines = []
for line in request.text.split("\n"):
stripped = line.strip()
if not stripped:
lines.append(line)
continue
lines.append(TRANSLATIONS.get(stripped, f"[EN] {stripped}"))
return TranslationResponse(
translated_text="\n".join(lines),
provider_name=self.get_name(),
from_cache=False,
)
def _make_scanned_pdf(path):
"""Image-only PDF: one page almost fully covered by a picture, no text."""
pix = fitz.Pixmap(fitz.csRGB, fitz.IRect(0, 0, 10, 10))
pix.clear_with(90)
png_bytes = pix.tobytes("png")
doc = fitz.open()
page = doc.new_page(width=612, height=792)
page.insert_image(fitz.Rect(20, 20, 592, 772), stream=png_bytes)
doc.save(str(path))
doc.close()
return path
def _make_text_pdf(path):
doc = fitz.open()
page = doc.new_page()
page.insert_text(
(72, 100),
"Real selectable text with plenty of characters to exceed the scanned threshold. " * 2,
fontsize=11,
)
doc.save(str(path))
doc.close()
return path
@pytest.fixture
def no_api_key(monkeypatch):
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
monkeypatch.setattr(config, "MISTRAL_API_KEY", "")
monkeypatch.setattr(config, "MISTRAL_OCR_ENABLED", True)
@pytest.fixture
def api_key(monkeypatch):
# Set BOTH the env var and the config attribute: some other test in the
# full suite reloads the config module, and pdf_translator imports
# `config` at call time — after a reload only the env var is still
# visible (the class attributes are re-read from the environment).
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
monkeypatch.setattr(config, "MISTRAL_API_KEY", "test-key")
monkeypatch.setattr(config, "MISTRAL_OCR_ENABLED", True)
class TestScannedDetection:
def test_image_only_pdf_is_scanned(self, tmp_path):
pdf = _make_scanned_pdf(tmp_path / "scan.pdf")
translator = PDFTranslator(provider=None)
assert translator._is_scanned_pdf(pdf) is True
def test_text_pdf_is_not_scanned(self, tmp_path):
pdf = _make_text_pdf(tmp_path / "text.pdf")
translator = PDFTranslator(provider=None)
assert translator._is_scanned_pdf(pdf) is False
def test_title_only_pdf_is_not_scanned(self, tmp_path):
"""A sparse but textual page (no raster image) stays on the layout path."""
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 100), "Facture 2026", fontsize=18)
doc.save(str(tmp_path / "title.pdf"))
doc.close()
translator = PDFTranslator(provider=None)
assert translator._is_scanned_pdf(tmp_path / "title.pdf") is False
class TestMarkdownCleanup:
def test_images_links_headings_removed(self):
md = "# Titre\n\n![img](x.png)\n\n[lien](http://x) texte"
out = PDFTranslator._markdown_to_text(md)
assert "Titre" in out
assert "![img]" not in out
assert "(http://x)" not in out
assert "#" not in out
assert "lien texte" in out
def test_table_pipes_removed(self):
out = PDFTranslator._markdown_to_text("| A | B |\n|---|---|\n| un | deux |")
assert "|" not in out
assert "un" in out and "deux" in out
class TestScannedOCRPath:
def test_translate_scanned_pdf_end_to_end(self, tmp_path, api_key, monkeypatch):
pdf = _make_scanned_pdf(tmp_path / "scan.pdf")
monkeypatch.setattr(
MistralOCRClient,
"extract_pdf_text",
lambda self, path, progress_callback=None: list(FAKE_OCR_PAGES),
)
translator = PDFTranslator(provider=FakeProvider())
# Configure OCR the way the production route does (set_ocr_config).
# Going through the config module is fragile in the full suite:
# another test replaces sys.modules["config"], so a config-module
# patch may never reach the translator.
translator.set_ocr_config(api_key="test-key", enabled=True)
out = tmp_path / "out.pdf"
result = translator.translate_file(pdf, out, "en", "fr")
assert result.exists() and result.stat().st_size > 0
doc = fitz.open(str(result))
text = "\n".join(p.get_text("text") for p in doc)
doc.close()
assert "Invoice" in text
assert "Payment is due within 30 days" in text
assert "Facture" not in text
assert "logo" not in text # markdown images stripped
assert "|" not in text # markdown tables stripped
def test_missing_api_key_raises_clear_error(self, tmp_path, no_api_key):
pdf = _make_scanned_pdf(tmp_path / "scan.pdf")
translator = PDFTranslator(provider=FakeProvider())
with pytest.raises(RuntimeError) as exc:
translator.translate_file(pdf, tmp_path / "out.pdf", "en", "fr")
assert "MISTRAL_API_KEY" in str(exc.value)
def test_disabled_ocr_raises_clear_error(self, tmp_path, monkeypatch):
monkeypatch.setattr(config, "MISTRAL_API_KEY", "test-key")
monkeypatch.setattr(config, "MISTRAL_OCR_ENABLED", False)
pdf = _make_scanned_pdf(tmp_path / "scan.pdf")
translator = PDFTranslator(provider=FakeProvider())
with pytest.raises(RuntimeError):
translator.translate_file(pdf, tmp_path / "out.pdf", "en", "fr")
def test_text_pdf_bypasses_ocr(self, tmp_path, api_key, monkeypatch):
pdf = _make_text_pdf(tmp_path / "text.pdf")
called = {"ocr": False}
def _fail(self, path, progress_callback=None):
called["ocr"] = True
return []
monkeypatch.setattr(MistralOCRClient, "extract_pdf_text", _fail)
translator = PDFTranslator(provider=FakeProvider())
out = tmp_path / "out.pdf"
translator.translate_file(pdf, out, "en", "fr")
assert called["ocr"] is False

View File

@@ -0,0 +1,228 @@
"""Tests for security fixes C1C4 (audit 2026-08-26)."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from routes import translate_routes as tr
from middleware.cleanup import FileCleanupManager
class TestSanitizeUrlFilename:
"""AC1 — path traversal in URL-downloaded filenames is neutralized."""
@pytest.mark.parametrize(
"raw",
[
"../../evil.xlsx",
"..\\..\\evil.docx",
"normal_file.pptx",
"..\t..evil.pdf",
"...",
"",
],
)
def test_traversal_stripped(self, raw):
result = tr._sanitize_url_filename(raw)
# Core guarantee: no traversal or path separators survive
assert ".." not in result
assert "/" not in result
assert "\\" not in result
assert result != ""
def test_long_filename_truncated(self):
raw = "a" * 300 + ".xlsx"
result = tr._sanitize_url_filename(raw)
assert len(result) <= 255
assert result.endswith(".xlsx")
class TestRedirectSsrf:
"""AC2 — redirect to internal address is blocked."""
@pytest.mark.asyncio
async def test_redirect_to_metadata_blocked(self):
def fake_response(status, location=None):
resp = MagicMock()
resp.status_code = status
resp.headers = {"location": location} if location else {}
return resp
class FakeClient:
def build_request(self, method, url):
return (method, url)
async def send(self, req, stream=False):
url = req[1]
if "public.example" in url:
return fake_response(302, "http://169.254.169.254/latest/meta-data")
return fake_response(200)
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
with patch.object(tr.httpx, "AsyncClient", return_value=FakeClient()):
with pytest.raises(tr.TranslateEndpointError) as exc:
await tr.download_from_url("http://public.example/file.xlsx")
assert exc.value.details.get("reason") == "ssrf_blocked"
class TestCleanupOrphanGrace:
"""AC3 — young orphans are not deleted."""
def _manager(self, tmp_path):
m = FileCleanupManager(
upload_dir=tmp_path / "uploads",
output_dir=tmp_path / "outputs",
temp_dir=tmp_path / "temp",
)
for d in (m.upload_dir, m.output_dir, m.temp_dir):
d.mkdir(parents=True, exist_ok=True)
return m
@pytest.mark.asyncio
async def test_young_orphan_kept(self, tmp_path):
"""Orphan younger than the grace period is NOT deleted."""
m = self._manager(tmp_path)
f = m.upload_dir / "recent_orphan.xlsx"
f.write_bytes(b"x")
fake_redis = MagicMock()
fake_redis.keys = AsyncMock(return_value=[])
fake_redis.get = AsyncMock(return_value=None)
with patch(
"middleware.cleanup._get_async_redis", return_value=fake_redis
):
stats = await m.cleanup()
assert f.exists()
@pytest.mark.asyncio
async def test_input_path_key_recognized(self, tmp_path):
"""Files tracked under 'input_path' are not orphans (key mismatch fix)."""
import json as _json
m = self._manager(tmp_path)
f = m.upload_dir / "tracked.xlsx"
f.write_bytes(b"x")
fake_redis = MagicMock()
fake_redis.keys = AsyncMock(return_value=["translation:file:tr_1"])
fake_redis.get = AsyncMock(
return_value=_json.dumps({"input_path": str(f), "user_id": "u1"})
)
with patch(
"middleware.cleanup._get_async_redis", return_value=fake_redis
):
stats = await m.cleanup()
assert f.exists()
assert stats["orphaned_deleted"] == 0
@pytest.mark.asyncio
async def test_old_orphan_deleted(self, tmp_path):
import json as _json
import os
import time
m = self._manager(tmp_path)
f = m.upload_dir / "old_orphan.xlsx"
f.write_bytes(b"x")
old = time.time() - (m.orphan_grace_seconds + 600)
os.utime(f, (old, old))
fake_redis = MagicMock()
fake_redis.keys = AsyncMock(return_value=[])
fake_redis.get = AsyncMock(return_value=None)
with patch(
"middleware.cleanup._get_async_redis", return_value=fake_redis
):
stats = await m.cleanup()
assert not f.exists()
assert stats["orphaned_deleted"] == 1
class TestJobAccessControl:
"""H2 — ownership / token checks on status and download."""
def _job(self, user_id=None, token="tok123"):
return {"id": "tr_abc", "user_id": user_id, "access_token": token}
def _user(self, uid):
u = MagicMock()
u.id = uid
return u
def test_owner_allowed(self):
job = self._job(user_id=7)
assert tr._check_job_access(job, self._user(7), None) is None
def test_other_user_denied(self):
job = self._job(user_id=7)
resp = tr._check_job_access(job, self._user(8), None)
assert resp is not None and resp.status_code == 403
def test_anonymous_caller_on_owned_job_denied(self):
job = self._job(user_id=7)
resp = tr._check_job_access(job, None, "tok123")
assert resp is not None and resp.status_code == 401
def test_anonymous_job_requires_token(self):
job = self._job(user_id=None)
assert tr._check_job_access(job, None, "wrong") is not None
assert tr._check_job_access(job, None, None) is not None
assert tr._check_job_access(job, None, "tok123") is None
def test_old_anonymous_job_without_token_denied(self):
job = {"id": "tr_old", "user_id": None} # job created before the fix
assert tr._check_job_access(job, None, "anything") is not None
class TestZipBomb:
"""H1 — dangerous archives are rejected."""
def _make_zip(self, tmp_path, entries):
import zipfile
p = tmp_path / "bomb.xlsx"
with zipfile.ZipFile(p, "w", zipfile.ZIP_DEFLATED) as zf:
for name, data in entries:
zf.writestr(name, data)
return p
def test_normal_file_accepted(self, tmp_path):
from utils.file_handler import validate_zip_safety
p = self._make_zip(tmp_path, [("sheet1.xml", b"<xml/>ok" * 100)])
validate_zip_safety(p) # no exception
def test_not_a_zip_rejected(self, tmp_path):
from utils.file_handler import validate_zip_safety
p = tmp_path / "fake.xlsx"
p.write_bytes(b"this is not a zip file")
with pytest.raises(ValueError):
validate_zip_safety(p)
def test_high_ratio_rejected(self, tmp_path):
from utils.file_handler import validate_zip_safety
# 50 MB of zeros compresses far beyond the 100:1 ratio cap
p = self._make_zip(tmp_path, [("huge.xml", b"\0" * (50 * 1024 * 1024))])
with pytest.raises(ValueError):
validate_zip_safety(p)
def test_declared_total_too_big_rejected(self, tmp_path):
import zipfile
from unittest.mock import patch as _patch
from utils.file_handler import validate_zip_safety
p = self._make_zip(tmp_path, [("a.xml", b"<xml/>")])
fake_info = MagicMock()
fake_info.is_dir = lambda: False
fake_info.file_size = 5 * 1024 * 1024 * 1024 # 5 GB declared
fake_info.compress_size = 50 * 1024 * 1024
with _patch.object(zipfile.ZipFile, "infolist", return_value=[fake_info]):
with pytest.raises(ValueError):
validate_zip_safety(p)

View File

@@ -30,10 +30,18 @@ def test_validate_invalid_magic_bytes():
assert response.json()["error"] == "CORRUPTED_FILE"
assert "corrompu" in response.json()["message"]
def _minimal_zip() -> bytes:
"""A real minimal ZIP archive (Office files are ZIPs)."""
import io, zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("[Content_Types].xml", "<Types/>")
return buf.getvalue()
def test_validate_valid_file_header():
# Test with a minimal valid-looking zip (Office files are ZIPs)
# FileValidator checks for b"PK\x03\x04"
files = {"file": ("test.docx", b"PK\x03\x04" + b"\x00" * 20, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
# Minimal real ZIP: passes magic-byte AND zip-safety checks
files = {"file": ("test.docx", _minimal_zip(), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
response = client.post(
"/api/v1/translate",
files=files,

View File

@@ -593,7 +593,11 @@ class TestOptionalParameters:
assert response.status_code == 202
def test_accepts_mode_llm(self, authenticated_client):
"""Accepts mode='llm'"""
"""mode='llm' maps to the openrouter engine — Pro and above only.
The default test user is on the Free plan, so the plan-based engine
gate must refuse it (403) instead of silently running a paid engine.
"""
excel_content = create_valid_excel()
response = authenticated_client.post(
TRANSLATE_URL,
@@ -606,7 +610,8 @@ class TestOptionalParameters:
},
data={"target_lang": "fr", "mode": "llm"},
)
assert response.status_code == 202
assert response.status_code == 403
assert "PRO_FEATURE_REQUIRED" in str(response.json())
def test_accepts_webhook_url(self, authenticated_client):
"""Accepts webhook_url parameter"""
@@ -875,7 +880,11 @@ class TestTranslateImagesParameter:
"""Test translate_images parameter in POST /api/v1/translate"""
def test_accepts_translate_images_parameter(self, authenticated_client):
"""Endpoint accepts translate_images form parameter"""
"""translate_images is a Pro+ feature — Free plan gets 403.
The parameter is accepted by the schema; the plan gate refuses it
for the default (Free) test user.
"""
excel_content = create_valid_excel()
response = authenticated_client.post(
TRANSLATE_URL,
@@ -888,4 +897,5 @@ class TestTranslateImagesParameter:
},
data={"target_lang": "fr", "translate_images": "true"},
)
assert response.status_code == 202
assert response.status_code == 403
assert "PRO_FEATURE_REQUIRED" in str(response.json())

View File

@@ -0,0 +1,168 @@
"""
Unit tests for the helper functions in routes/translate_routes.py.
These cover the bug fixes that are pure logic (no full HTTP machinery needed):
- Bug 2: _compute_cost_factor / _provider_model (reads ``_model`` AND ``model``)
- Bug 3: _release_quota_if_needed (releases reserved quota on soft-failure)
- Bug 4: _compute_duration_seconds (UTC-aware, never crashes)
- Bug 5: _cleanup_old_jobs snapshots the jobs dict (no "changed size during iteration")
"""
import asyncio
import time
from datetime import datetime, timezone, timedelta
from unittest.mock import patch, MagicMock
import pytest
from routes import translate_routes as tr
# ---------------------------------------------------------------------------
# Bug 2 — _provider_model / _compute_cost_factor
# ---------------------------------------------------------------------------
class _NewStyleProvider:
"""Mimics services/providers/* classes that store ``self._model``."""
def __init__(self, model):
self._model = model
class _LegacyProvider:
"""Mimics legacy services/translation_service classes with ``self.model``."""
def __init__(self, model):
self.model = model
class TestProviderModel:
def test_reads_new_style_private_attr(self):
assert tr._provider_model(_NewStyleProvider("gpt-4o")) == "gpt-4o"
def test_reads_legacy_public_attr(self):
assert tr._provider_model(_LegacyProvider("claude-sonnet-4")) == "claude-sonnet-4"
def test_prefers_private_when_both_present(self):
class Both:
_model = "private-model"
model = "public-model"
assert tr._provider_model(Both()) == "private-model"
def test_none_provider(self):
assert tr._provider_model(None) == ""
def test_empty_model(self):
assert tr._provider_model(_NewStyleProvider("")) == ""
class TestComputeCostFactor:
def test_claude_is_premium(self):
assert tr._compute_cost_factor(_NewStyleProvider("anthropic/claude-sonnet-4.6")) == 5
def test_gpt4_is_premium(self):
assert tr._compute_cost_factor(_NewStyleProvider("gpt-4o")) == 5
def test_gpt4o_mini_is_standard(self):
# Cheap GPT-4 variants must not be billed at the premium factor.
assert tr._compute_cost_factor(_NewStyleProvider("gpt-4o-mini")) == 1
assert tr._compute_cost_factor(_NewStyleProvider("gpt-4o-nano")) == 1
def test_legacy_gpt4o_mini_is_standard(self):
assert tr._compute_cost_factor(_LegacyProvider("gpt-4o-mini")) == 1
def test_haiku_is_standard(self):
assert tr._compute_cost_factor(_NewStyleProvider("anthropic/claude-3-haiku")) == 1
def test_openrouter_premium_alias_is_premium_without_model(self):
# Regression for the original bug: model read failed (""), so the
# premium tier was never detected. The alias must still bump it to 5.
assert tr._compute_cost_factor(None, "openrouter_premium") == 5
def test_standard_model(self):
assert tr._compute_cost_factor(_NewStyleProvider("deepseek-chat")) == 1
def test_legacy_provider_model_is_read(self):
# Legacy classes expose .model — must also be billed correctly.
assert tr._compute_cost_factor(_LegacyProvider("gpt-4o")) == 5
# ---------------------------------------------------------------------------
# Bug 3 — _release_quota_if_needed
# ---------------------------------------------------------------------------
class TestReleaseQuotaIfNeeded:
@pytest.mark.asyncio
async def test_releases_when_user_id_and_not_recorded(self):
# release_translation_quota is invoked via asyncio.to_thread (a worker
# thread); patching it and asserting the call validates the release path.
with patch.object(tr, "release_translation_quota") as mock_release:
await tr._release_quota_if_needed("user-123", usage_recorded=False, job_id="j1")
mock_release.assert_called_once_with("user-123")
@pytest.mark.asyncio
async def test_skips_when_usage_already_recorded(self):
with patch.object(tr, "release_translation_quota") as mock_release:
await tr._release_quota_if_needed("user-123", usage_recorded=True, job_id="j1")
mock_release.assert_not_called()
@pytest.mark.asyncio
async def test_skips_when_no_user_id(self):
with patch.object(tr, "release_translation_quota") as mock_release:
await tr._release_quota_if_needed(None, usage_recorded=False, job_id="j1")
mock_release.assert_not_called()
@pytest.mark.asyncio
async def test_swallows_release_errors(self):
with patch.object(tr, "release_translation_quota", side_effect=RuntimeError("db down")):
# Must not raise even if the release itself fails.
await tr._release_quota_if_needed("user-123", usage_recorded=False, job_id="j1")
# ---------------------------------------------------------------------------
# Bug 4 — _compute_duration_seconds (UTC-aware, crash-safe)
# ---------------------------------------------------------------------------
class TestComputeDurationSeconds:
def test_recent_timestamp_returns_positive(self):
ts = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat()
dur = tr._compute_duration_seconds(ts)
assert dur >= 9 # allow tiny scheduling slack
def test_z_suffix_handled(self):
ts = (datetime.now(timezone.utc) - timedelta(seconds=5)).strftime("%Y-%m-%dT%H:%M:%SZ")
dur = tr._compute_duration_seconds(ts)
assert dur >= 0
def test_malformed_returns_zero(self):
# Regression for the original bug: a bad timestamp used to crash the
# success path and flip the job to failed. It must now return 0.
assert tr._compute_duration_seconds("not-a-date") == 0.0
def test_empty_returns_zero(self):
assert tr._compute_duration_seconds("") == 0.0
# ---------------------------------------------------------------------------
# Bug 5 — _cleanup_old_jobs snapshots the dict (no resize-during-iteration)
# ---------------------------------------------------------------------------
class TestCleanupOldJobsSnapshots:
def test_cleanup_does_not_raise_when_dict_mutated_concurrently(self, monkeypatch):
# Force cleanup to run now (bypass throttle).
monkeypatch.setattr(tr, "_last_cleanup_ts", 0.0)
monkeypatch.setattr(tr, "_CLEANUP_INTERVAL_SECONDS", 0)
# Two expired jobs.
old_ts = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
tr._translation_jobs.clear()
tr._translation_jobs["j1"] = {"status": "completed", "completed_at": old_ts}
tr._translation_jobs["j2"] = {"status": "failed", "failed_at": old_ts}
tr._translation_jobs["j3"] = {"status": "running"} # not expired
# If cleanup did NOT snapshot, mutating during iteration would raise.
tr._cleanup_old_jobs()
assert "j1" not in tr._translation_jobs
assert "j2" not in tr._translation_jobs
assert "j3" in tr._translation_jobs
def teardown_method(self):
tr._translation_jobs.clear()

View File

@@ -40,7 +40,11 @@ async def test_translate_endpoint_triggers_tracking(client):
with patch(
"routes.translate_routes.storage_tracker.track_file", new_callable=AsyncMock
) as mock_track:
with patch("routes.translate_routes.file_validator.validate_async") as mock_val:
# The upload write is mocked below, so no real archive exists on
# disk: neutralize the zip safety check for this test.
with patch(
"routes.translate_routes.file_validator.validate_async"
) as mock_val, patch("routes.translate_routes.validate_zip_safety"):
mock_val.return_value.is_valid = True
mock_val.return_value.data = {"extension": ".docx", "size_bytes": 500}
@@ -88,7 +92,10 @@ async def test_translate_endpoint_triggers_tracking(client):
async def test_translate_endpoint_handles_hash_failure(client):
app.dependency_overrides[get_authenticated_user] = mock_auth
with patch("routes.translate_routes.file_validator.validate_async") as mock_val:
# No real archive exists on disk (save is mocked): skip the zip check.
with patch(
"routes.translate_routes.file_validator.validate_async"
) as mock_val, patch("routes.translate_routes.validate_zip_safety"):
mock_val.return_value.is_valid = True
mock_val.return_value.data = {"extension": ".docx", "size_bytes": 500}

View File

@@ -564,60 +564,46 @@ class TestPptxChartWhitespace:
</c:chartSpace>
"""
def test_padded_chart_text_via_internal_method(self):
"""The internal chart apply logic should preserve whitespace."""
def test_chart_translation_reaches_output_file(self, tmp_path):
"""Chart translations must reach the OUTPUT FILE (real ChartPart).
python-pptx's ChartPart.blob is read-only — the previous in-memory
`blob = ...` write never landed in the saved .pptx. This end-to-end
test uses a real chart and verifies the chart XML inside the
output ZIP.
"""
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE
provider = MockProvider({"Padded chart title": "Titre avec espaces"})
translator = PowerPointTranslator(provider=provider)
# Build a chart entry by hand (simulating collect time)
chart_xml = etree.fromstring(self.CHART_PADDED_XML.encode("utf-8"))
entries = []
for t_elem in chart_xml.iter(f"{{{_NS_A}}}t"):
text_raw = t_elem.text or ""
text = text_raw.strip()
if not text:
continue
entry = {
"element": t_elem,
"original": text,
"original_raw": text_raw,
"translated": "Titre avec espaces",
"tag": "a:t",
"element_path": translator._get_element_path(t_elem),
}
entries.append(entry)
if not hasattr(translator, "_chart_entries"):
translator._chart_entries = []
class _FakePart:
def __init__(self, blob):
self._blob = blob
@property
def blob(self):
return self._blob
@blob.setter
def blob(self, value):
self._blob = value
fake_part = _FakePart(
etree.tostring(
chart_xml, xml_declaration=True, encoding="UTF-8", standalone=True
)
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5])
chart_data = CategoryChartData()
chart_data.categories = ["A", "B"]
chart_data.add_series("Series 1", (1, 2))
graphic_frame = slide.shapes.add_chart(
XL_CHART_TYPE.COLUMN_CLUSTERED, 10, 10, 400, 300, chart_data
)
translator._chart_entries.append({
"chart_part": fake_part,
"entries": entries,
})
chart = graphic_frame.chart
chart.has_title = True
chart.chart_title.text_frame.text = "Padded chart title"
# Apply
translator._apply_chart_translations(Path("dummy"))
input_file = tmp_path / "chart_in.pptx"
output_file = tmp_path / "chart_out.pptx"
prs.save(str(input_file))
# Re-parse and check whitespace preserved
updated_xml = etree.fromstring(fake_part._blob)
all_t = list(updated_xml.iter(f"{{{_NS_A}}}t"))
# Find the title text
title_text = all_t[0].text or ""
assert " Titre avec espaces " in title_text, (
f"Chart whitespace not preserved: {title_text!r}"
translator.translate_file(input_file, output_file, "fr", "en")
with zipfile.ZipFile(output_file, "r") as zf:
chart_parts = [
n for n in zf.namelist() if n.startswith("ppt/charts/chart")
]
assert chart_parts, "chart part missing from output file"
chart_xml = zf.read(chart_parts[0]).decode("utf-8")
assert "Titre avec espaces" in chart_xml, (
"Chart title translation never reached the output file"
)
assert "Padded chart title" not in chart_xml

View File

@@ -0,0 +1,139 @@
"""PDF quality fixes: table-cell merge guard, bold/italic fonts, unchanged
blocks left untouched (no redaction/rewrite), and stats propagation."""
import fitz
import pytest
from translators.pdf_translator import PDFTranslator
class TestTableCellMergeGuard:
def test_table_cells_never_merge(self):
t = PDFTranslator(provider=None)
a = {
"bbox": (72, 100, 200, 115),
"font_size": 11,
"_is_table_cell": True,
}
b = {
"bbox": (72, 118, 200, 133), # same column, next row
"font_size": 11,
"_is_table_cell": True,
}
assert t._should_merge_blocks(a, b) is False
def test_table_cell_and_paragraph_do_not_merge(self):
t = PDFTranslator(provider=None)
a = {"bbox": (72, 100, 200, 115), "font_size": 11, "_is_table_cell": True}
b = {"bbox": (72, 118, 200, 133), "font_size": 11}
assert t._should_merge_blocks(a, b) is False
def test_regular_paragraphs_still_merge(self):
t = PDFTranslator(provider=None)
a = {"bbox": (72, 100, 300, 115), "font_size": 11}
b = {"bbox": (72, 118, 302, 133), "font_size": 11}
assert t._should_merge_blocks(a, b) is True
class TestBoldItalicFontSelection:
def _capturing_page(self):
page = fitz.open().new_page()
calls = []
original = page.insert_textbox
def capture(rect, text, fontname=None, fontfile=None, fontsize=None, **kw):
calls.append({"fontname": fontname, "fontfile": fontfile})
return 0
page.insert_textbox = capture
return page, calls
def _block(self, **kwargs):
block = {
"bbox": (72, 100, 400, 130),
"text": "Bold heading",
"translated": "Titre en gras",
"font_size": 20,
"color": 0,
"line_count": 1,
"sub_bboxes": [(72, 100, 400, 130)],
}
block.update(kwargs)
return block
def test_bold_block_uses_hebo(self):
page, calls = self._capturing_page()
t = PDFTranslator(provider=None)
t._font_path = None # force base-14 selection
t._write_translated_block(page, self._block(is_bold=True), None, False)
assert calls and calls[0]["fontname"] == "hebo"
def test_italic_block_uses_heit(self):
page, calls = self._capturing_page()
t = PDFTranslator(provider=None)
t._font_path = None
t._write_translated_block(page, self._block(is_italic=True), None, False)
assert calls and calls[0]["fontname"] == "heit"
def test_bold_italic_uses_hebi(self):
page, calls = self._capturing_page()
t = PDFTranslator(provider=None)
t._font_path = None
t._write_translated_block(
page, self._block(is_bold=True, is_italic=True), None, False
)
assert calls and calls[0]["fontname"] == "hebi"
def test_regular_block_keeps_helv(self):
page, calls = self._capturing_page()
t = PDFTranslator(provider=None)
t._font_path = None
t._write_translated_block(page, self._block(), None, False)
assert calls and calls[0]["fontname"] == "helv"
class _IdentityProvider:
"""Returns the input text unchanged (new-style provider)."""
def get_name(self):
return "identity"
def is_available(self):
return True
def translate_text(self, request):
from services.providers.schemas import TranslationResponse
return TranslationResponse(
translated_text=request.text,
provider_name="identity",
from_cache=False,
)
class TestUnchangedBlocksUntouched:
def test_identity_translation_stats_and_no_rewrite(self, tmp_path):
"""Provider returning the source text: stats stay changed=0 (so the
route gate detects it) and blocks are left un-redacted."""
doc = fitz.open()
page = doc.new_page()
page.insert_text((72, 100), "Already in English, nothing to do.", fontsize=11)
src = tmp_path / "en.pdf"
doc.save(str(src))
doc.close()
t = PDFTranslator(provider=_IdentityProvider())
out = tmp_path / "out.pdf"
result = t.translate_file(src, out, "en", "auto")
assert result.exists()
stats = t.get_translation_stats()
assert stats["attempted"] >= 1
assert stats["changed"] == 0
# The text is still there, byte-for-byte same rendering (block was
# not redacted + rewritten in the substitute font).
check = fitz.open(str(result))
text = check[0].get_text("text")
check.close()
assert "Already in English" in text

View File

@@ -190,20 +190,23 @@ class TestParagraphTranslation:
"""Tests for paragraph text translation (AC1)."""
def test_translate_paragraph_runs(self, tmp_path):
"""Test that paragraph runs are translated."""
"""Adjacent runs with identical formatting merge into ONE unit.
"Hello" + " " + "World" (same formatting, rsid-style splits) must be
translated as the whole sentence, not as separate fragments.
"""
mock_provider = MockTranslationProvider(
{
"Hello": "Bonjour",
"World": "Monde",
"Hello World": "Bonjour le monde",
}
)
translator = WordTranslator(provider=mock_provider)
doc = Document()
para = doc.add_paragraph()
run1 = para.add_run("Hello")
run2 = para.add_run(" ")
run3 = para.add_run("World")
para.add_run("Hello")
para.add_run(" ")
para.add_run("World")
input_file = tmp_path / "input.docx"
output_file = tmp_path / "output.docx"
@@ -214,8 +217,41 @@ class TestParagraphTranslation:
doc_out = Document(output_file)
text = doc_out.paragraphs[0].text
assert "Bonjour" in text
assert "Monde" in text
assert text == "Bonjour le monde"
# One merged unit → a single provider call
assert mock_provider._call_count == 1
def test_bold_span_kept_separate_and_coherent(self, tmp_path):
"""A formatting change mid-sentence splits the units; spaces survive."""
mock_provider = MockTranslationProvider(
{
"This is": "Ceci est",
"very important": "très important",
}
)
translator = WordTranslator(provider=mock_provider)
doc = Document()
para = doc.add_paragraph()
para.add_run("This is ")
bold = para.add_run("very important")
bold.bold = True
input_file = tmp_path / "input.docx"
output_file = tmp_path / "output.docx"
doc.save(input_file)
translator.translate_file(input_file, output_file, "fr")
doc_out = Document(output_file)
text = doc_out.paragraphs[0].text
assert text == "Ceci est très important"
# Bold formatting survives on the right span
runs = [r for r in doc_out.paragraphs[0].runs if r.text.strip()]
assert len(runs) == 2
assert runs[1].bold is True
assert runs[1].text == "très important"
def test_empty_paragraphs_not_translated(self, tmp_path):
"""Test that empty paragraphs are not translated."""