feat(ui,api): wave 3 — editorial pricing, real cancel, server history, DeepL purge
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m36s

Pricing: full editorial redesign — serif card headers with accent pills
replace the colored font-black blocks, tone sweep across toggle/metrics/
features/CTAs, PLAN_COLORS removed; one design system app-wide.

Translate: decorative titles one step down (CTA hierarchy restored);
glossary and image-translation blocks hidden entirely for free users
(progressive disclosure — three controls for free).

Reviews: XLIFF hint line explains the exchange format; backend errors
routed through a friendly mapper (session/not-found/rate-limit/server).

Landing: fabricated hero UI cards (fake 'Context Engine' overlay)
removed — the photo no longer promises screens that don't exist.

Nav: single DashboardNavLinks component shared by sidebar and mobile
drawer (was duplicated markup).

API: GET /api/v1/translations (user job history, paginated; completed
jobs retained 24h) and POST /api/v1/translations/{id}/cancel —
cooperative cancellation with worker checkpoints before dispatch and
before finalisation, reserved quota released immediately. Translate
monitor now offers a real 'Cancel translation' next to 'Back to start';
recent-jobs list reads server history first, localStorage fallback.

DeepL purge (backend): provider module, registry registration, config
attrs/defaults, dispatch branch, admin settings schema + test branch,
legacy availability block, validation rules, plan provider lists,
error-code mappings, MCP enums, translator prompt mention, related
tests updated/removed. Fallback resolver skips unknown providers, so
stale chains containing 'deepl' degrade gracefully.

Verified: backend 110 tests passed; frontend build exit 0, vitest 9/9,
0 missing i18n keys, eslint 63 errors (vs 64 at HEAD).
This commit is contained in:
2026-08-30 22:42:29 +02:00
parent 52748ee653
commit 67365918ae
35 changed files with 398 additions and 1572 deletions

View File

