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)
169 lines
7.0 KiB
Python
169 lines
7.0 KiB
Python
"""
|
|
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()
|