@@ -122,7 +122,7 @@ def client(users_file: Path, monkeypatch):
"extra_credits": 0,
"max_pages_per_doc": 50,
"max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"],
"allowed_providers": ["google"],
}
monkeypatch.setattr(

View File

@@ -19,8 +19,8 @@ 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_starter_engines(self):
assert _allowed_providers_for_plan(PlanType.STARTER) == {"google"}
def test_pro_adds_cloud_and_openrouter(self):
allowed = _allowed_providers_for_plan(PlanType.PRO)

View File

@@ -1,488 +0,0 @@
"""
Tests for the DeepLTranslationProvider.
"""
import socket
import pytest
from concurrent.futures import TimeoutError as FuturesTimeoutError
from unittest.mock import patch, MagicMock
from services.providers.deepl_provider import (
DeepLTranslationProvider,
DeepLProviderError,
get_deepl_provider,
register_deepl_provider,
DEEPL_QUOTA_EXCEEDED,
DEEPL_INVALID_KEY,
DEEPL_NETWORK_ERROR,
DEEPL_UNSUPPORTED_LANGUAGE,
DEEPL_TEXT_TOO_LONG,
)
from services.providers.schemas import TranslationRequest, TranslationResponse
class TestDeepLProviderError:
"""Tests for DeepLProviderError exception."""
def test_error_creation(self):
"""Test error creation with all fields."""
error = DeepLProviderError(
code=DEEPL_INVALID_KEY,
message="Invalid API key",
details={"provider": "deepl"},
)
assert error.code == DEEPL_INVALID_KEY
assert error.message == "Invalid API key"
assert error.details == {"provider": "deepl"}
def test_error_to_dict(self):
"""Test error serialization."""
error = DeepLProviderError(
code=DEEPL_QUOTA_EXCEEDED,
message="Quota exceeded",
details={"reset_at": "2024-01-16T00:00:00Z"},
)
result = error.to_dict()
assert result["error"] == DEEPL_QUOTA_EXCEEDED
assert result["message"] == "Quota exceeded"
assert result["details"]["reset_at"] == "2024-01-16T00:00:00Z"
def test_error_to_dict_no_details(self):
"""Test error serialization without details."""
error = DeepLProviderError(
code=DEEPL_NETWORK_ERROR,
message="Network error",
)
result = error.to_dict()
assert result["error"] == DEEPL_NETWORK_ERROR
assert result["message"] == "Network error"
assert "details" not in result
class TestDeepLTranslationProvider:
"""Tests for DeepLTranslationProvider."""
@pytest.fixture
def provider(self):
"""Create a DeepL provider instance with Pro key."""
return DeepLTranslationProvider(
api_key="test-pro-key-12345",
use_cache=False,
)
@pytest.fixture
def provider_free(self):
"""Create a DeepL provider instance with Free tier key."""
return DeepLTranslationProvider(
api_key="test-free-key:fx",
use_cache=False,
)
def test_init_requires_api_key(self):
"""Test that initialization requires API key."""
with pytest.raises(ValueError, match="API key is required"):
DeepLTranslationProvider(api_key="")
def test_get_name(self, provider):
"""Test provider name."""
assert provider.get_name() == "deepl"
def test_detect_api_type_pro(self, provider):
"""Test Pro API key detection."""
assert provider._api_type == "pro"
def test_detect_api_type_free(self, provider_free):
"""Test Free API key detection."""
assert provider_free._api_type == "free"
def test_get_api_url_pro(self, provider):
"""Test Pro API URL."""
url = provider._get_api_url()
assert url == "https://api.deepl.com/v2/translate"
def test_get_api_url_free(self, provider_free):
"""Test Free API URL."""
url = provider_free._get_api_url()
assert url == "https://api-free.deepl.com/v2/translate"
def test_normalize_language_code_uppercase(self, provider):
"""Test language code normalization to uppercase."""
assert provider._normalize_language_code("en") == "EN-US"
assert provider._normalize_language_code("fr") == "FR"
assert provider._normalize_language_code("pt") == "PT-BR"
def test_normalize_language_code_preserves_variant(self, provider):
"""Test that language variants are preserved."""
assert provider._normalize_language_code("en-gb") == "EN-GB"
assert provider._normalize_language_code("en-us") == "EN-US"
assert provider._normalize_language_code("pt-pt") == "PT-PT"
def test_normalize_language_code_auto(self, provider):
"""Test auto language code handling."""
assert provider._normalize_language_code("auto") == ""
assert provider._normalize_language_code("") == ""
def test_is_language_supported(self, provider):
"""Test language support checking."""
assert provider._is_language_supported("en") is True
assert provider._is_language_supported("fr") is True
assert provider._is_language_supported("EN-US") is True
assert provider._is_language_supported("XX") is False
def test_translate_text_empty(self, provider):
"""Test translating empty text."""
request = TranslationRequest(text="", target_language="fr")
response = provider.translate_text(request)
assert response.translated_text == ""
assert response.provider_name == "deepl"
assert response.from_cache is False
def test_translate_text_whitespace(self, provider):
"""Test translating whitespace-only text."""
request = TranslationRequest(text=" ", target_language="fr")
response = provider.translate_text(request)
assert response.translated_text == " "
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_translate_text_success(self, mock_get_translator, provider):
"""Test successful translation."""
mock_translator = MagicMock()
mock_translator.translate.return_value = "Bonjour"
mock_get_translator.return_value = mock_translator
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.translated_text == "Bonjour"
assert response.provider_name == "deepl"
assert response.from_cache is False
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_translate_text_with_source_language(self, mock_get_translator, provider):
"""Test translation with explicit source language."""
mock_translator = MagicMock()
mock_translator.translate.return_value = "Bonjour"
mock_get_translator.return_value = mock_translator
request = TranslationRequest(
text="Hello", target_language="fr", source_language="en"
)
response = provider.translate_text(request)
assert response.translated_text == "Bonjour"
def test_translate_text_same_language_skip(self, provider):
"""Test that translation is skipped when source == target."""
request = TranslationRequest(
text="Hello",
target_language="en",
source_language="en",
)
response = provider.translate_text(request)
assert response.translated_text == "Hello"
assert response.from_cache is False
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_translate_text_error_fallback(self, mock_get_translator, provider):
"""Test that translation errors return original text and structured error."""
mock_get_translator.side_effect = Exception("API Error")
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.translated_text == "Hello"
assert response.provider_name == "deepl"
assert response.error is not None
assert response.error_code is not None
def test_translate_batch_empty(self, provider):
"""Test batch translation with empty list."""
responses = provider.translate_batch([])
assert responses == []
@patch.object(DeepLTranslationProvider, "translate_text")
def test_translate_batch(self, mock_translate, provider):
"""Test batch translation."""
mock_translate.side_effect = [
TranslationResponse(translated_text="Bonjour", provider_name="deepl"),
TranslationResponse(translated_text="Monde", provider_name="deepl"),
]
requests = [
TranslationRequest(text="Hello", target_language="fr"),
TranslationRequest(text="World", target_language="fr"),
]
responses = provider.translate_batch(requests)
assert len(responses) == 2
assert responses[0].translated_text == "Bonjour"
assert responses[1].translated_text == "Monde"
def test_health_check(self, provider):
"""Test health check."""
status = provider.health_check()
assert status.name == "deepl"
assert isinstance(status.available, bool)
assert status.latency_ms is not None
class TestDeepLErrorCodes:
"""Tests for DeepL error code handling."""
@pytest.fixture
def provider(self):
"""Create a DeepL provider instance."""
return DeepLTranslationProvider(
api_key="test-key-12345",
use_cache=False,
max_retries=0,
)
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_quota_exceeded_error(self, mock_get_translator, provider):
"""Test quota exceeded error handling."""
mock_get_translator.side_effect = Exception("quota exceeded")
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.error_code == DEEPL_QUOTA_EXCEEDED
assert "quota" in response.error.lower() or "dépassé" in response.error.lower()
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_invalid_key_error(self, mock_get_translator, provider):
"""Test invalid API key error handling."""
mock_get_translator.side_effect = Exception("403 Forbidden - invalid auth")
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.error_code == DEEPL_INVALID_KEY
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_unsupported_language_error(self, mock_get_translator, provider):
"""Test unsupported language error handling."""
mock_get_translator.side_effect = Exception("language not supported")
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.error_code == DEEPL_UNSUPPORTED_LANGUAGE
def test_text_too_long_error(self, provider):
"""Test text too long error handling."""
long_text = "x" * (200 * 1024)
request = TranslationRequest(text=long_text, target_language="fr")
response = provider.translate_text(request)
assert response.error_code == DEEPL_TEXT_TOO_LONG
assert response.error_details is not None
assert "text_length" in response.error_details or "max_length" in response.error_details
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_timeout_exception_maps_to_network_error(self, mock_get_translator, provider):
"""Test that socket.timeout and FuturesTimeoutError map to DEEPL_NETWORK_ERROR."""
mock_get_translator.side_effect = FuturesTimeoutError()
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.error_code == DEEPL_NETWORK_ERROR
assert response.error is not None
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_socket_timeout_maps_to_network_error(self, mock_get_translator, provider):
"""Test that socket.timeout maps to DEEPL_NETWORK_ERROR."""
mock_get_translator.side_effect = socket.timeout("timed out")
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.error_code == DEEPL_NETWORK_ERROR
class TestDeepLProviderCaching:
"""Tests for DeepL provider caching functionality."""
@pytest.fixture
def mock_cache(self):
"""Create a mock cache."""
cache = MagicMock()
cache.get.return_value = None
return cache
def test_cache_hit(self, mock_cache):
"""Test that cache hits return cached result."""
mock_cache.get.return_value = "Cached Translation"
provider = DeepLTranslationProvider(
api_key="test-key-12345",
use_cache=True,
)
provider._cache = mock_cache
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.translated_text == "Cached Translation"
assert response.from_cache is True
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_cache_set_on_miss(self, mock_get_translator, mock_cache):
"""Test that translations are cached on miss."""
mock_translator = MagicMock()
mock_translator.translate.return_value = "Bonjour"
mock_get_translator.return_value = mock_translator
provider = DeepLTranslationProvider(
api_key="test-key-12345",
use_cache=True,
)
provider._cache = mock_cache
request = TranslationRequest(text="Hello", target_language="fr")
provider.translate_text(request)
mock_cache.set.assert_called_once()
class TestDeepLProviderRetry:
"""Tests for DeepL provider retry logic."""
@pytest.fixture
def provider(self):
"""Create a DeepL provider with retry enabled."""
return DeepLTranslationProvider(
api_key="test-key-12345",
use_cache=False,
max_retries=2,
retry_delay=0.01,
)
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_retry_on_network_error(self, mock_get_translator, provider):
"""Test that network errors trigger retry."""
mock_translator = MagicMock()
mock_translator.translate.side_effect = [
Exception("timeout"),
"Bonjour",
]
mock_get_translator.return_value = mock_translator
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.translated_text == "Bonjour"
assert mock_translator.translate.call_count == 2
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_no_retry_on_invalid_key(self, mock_get_translator, provider):
"""Test that invalid key errors do not trigger retry."""
mock_translator = MagicMock()
mock_translator.translate.side_effect = Exception("401 invalid auth")
mock_get_translator.return_value = mock_translator
request = TranslationRequest(text="Hello", target_language="fr")
provider.translate_text(request)
assert mock_translator.translate.call_count == 1
class TestDeepLProviderSingleton:
"""Tests for DeepL provider singleton functions."""
def test_get_deepl_provider_no_config(self):
"""Test get_deepl_provider returns None without config."""
import services.providers.deepl_provider as deepl_module
deepl_module._provider_instance = None
with patch("services.providers.config.ProvidersConfig") as mock_config:
mock_config.DEEPL_API_KEY = ""
result = deepl_module.get_deepl_provider()
assert result is None
def test_get_deepl_provider_with_config(self):
"""Test get_deepl_provider creates instance with config."""
import services.providers.deepl_provider as deepl_module
deepl_module._provider_instance = None
with patch("services.providers.config.ProvidersConfig") as mock_config:
mock_config.DEEPL_API_KEY = "test-key:fx"
mock_config.DEEPL_TIMEOUT = 30
mock_config.DEEPL_MAX_RETRIES = 3
mock_config.DEEPL_RETRY_DELAY = 1.0
provider = deepl_module.get_deepl_provider()
assert provider is not None
assert provider._api_type == "free"
deepl_module._provider_instance = None
class TestDeepLRegistryIntegration:
"""Tests for DeepL provider registry integration."""
def test_register_deepl_provider(self):
"""Test provider registration."""
from services.providers.registry import registry
registry.unregister("deepl")
with patch("services.providers.deepl_provider.get_deepl_provider") as mock_get:
mock_provider = MagicMock()
mock_get.return_value = mock_provider
from services.providers.deepl_provider import register_deepl_provider
result = register_deepl_provider()
assert result == mock_provider
assert "deepl" in registry
registry.unregister("deepl")
def test_register_deepl_provider_no_config(self):
"""Test provider registration when not configured."""
from services.providers.registry import registry
registry.unregister("deepl")
with patch("services.providers.deepl_provider.get_deepl_provider") as mock_get:
mock_get.return_value = None
from services.providers.deepl_provider import register_deepl_provider
result = register_deepl_provider()
assert result is None
assert "deepl" not in registry
class TestLegacyDeepLAdapter:
"""Tests for LegacyDeepLAdapter."""
def test_adapter_not_configured(self):
"""Test adapter when DeepL is not configured."""
with patch("services.providers.deepl_provider.get_deepl_provider") as mock_get:
mock_get.return_value = None
from services.providers.deepl_provider import LegacyDeepLAdapter
adapter = LegacyDeepLAdapter()
with pytest.raises(Exception) as exc_info:
adapter.translate("Hello", "fr")
assert "not configured" in str(exc_info.value).lower()

View File

@@ -589,7 +589,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0,
"max_pages_per_doc": 50,
"max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"],
"allowed_providers": ["google"],
},
)
@@ -616,7 +616,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0,
"max_pages_per_doc": 50,
"max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"],
"allowed_providers": ["google"],
},
)
@@ -652,7 +652,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0,
"max_pages_per_doc": 50,
"max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"],
"allowed_providers": ["google"],
},
)
@@ -690,7 +690,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0,
"max_pages_per_doc": 50,
"max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"],
"allowed_providers": ["google"],
},
)
@@ -728,7 +728,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0,
"max_pages_per_doc": 50,
"max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"],
"allowed_providers": ["google"],
},
)
@@ -764,7 +764,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0,
"max_pages_per_doc": 50,
"max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"],
"allowed_providers": ["google"],
},
)
@@ -800,7 +800,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0,
"max_pages_per_doc": 50,
"max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"],
"allowed_providers": ["google"],
},
)

View File

@@ -125,7 +125,7 @@ def client(users_file: Path, monkeypatch):
"extra_credits": 0,
"max_pages_per_doc": 50,
"max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"],
"allowed_providers": ["google"],
}
monkeypatch.setattr(
@@ -483,7 +483,7 @@ class TestQuotaExceeded:
"extra_credits": 0,
"max_pages_per_doc": 50,
"max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"],
"allowed_providers": ["google"],
}
monkeypatch.setattr(
@@ -535,7 +535,7 @@ class TestQuotaExceeded:
"extra_credits": 0,
"max_pages_per_doc": 50,
"max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"],
"allowed_providers": ["google"],
}
monkeypatch.setattr(

View File

@@ -48,7 +48,7 @@ class TestMakeCacheKey:
def test_different_provider_different_key(self):
k1 = make_cache_key("Hello", "fr", "en", "google")
k2 = make_cache_key("Hello", "fr", "en", "deepl")
k2 = make_cache_key("Hello", "fr", "en", "openai")
assert k1 != k2
def test_different_text_different_key(self):