diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..765ffde --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI Pipeline + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + test: + name: Run Tests and Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest-cov + + - name: Prepare Environment Variables + run: cp .env.example .env + + - name: Run Pytest with Coverage + run: | + pytest --cov=app tests/ --cov-report=xml --cov-report=term + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: your-org/translator + # Optional: Don't fail the build if codecov upload fails + continue-on-error: true + + docker-build: + name: Test Docker Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker Image + uses: docker/build-push-action@v5 + with: + context: . + push: false + tags: translator-api:test + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index f69c23e..140d628 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,8 @@ ENV PATH="/opt/venv/bin:$PATH" # Install python dependencies COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install -r requirements.txt # Stage 2: Production FROM python:3.14.4-slim diff --git a/app/core/config.py b/app/core/config.py index d9bb3a6..639aa8f 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -10,17 +10,17 @@ class Settings(BaseSettings): POSTGRES_DB: str POSTGRES_HOST: str POSTGRES_PORT: str - DUCKLING_URL: str = "http://translator_duckling:8000/parse" + DUCKLING_URL: str # LLM Settings - LLM_PROVIDER: str = "gemini" # gemini or ollama - GEMINI_API_KEY: str | None = None - OLLAMA_BASE_URL: str = "http://localhost:11434" - LLM_MODEL_NAME: str = "gemini-1.5-flash" + LLM_PROVIDER: str + GEMINI_API_KEY: str + OLLAMA_BASE_URL: str + LLM_MODEL_NAME: str # Authentication - API_USERNAME: str = "admin" - API_PASSWORD: str = "changeme" + API_USERNAME: str + API_PASSWORD: str COMPLEXITY_THRESHOLD: int = 50 diff --git a/app/db/session.py b/app/db/session.py index 1ffe960..585b772 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -44,5 +44,5 @@ async def init_db(): logger.info("Database tables verified/created.") except Exception as e: - logger.error(f"Failed to connect to the database or create tables: {e}") + logger.exception(f"Failed to connect to the database or create tables: {e}") raise e diff --git a/app/document_translation/controller.py b/app/document_translation/controller.py index 687d75d..f36b191 100644 --- a/app/document_translation/controller.py +++ b/app/document_translation/controller.py @@ -14,6 +14,10 @@ DocumentNode, is_ast_compatible, ) +from app.pipeline.translation import translate_json_with_llm +from app.pipeline.complexity import calculate_complexity_score +from app.core.config import settings +from app.brands.service import BrandService logger = logging.getLogger(__name__) @@ -22,6 +26,70 @@ def __init__(self, db: AsyncSession): self.db = db self.text_ctl = TextTranslationController(db) + async def _fetch_and_parse_document(self, document_url: str) -> tuple[DocumentNode | None, str, str | None]: + try: + parsed_url = urlparse(document_url) + filename = os.path.basename(parsed_url.path) or "document.json" + except Exception: + filename = "document.json" + + try: + doc_data = await DocumentService.download_json(document_url) + except Exception as e: + return None, filename, f"Failed to fetch document: {str(e)}" + + try: + root_node = json_to_ast(doc_data) + doc_node = DocumentNode(root_node, "json") + return doc_node, filename, None + except Exception as e: + return None, filename, f"Failed to parse document to AST: {str(e)}" + + async def _process_local_node( + self, node, text: str, source_lang: str, target_lang: str, + brand_uuid: str | None, domain_name: str | None, filename: str + ): + seg_payload = TranslationRequest( + text=text, + source_lang=source_lang, + target_lang=target_lang, + ) + try: + res = await self.text_ctl.translate_text( + payload=seg_payload, + brand_uuid=brand_uuid, + domain_name=domain_name, + filename=filename, + property_name=node.path, + ) + if "error" in res: + logger.warning("Failed to translate segment '%s' in path %s: %s", text[:30], node.path, res["error"]) + node.translated_value = text + else: + node.translated_value = res.get("translation", text) + except Exception: + logger.exception("Error translating segment '%s'", text[:30]) + node.translated_value = text + + async def _process_llm_batch( + self, llm_batch: dict, translatable_nodes: list, source_lang: str, target_lang: str, brand_context: dict + ): + if not llm_batch: + return + + try: + translated_batch = await translate_json_with_llm( + llm_batch, source_lang, target_lang, brand_context=brand_context + ) + for node in translatable_nodes: + if node.path in translated_batch: + node.translated_value = translated_batch[node.path] + except Exception: + logger.exception("Failed to batch translate JSON with LLM") + for node in translatable_nodes: + if node.path in llm_batch: + node.translated_value = node.value + async def translate_document( self, payload: DocumentTranslationRequest, @@ -39,51 +107,39 @@ async def translate_document( if not is_in_supported_languages(source_lang, target_lang): return {"error": f"Language pair {source_lang}->{target_lang} is not supported"} - try: - parsed_url = urlparse(document_url) - filename = os.path.basename(parsed_url.path) or "document.json" - except Exception: - filename = "document.json" - - try: - doc_data = await DocumentService.download_json(document_url) - except Exception as e: - return {"error": f"Failed to fetch document: {str(e)}"} - - try: - root_node = json_to_ast(doc_data) - doc_node = DocumentNode(root_node, "json") - except Exception as e: - return {"error": f"Failed to parse document to AST: {str(e)}"} + doc_node, filename, err = await self._fetch_and_parse_document(document_url) + if err: + return {"error": err} translatable_nodes = collect_translatable_nodes(doc_node) + brand_service = BrandService(self.db) + brand_context = await brand_service.get_brand_context(brand_uuid) if brand_uuid else {} + glossary = brand_context.get("glossary", {}) if brand_context else {} + keywords = brand_context.get("keywords", []) if brand_context else [] + + llm_batch: dict[str, str] = {} for node in translatable_nodes: - seg_payload = TranslationRequest( - text=node.value, - source_lang=source_lang, - target_lang=target_lang, + text = node.value + text_lower = text.lower() + + # Flattened complexity checks to reduce nesting + requires_llm = ( + any(term.lower() in text_lower for term in glossary.keys()) or + any(kw.lower() in text_lower for kw in keywords) or + await calculate_complexity_score(text, brand_context) >= settings.COMPLEXITY_THRESHOLD ) - try: - res = await self.text_ctl.translate_text( - payload=seg_payload, - brand_uuid=brand_uuid, - domain_name=domain_name, - filename=filename, - property_name=node.path, + + if requires_llm: + llm_batch[node.path] = text + else: + await self._process_local_node( + node, text, source_lang, target_lang, brand_uuid, domain_name, filename ) - if "error" in res: - logger.warning("Failed to translate segment '%s' in path %s: %s", node.value[:30], node.path, res["error"]) - node.translated_value = node.value - else: - node.translated_value = res.get("translation", node.value) - except Exception: - logger.exception("Error translating segment '%s'", node.value[:30]) - node.translated_value = node.value - # Reconstitute the document from AST - translated_document = doc_node.to_dict() + await self._process_llm_batch(llm_batch, translatable_nodes, source_lang, target_lang, brand_context) + translated_document = doc_node.to_dict() translated_ast_root = json_to_ast(translated_document) translated_doc_node = DocumentNode(translated_ast_root, "json") diff --git a/app/pipeline/complexity.py b/app/pipeline/complexity.py index e9ef80c..a51a6ef 100644 --- a/app/pipeline/complexity.py +++ b/app/pipeline/complexity.py @@ -1,12 +1,157 @@ +import re import logging import textstat +import asyncio +from typing import Optional, Dict +from wordfreq import zipf_frequency +from transformers import pipeline + logger = logging.getLogger(__name__) -def calculate_complexity_score(text: str) -> int: +HTML_TAG_PATTERN = re.compile(r"<[^>]+>") +VAR_PATTERN = re.compile(r"\{\{[^}]*\}\}|\{[^}]*\}|%s|%d") +MARKDOWN_PATTERN = re.compile(r"\[[^\]]*\]\([^)]*\)") + +_readability_pipe = None +_lcp_pipe = None +_models_loaded = False +_cache = {} + +def _load_models(): + global _readability_pipe, _lcp_pipe, _models_loaded + if _models_loaded or not pipeline: + return + try: + logger.info("Loading readability model (v-urushkin/xlm-roberta-base-readability)...") + _readability_pipe = pipeline("text-classification", model="v-urushkin/xlm-roberta-base-readability") + except Exception as e: + logger.exception(f"Failed to load readability model: {e}") + + try: + logger.info("Loading LCP model (roberta-base-complex-word-identification)...") + _lcp_pipe = pipeline("text-classification", model="bhavsarpratik/roberta-base-complex-word-identification") + except Exception as e: + logger.exception(f"Failed to load LCP model: {e}") + + _models_loaded = True + +def _get_wordfreq_score(text: str) -> float: + if not zipf_frequency: + return 0 + words = [w for w in re.findall(r'\b\w+\b', text.lower()) if not w.isnumeric()] + if not words: + return 0 + freqs = [zipf_frequency(w, 'en') for w in words] + valid_freqs = [f for f in freqs if f > 0] + if not valid_freqs: + return 10 + avg_freq = sum(valid_freqs) / len(valid_freqs) + complexity = max(0, (6.0 - avg_freq) * 3) + return min(10, complexity) + +def _get_jargon_score(text: str, brand_context: Optional[Dict], domain_rules: Optional[Dict]) -> int: + score = 0 + text_lower = text.lower() + + if brand_context and "glossary" in brand_context: + for term in brand_context["glossary"].keys(): + if term.lower() in text_lower: + score += 5 + + if domain_rules and "jargon" in domain_rules: + for term in domain_rules["jargon"]: + if term.lower() in text_lower: + score += 5 + + return min(15, score) + +def _get_sentence_length_score(text: str) -> float: + try: + sentences = max(1, textstat.sentence_count(text)) + words = textstat.lexicon_count(text) + avg_words = words / sentences + if avg_words > 20: + return min(10, (avg_words - 20) * 0.5) + except Exception: + pass + return 0.0 + +def _get_model_score(text: str) -> float: + model_score = 0.0 + try: + if _readability_pipe: + res = _readability_pipe(text[:512], truncation=True)[0] + if "complex" in str(res['label']).lower() or "hard" in str(res['label']).lower(): + model_score += 15 * res['score'] + else: + model_score += 5 + except Exception: + pass + try: - flesch = textstat.flesch_reading_ease(text) - return int(100 - max(0.0, min(100.0, flesch))) + if _lcp_pipe: + res = _lcp_pipe(text[:512], truncation=True)[0] + if "complex" in str(res['label']).lower(): + model_score += 15 * res['score'] except Exception: - return 50 + pass + return min(30, model_score) + +async def _get_llm_fallback_score(text: str, current_score: int) -> int: + try: + from app.core.config import settings + from app.llms.model import get_llm + threshold = settings.COMPLEXITY_THRESHOLD + if threshold - 5 <= current_score <= threshold + 5: + llm = get_llm() + prompt = f"Rate the translation complexity of the following text from 0 to 100, where 100 is highly complex. Output ONLY the integer.\n\nText: {text}" + response = await llm.ainvoke(prompt) + llm_score = int(re.search(r'\d+', response.content).group()) + return int((current_score + llm_score) / 2) + except Exception as e: + logger.debug(f"LLM fallback failed: {e}") + return current_score + +async def calculate_complexity_score(text: str, brand_context: Optional[Dict] = None, domain_rules: Optional[Dict] = None) -> int: + if not text: + return 0 + + cache_key = (text, str(brand_context), str(domain_rules)) + if cache_key in _cache: + return _cache[cache_key] + + _load_models() + score = 0 + text_length = len(text) + + # 1. Structural Complexity + if HTML_TAG_PATTERN.search(text): score += 15 + if VAR_PATTERN.search(text): score += 15 + if MARKDOWN_PATTERN.search(text): score += 5 + + if text_length < 15 and score == 0: + return 0 + + # 2. Sentence Length + score += _get_sentence_length_score(text) + + # 3. Vocabulary Complexity + score += _get_wordfreq_score(text) + + # 4. Technical Jargon Density + score += _get_jargon_score(text, brand_context, domain_rules) + + # 5. Readability & LCP Models + score += _get_model_score(text) + + final_score = int(max(0, min(100, score))) + + # 6. LLM Fallback + final_score = await _get_llm_fallback_score(text, final_score) + + _cache[cache_key] = final_score + if len(_cache) > 10000: + _cache.pop(next(iter(_cache))) + return final_score diff --git a/app/pipeline/embeddings.py b/app/pipeline/embeddings.py index 256a6f0..9600723 100644 --- a/app/pipeline/embeddings.py +++ b/app/pipeline/embeddings.py @@ -14,7 +14,7 @@ def get_embedding_model(): _embedding_model = SentenceTransformer('all-MiniLM-L6-v2') logger.info("Model loaded successfully.") except ImportError: - logger.error("sentence-transformers is not installed.") + logger.exception("sentence-transformers is not installed.") raise return _embedding_model diff --git a/app/pipeline/reviewer.py b/app/pipeline/reviewer.py index cd3679d..1445d45 100644 --- a/app/pipeline/reviewer.py +++ b/app/pipeline/reviewer.py @@ -44,14 +44,53 @@ async def fix_translation_with_llm(source_text: str, bad_translation: str, targe return content except Exception as e: - logger.error(f"Failed to fix translation with LLM: {e}") + logger.exception(f"Failed to fix translation with LLM: {e}") return bad_translation -async def review_translations_batch(db: AsyncSession) -> dict: +async def _process_translation(db: AsyncSession, t: Any) -> tuple[bool, bool]: + needs_update = False + reviewed = False + fixed = False + + if t.complexity_score is None: + t.complexity_score = await calculate_complexity_score(t.value) + needs_update = True + + if t.trust_score is None: + if t.score is not None: + t.trust_score = t.score + elif t.translation: + t.trust_score = await asyncio.to_thread(score_translation, t.value, t.translation) + needs_update = True + + if t.trust_score is not None and t.trust_score <= 0.85 and t.complexity_score >= 35: + logger.info(f"Reviewing translation ID {t.id} (Trust: {t.trust_score}, Complexity: {t.complexity_score})") + reviewed = True + + fixed_translation = await fix_translation_with_llm( + source_text=t.value, + bad_translation=t.translation, + target_lang=t.translation_language + ) + + if fixed_translation and fixed_translation != t.translation: + t.translation = fixed_translation + new_trust_score = await asyncio.to_thread(score_translation, t.value, t.translation) + t.trust_score = new_trust_score + t.score = new_trust_score + t.is_verified = True + needs_update = True + fixed = True + + if needs_update: + db.add(t) + await db.commit() + + return reviewed, fixed +async def review_translations_batch(db: AsyncSession) -> dict: logger.info("Starting batch translation review...") - stmt = select(Translation).where( Translation.is_successed == True, ) @@ -63,43 +102,11 @@ async def review_translations_batch(db: AsyncSession) -> dict: fixed_count = 0 for t_raw in translations: - t: Any = t_raw - needs_update = False - - if t.complexity_score is None: - t.complexity_score = calculate_complexity_score(t.value) - needs_update = True - - if t.trust_score is None: - if t.score is not None: - t.trust_score = t.score - else: - if t.translation: - t.trust_score = await asyncio.to_thread(score_translation, t.value, t.translation) - needs_update = True - - if t.trust_score is not None and t.trust_score <= 0.85 and t.complexity_score >= 35: - logger.info(f"Reviewing translation ID {t.id} (Trust: {t.trust_score}, Complexity: {t.complexity_score})") + reviewed, fixed = await _process_translation(db, t_raw) + if reviewed: reviewed_count += 1 - - fixed_translation = await fix_translation_with_llm( - source_text=t.value, - bad_translation=t.translation, - target_lang=t.translation_language - ) - - if fixed_translation and fixed_translation != t.translation: - t.translation = fixed_translation - new_trust_score = await asyncio.to_thread(score_translation, t.value, t.translation) - t.trust_score = new_trust_score - t.score = new_trust_score - t.is_verified = True - needs_update = True - fixed_count += 1 - - if needs_update: - db.add(t) - await db.commit() + if fixed: + fixed_count += 1 logger.info(f"Batch review complete. Reviewed: {reviewed_count}, Fixed: {fixed_count}") return {"reviewed": reviewed_count, "fixed": fixed_count} diff --git a/app/pipeline/translation.py b/app/pipeline/translation.py index 804c19f..583c7eb 100644 --- a/app/pipeline/translation.py +++ b/app/pipeline/translation.py @@ -3,6 +3,7 @@ import logging from typing import Any +import asyncio from app.machine_translation import nllb_service from app.llms.model import get_llm from app.llms.prompts import get_translation_draft_prompt @@ -55,7 +56,7 @@ async def translate_with_llm( return str(translated_list[0]) return content except Exception as e: - logger.error("Failed to parse LLM response: %s", e) + logger.exception("Failed to parse LLM response: %s", e) return content @@ -80,13 +81,29 @@ async def translate( domain_rules: Optional domain rules. similar_examples: Optional similar examples for RAG. """ - import asyncio + - if complexity_score >= settings.COMPLEXITY_THRESHOLD: + requires_llm = False + text_lower = text.lower() + + if brand_context: + glossary = brand_context.get("glossary", {}) + if any(term.lower() in text_lower for term in glossary.keys()): + requires_llm = True + + keywords = brand_context.get("keywords", []) + if any(kw.lower() in text_lower for kw in keywords): + requires_llm = True + + if not requires_llm and complexity_score >= settings.COMPLEXITY_THRESHOLD: + requires_llm = True + + if requires_llm: logger.info( - "Input complexity score is %d/%d. Falling back to LLM translation.", + "Routing to LLM (complexity=%d/%d, brand_context_match=%s).", complexity_score, settings.COMPLEXITY_THRESHOLD, + requires_llm and complexity_score < settings.COMPLEXITY_THRESHOLD ) return await translate_with_llm( text, source_lang, target_lang, brand_context, domain_rules, similar_examples @@ -99,3 +116,44 @@ async def translate( target_lang, ) return await asyncio.to_thread(nllb_service.translate_text, text, source_lang, target_lang) + + +async def translate_json_with_llm( + pruned_json: dict[str, str], + source_lang: str, + target_lang: str, + brand_context: dict[str, Any] | None = None, + domain_rules: dict[str, Any] | None = None, +) -> dict[str, str]: + """Translates a dictionary of flattened/pruned json nodes returning a dict with identical keys.""" + if not pruned_json: + return {} + + ctx = brand_context or {} + llm = get_llm() + prompt = get_translation_draft_prompt() + chain = prompt | llm + + # Dump the pruned json into the prompt + response = await chain.ainvoke({ + "source_language": source_lang, + "target_language": target_lang, + "industry": ctx.get("industry", "General"), + "summary": ctx.get("summary", "Translate this JSON object values. Return ONLY valid JSON with identical keys."), + "glossary": json.dumps(ctx.get("glossary", {})), + "domain_rules": json.dumps(domain_rules or {}), + "rag_examples": "None", + "texts": json.dumps(pruned_json, ensure_ascii=False), + }) + + raw_content = response.content + content = str(raw_content) if not isinstance(raw_content, list) else " ".join(map(str, raw_content)) + + # Strip markdown codeblocks + content_clean = content.replace("```json", "").replace("```", "").strip() + try: + translated_dict = json.loads(content_clean) + return translated_dict + except json.JSONDecodeError: + logger.exception("Failed to parse LLM JSON batch response.") + return {} diff --git a/app/text_translation/controller.py b/app/text_translation/controller.py index 3ab05f0..138011c 100644 --- a/app/text_translation/controller.py +++ b/app/text_translation/controller.py @@ -1,3 +1,4 @@ +from bokeh.core.property.any import Any import time import logging import re @@ -21,6 +22,8 @@ logger = logging.getLogger(__name__) +trustedScore = 0.85 + class TextTranslationController: def __init__(self, db: AsyncSession): self.db = db @@ -133,9 +136,9 @@ async def translate_text( if not is_in_supported_languages(source_lang, target_lang): return {"error": f"Language pair {source_lang}->{target_lang} is not supported"} - cached = await self.translation_svc.find_cached(text, source_lang, target_lang) - if cached and cached.trust_score is not None and cached.trust_score >= 0.85: - complexity_score = calculate_complexity_score(text) + cached: Any = await self.translation_svc.find_cached(text, source_lang, target_lang) + if cached and cached.trust_score is not None and float(cached.trust_score) >= trustedScore: + complexity_score = await calculate_complexity_score(text) return { "message": "Translation retrieved from cache", "translation": cached.translation, @@ -147,7 +150,7 @@ async def translate_text( brand_context, domain_rules = await self._compile_context(brand_uuid, domain_name, text, target_lang) - complexity_score = calculate_complexity_score(text) + complexity_score = await calculate_complexity_score(text, brand_context, domain_rules) similar_examples = [] if complexity_score >= settings.COMPLEXITY_THRESHOLD: diff --git a/docs/superpowers/plans/2026-06-15-e2e-testing-implementation.md b/docs/superpowers/plans/2026-06-15-e2e-testing-implementation.md new file mode 100644 index 0000000..05df802 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-e2e-testing-implementation.md @@ -0,0 +1,355 @@ +# E2E Testing for CLI and Endpoints Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement a new end-to-end test suite (`tests/test_e2e.py`) to verify the translation CLI and all API endpoints. + +**Architecture:** We use `typer.testing.CliRunner` to execute the CLI commands in-process and `httpx.AsyncClient` to test the API endpoints. Third-party integrations (DB, Duckling, models, LLM) are patched at the boundary layer. + +**Tech Stack:** pytest, pytest-mock, pytest-asyncio, httpx, typer. + +--- + +### Task 1: Create the E2E Test File + +**Files:** +- Create: `tests/test_e2e.py` + +- [ ] **Step 1: Write E2E test suite file containing CLI and Endpoint tests** + +Create the file `tests/test_e2e.py` with the following implementation: + +```python +import json +import pytest +from pathlib import Path +from unittest.mock import AsyncMock, patch, MagicMock +from httpx import AsyncClient +import typer +from typer.testing import CliRunner + +from app.main import app +from app.text_translation.controller import TextTranslationController +from app.brands.models import Brand +from app.domains.models import Domain +from scripts.translate_json import app as cli_app + + +# ===================================================================== # +# CLI E2E Tests +# ===================================================================== # + +def test_cli_init_success(mocker): + """Test the translator init command end-to-end under successful conditions.""" + mocker.patch("scripts.translate_json.init_db", new_callable=AsyncMock) + + mock_conn = AsyncMock() + mock_conn.execute = AsyncMock() + + mock_begin = MagicMock() + mock_begin.__aenter__ = AsyncMock(return_value=mock_conn) + mock_begin.__aexit__ = AsyncMock() + + mocker.patch("scripts.translate_json.engine.begin", return_value=mock_begin) + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) + + mock_llm = AsyncMock() + mock_llm.ainvoke = AsyncMock() + mocker.patch("scripts.translate_json.get_llm", return_value=mock_llm) + + mocker.patch("scripts.translate_json.nllb_service.preload_models", return_value=None) + mocker.patch("scripts.translate_json.get_embedding_model", return_value=None) + mocker.patch("scripts.translate_json._get_model", return_value=None) + mocker.patch("scripts.translate_json.nltk.data.find", return_value=True) + + runner = CliRunner() + result = runner.invoke(cli_app, ["init"]) + assert result.exit_code == 0 + assert "Init complete" in result.output + + +def test_cli_translate_success(tmp_path, mocker): + """Test translating a JSON document via the CLI end-to-end.""" + input_file = tmp_path / "en.json" + output_file = tmp_path / "ar.json" + + input_data = {"welcome": "Hello", "menu": {"title": "Main"}} + input_file.write_text(json.dumps(input_data), encoding="utf-8") + + mocker.patch("scripts.translate_json.init_db", new_callable=AsyncMock) + + mock_translate_text = AsyncMock(side_effect=lambda payload, **kwargs: {"translation": f"TR_{payload.text}"}) + mocker.patch( + "scripts.translate_json.TextTranslationController.translate_text", + new=mock_translate_text + ) + + runner = CliRunner() + result = runner.invoke( + cli_app, + [ + "translate", + "-i", str(input_file), + "-o", str(output_file), + "-t", "ar", + "-s", "en" + ] + ) + + assert result.exit_code == 0 + assert "Successfully translated" in result.output + + assert output_file.exists() + output_data = json.loads(output_file.read_text(encoding="utf-8")) + assert output_data["welcome"] == "TR_Hello" + assert output_data["menu"]["title"] == "TR_Main" + + +def test_cli_translate_missing_input(tmp_path): + """Test CLI translate fails when the input file is missing.""" + output_file = tmp_path / "ar.json" + runner = CliRunner() + result = runner.invoke( + cli_app, + [ + "translate", + "-i", "nonexistent.json", + "-o", str(output_file), + "-t", "ar" + ] + ) + assert result.exit_code == 1 + assert "does not exist" in result.output + + +# ===================================================================== # +# Endpoint E2E Tests +# ===================================================================== # + +@pytest.mark.asyncio +async def test_endpoint_health(client: AsyncClient, mocker): + """Test health check route.""" + mocker.patch("app.main.health_status", {"db": "ok", "duckling": "ok", "models": "ok", "llm": "ok"}) + + response = await client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + + +@pytest.mark.asyncio +async def test_endpoint_translate_success(client: AsyncClient, mocker): + """Test translating text via translation route.""" + mocker.patch( + "app.text_translation.router.TextTranslationController.translate_text", + new_callable=AsyncMock, + return_value={ + "translation": "Bonjour", + "score": 0.9, + "complexity_score": 20, + "detected_input_lang": "en" + } + ) + response = await client.post( + "/api/v1/translate", + json={"text": "Hello", "source_lang": "en", "target_lang": "fr"} + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["translation"] == "Bonjour" + + +@pytest.mark.asyncio +async def test_endpoint_document_success(client: AsyncClient, mocker): + """Test translating a document via document route.""" + mocker.patch( + "app.document_translation.router.DocumentTranslationController.translate_document", + new_callable=AsyncMock, + return_value={ + "message": "Document translation completed successfully", + "data": { + "document_url": "https://example.com/doc.json", + "source_lang": "en", + "target_lang": "fr" + }, + "translated_document": {"greeting": "Bonjour"} + } + ) + response = await client.post( + "/api/v1/document", + json={ + "document_url": "https://example.com/doc.json", + "source_lang": "en", + "target_lang": "fr" + } + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["translated_document"]["greeting"] == "Bonjour" + + +@pytest.mark.asyncio +async def test_endpoint_brands_crud(client: AsyncClient, mocker): + """Test CRUD operations on Brands endpoints.""" + mock_brand = Brand( + id=1, + uuid="123e4567-e89b-12d3-a456-426614174000", + name="BrandName", + industry="Tech", + keywords=[], + entities=[] + ) + + # Create + mocker.patch( + "app.brands.router.BrandController.create", + new_callable=AsyncMock, + return_value=mock_brand + ) + create_resp = await client.post( + "/api/v1/brands", + json={"name": "BrandName", "industry": "Tech"} + ) + assert create_resp.status_code == 201 + assert create_resp.json()["data"]["uuid"] == "123e4567-e89b-12d3-a456-426614174000" + + # Get + mocker.patch( + "app.brands.router.BrandController.get_by_uuid", + new_callable=AsyncMock, + return_value=mock_brand + ) + get_resp = await client.get("/api/v1/brands/123e4567-e89b-12d3-a456-426614174000") + assert get_resp.status_code == 200 + assert get_resp.json()["data"]["name"] == "BrandName" + + # Update + mocker.patch( + "app.brands.router.BrandController.update", + new_callable=AsyncMock, + return_value=mock_brand + ) + put_resp = await client.put( + "/api/v1/brands/123e4567-e89b-12d3-a456-426614174000", + json={"name": "NewBrandName"} + ) + assert put_resp.status_code == 200 + + # Delete + mocker.patch( + "app.brands.router.BrandController.delete", + new_callable=AsyncMock, + return_value=True + ) + del_resp = await client.delete("/api/v1/brands/123e4567-e89b-12d3-a456-426614174000") + assert del_resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_endpoint_domains_crud(client: AsyncClient, mocker): + """Test CRUD operations on Domains endpoints.""" + mock_domain = Domain( + uuid="123e4567-e89b-12d3-a456-426614174001", + name="ui", + description="UI elements", + content_types=["button"], + rules={"creativity": "low"} + ) + + # Create + mocker.patch( + "app.domains.router.DomainController.get_by_name", + new_callable=AsyncMock, + return_value=None + ) + mocker.patch( + "app.domains.router.DomainController.create", + new_callable=AsyncMock, + return_value=mock_domain + ) + create_resp = await client.post( + "/api/v1/domains/", + json={ + "name": "ui", + "description": "UI elements", + "content_types": ["button"], + "rules": {"creativity": "low"} + } + ) + assert create_resp.status_code == 201 + assert create_resp.json()["data"]["name"] == "ui" + + # List + mocker.patch( + "app.domains.router.DomainController.list_domains", + new_callable=AsyncMock, + return_value=[mock_domain] + ) + list_resp = await client.get("/api/v1/domains/") + assert list_resp.status_code == 200 + assert len(list_resp.json()["data"]) == 1 + + # Get + mocker.patch( + "app.domains.router.DomainController.get_by_name", + new_callable=AsyncMock, + return_value=mock_domain + ) + get_resp = await client.get("/api/v1/domains/ui") + assert get_resp.status_code == 200 + assert get_resp.json()["data"]["uuid"] == "123e4567-e89b-12d3-a456-426614174001" + + # Update + mocker.patch( + "app.domains.router.DomainController.update", + new_callable=AsyncMock, + return_value=mock_domain + ) + put_resp = await client.put( + "/api/v1/domains/ui", + json={"description": "New description"} + ) + assert put_resp.status_code == 200 + + # Delete + mocker.patch( + "app.domains.router.DomainController.delete", + new_callable=AsyncMock, + return_value=True + ) + del_resp = await client.delete("/api/v1/domains/ui") + assert del_resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_endpoint_review_start(client: AsyncClient, mocker): + """Test triggering background review batch.""" + response = await client.post("/api/v1/review/start") + assert response.status_code == 202 + data = response.json() + assert data["success"] is True + assert "started" in data["message"] +``` + +- [ ] **Step 2: Run new E2E tests to verify they all pass** + +Run: `conda run -n translator python -m pytest tests/test_e2e.py -v` +Expected: 10 tests passed successfully. + +- [ ] **Step 3: Run the entire test suite to ensure no regressions** + +Run: `conda run -n translator python -m pytest` +Expected: 31 tests passed successfully. + +- [ ] **Step 4: Commit the new E2E tests** + +Run: +```bash +git add tests/test_e2e.py docs/superpowers/plans/2026-06-15-e2e-testing-implementation.md +git commit -m "feat: add CLI and Endpoint E2E test suite" +``` diff --git a/docs/superpowers/specs/2026-06-15-e2e-testing-design.md b/docs/superpowers/specs/2026-06-15-e2e-testing-design.md new file mode 100644 index 0000000..87cd2b7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-e2e-testing-design.md @@ -0,0 +1,44 @@ +# End-to-End Testing for CLI and Endpoints Design + +## Goal +Implement a robust, fast, and comprehensive end-to-end (E2E) test suite (`tests/test_e2e.py`) to verify the behavior of both the translation CLI (`scripts/translate_json.py`) and the application API endpoints under a mocked/controlled environment. + +## Design + +### 1. CLI E2E Tests +We will verify the Typer CLI commands using `typer.testing.CliRunner`. + +* **`init` command**: + * Tests that all startup dependency checks (Database, Duckling, LLM, translation/embedding/quality models, NLTK tokenizer) are triggered. + * Mocks the checks at the boundary level: + * Database: `init_db()` and `engine.begin()`. + * Duckling: `httpx.AsyncClient.post()` response. + * LLM: `get_llm().ainvoke()`. + * Models: `preload_models()`, `get_embedding_model()`, `_get_model()`, and `nltk.download()`. + * Verifies correct console output and exit code (0 for success, non-zero on failure). +* **`translate` command**: + * Takes an input file, output file, target language, and optional arguments. + * Uses pytest's `tmp_path` fixture to manage test-isolated files. + * Mocks the main translation controller (`TextTranslationController.translate_text`) to return a simulated response immediately. + * Verifies that the CLI processes the AST, executes translations for translatable nodes, verifies AST compatibility, and writes the translated JSON back to the destination. + * Verifies that errors (such as a missing input file) are handled gracefully with the correct exit code. + +### 2. API Endpoints E2E Tests +We will verify the FastAPI endpoint paths using `httpx.AsyncClient` along with the valid authentication header (`TEST_AUTH` from `tests/conftest.py`). + +* **Health Check (`/health`)**: + * Verifies that the health endpoint checks all components and returns a 200 status code. +* **Text Translation (`/api/v1/translate`)**: + * Verifies request parameter validation, authentication, and successful translation routing. + * Mocks downstream translation services to avoid model downloads. +* **Document Translation (`/api/v1/document`)**: + * Verifies JSON fetching, AST parsing, translation, and AST verification. +* **Brand CRUD (`/api/v1/brands`)**: + * Tests POST, GET, PUT, and DELETE operations. +* **Domain CRUD (`/api/v1/domains`)**: + * Tests POST, GET, PUT, and DELETE operations. +* **Reviewer Module (`/api/v1/review/start`)**: + * Verifies triggering the background review process and returning 202 Accepted. + +## Proposed Code Structure +We will create a single new file: `tests/test_e2e.py`. All tests will run inside `pytest` using the conda environment `translator`. diff --git a/requirements.txt b/requirements.txt index 100314b..369af69 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,4 +25,6 @@ sentence-transformers langchain-ollama boto3 typer -aiofiles \ No newline at end of file +aiofiles +wordfreq +pytest-cov diff --git a/scripts/migrate_columns.py b/scripts/migrate_columns.py index 60e3349..4d50ecb 100644 --- a/scripts/migrate_columns.py +++ b/scripts/migrate_columns.py @@ -12,19 +12,19 @@ async def migrate(): logger.info("Adding trust_score...") await conn.execute(text("ALTER TABLE translations ADD COLUMN IF NOT EXISTS trust_score FLOAT")) except Exception as e: - logger.error(f"Error adding trust_score: {e}") + logger.exception(f"Error adding trust_score: {e}") try: logger.info("Adding complexity_score...") await conn.execute(text("ALTER TABLE translations ADD COLUMN IF NOT EXISTS complexity_score FLOAT")) except Exception as e: - logger.error(f"Error adding complexity_score: {e}") + logger.exception(f"Error adding complexity_score: {e}") try: logger.info("Backfilling trust_score from score...") await conn.execute(text("UPDATE translations SET trust_score = score WHERE trust_score IS NULL")) except Exception as e: - logger.error(f"Error backfilling trust_score: {e}") + logger.exception(f"Error backfilling trust_score: {e}") logger.info("Migration complete.") diff --git a/tests/conftest.py b/tests/conftest.py index 7c3ffeb..fd008b8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,8 +12,10 @@ class MockAsyncSession: pass +from app.core.config import settings + # Test credentials matching the defaults in config -TEST_AUTH = BasicAuth(username="admin", password="changeme") +TEST_AUTH = BasicAuth(username=settings.API_USERNAME, password=settings.API_PASSWORD) @pytest.fixture diff --git a/tests/test_brands.py b/tests/test_brands.py index bb8068a..99d0376 100644 --- a/tests/test_brands.py +++ b/tests/test_brands.py @@ -7,9 +7,9 @@ @pytest.mark.asyncio async def test_create_brand(client: AsyncClient, mocker): - mock_brand = Brand(id=1, uuid="test-uuid-123", name="TestBrand", industry="Tech") + mock_brand = Brand(id=1, uuid="test-uuid-123", name="TestBrand", industry="Tech", keywords=[], entities=[]) mocker.patch( - "app.brands.router.BrandService.create", + "app.brands.router.BrandController.create", new_callable=AsyncMock, return_value=mock_brand, ) @@ -28,9 +28,9 @@ async def test_create_brand(client: AsyncClient, mocker): @pytest.mark.asyncio async def test_get_brand(client: AsyncClient, mocker): - mock_brand = Brand(id=1, uuid="test-uuid-123", name="TestBrand", industry="Tech") + mock_brand = Brand(id=1, uuid="test-uuid-123", name="TestBrand", industry="Tech", keywords=[], entities=[]) mocker.patch( - "app.brands.router.BrandService.get_by_uuid", + "app.brands.router.BrandController.get_by_uuid", new_callable=AsyncMock, return_value=mock_brand, ) @@ -45,7 +45,7 @@ async def test_get_brand(client: AsyncClient, mocker): @pytest.mark.asyncio async def test_get_brand_not_found(client: AsyncClient, mocker): mocker.patch( - "app.brands.router.BrandService.get_by_uuid", + "app.brands.router.BrandController.get_by_uuid", new_callable=AsyncMock, return_value=None, ) diff --git a/tests/test_document_translation.py b/tests/test_document_translation.py index c1e0aca..c6eaa04 100644 --- a/tests/test_document_translation.py +++ b/tests/test_document_translation.py @@ -9,7 +9,7 @@ DocumentNode, TextNode, ) -from app.document_translation.controller import translate_document_controller +from app.document_translation.controller import DocumentTranslationController from app.document_translation.schemas import DocumentTranslationRequest @@ -109,9 +109,9 @@ async def mock_translate_text(payload, brand_uuid, domain_name, filename, proper brand_uuid=None ) - result = await translate_document_controller( + ctl = DocumentTranslationController(db_mock) + result = await ctl.translate_document( payload=request_payload, - db=db_mock, brand_uuid=None, domain_name=None ) @@ -134,9 +134,9 @@ async def test_translate_document_controller_unsupported_languages(mocker): brand_uuid=None ) - result = await translate_document_controller( - payload=request_payload, - db=db_mock + ctl = DocumentTranslationController(db_mock) + result = await ctl.translate_document( + payload=request_payload ) assert "error" in result @@ -159,9 +159,9 @@ async def test_translate_document_controller_http_failure(mocker): brand_uuid=None ) - result = await translate_document_controller( - payload=request_payload, - db=db_mock + ctl = DocumentTranslationController(db_mock) + result = await ctl.translate_document( + payload=request_payload ) assert "error" in result @@ -203,7 +203,7 @@ async def test_translate_document_endpoint_integration(client: AsyncClient, mock async def test_translate_text_controller_cache_hit_fields(mocker): from app.text_translation.models import Translation from app.text_translation.schemas import TranslationRequest - from app.text_translation.controller import translate_text_controller + from app.text_translation.controller import TextTranslationController db_mock = AsyncMock() mock_translation = Translation( @@ -214,6 +214,11 @@ async def test_translate_text_controller_cache_hit_fields(mocker): detected_input_lang="en" ) + mocker.patch( + "app.text_translation.controller.is_source_target_compatible", + return_value={"compatible": True, "detected_lang": "en"} + ) + mocker.patch( "app.text_translation.controller.TranslationService.find_cached", return_value=mock_translation @@ -225,7 +230,8 @@ async def test_translate_text_controller_cache_hit_fields(mocker): target_lang="es" ) - result = await translate_text_controller(payload, db=db_mock) + ctl = TextTranslationController(db_mock) + result = await ctl.translate_text(payload) assert result["cached"] is True assert result["translation"] == "Hola mundo" assert result["score"] == 0.95 @@ -237,7 +243,7 @@ async def test_translate_text_controller_cache_hit_fields(mocker): async def test_translate_text_controller_llm_rag_lookup(mocker): from app.text_translation.models import Translation from app.text_translation.schemas import TranslationRequest - from app.text_translation.controller import translate_text_controller + from app.text_translation.controller import TextTranslationController db_mock = AsyncMock() @@ -293,7 +299,8 @@ async def test_translate_text_controller_llm_rag_lookup(mocker): target_lang="es" ) - result = await translate_text_controller(payload, db=db_mock) + ctl = TextTranslationController(db_mock) + result = await ctl.translate_text(payload) assert result["translation"] == "Hola mundito" # Verify mock_translate was called with the RAG example diff --git a/tests/test_domains.py b/tests/test_domains.py index 986c038..6ae39b6 100644 --- a/tests/test_domains.py +++ b/tests/test_domains.py @@ -8,7 +8,7 @@ @pytest.mark.asyncio async def test_create_domain(client: AsyncClient, mocker): mock_domain = Domain( - uuid="test-uuid-123", + uuid="123e4567-e89b-12d3-a456-426614174000", name="test-domain", description="A test domain description", content_types=["button", "label"], @@ -57,7 +57,7 @@ async def test_create_domain(client: AsyncClient, mocker): @pytest.mark.asyncio async def test_create_domain_already_exists(client: AsyncClient, mocker): mock_domain = Domain( - uuid="test-uuid-123", + uuid="123e4567-e89b-12d3-a456-426614174000", name="test-domain", description="A test domain description", content_types=["button", "label"], @@ -87,14 +87,14 @@ async def test_create_domain_already_exists(client: AsyncClient, mocker): async def test_list_domains(client: AsyncClient, mocker): mock_domains = [ Domain( - uuid="uuid-1", + uuid="123e4567-e89b-12d3-a456-426614174001", name="ui", description="UI domain", content_types=["button"], rules={"creativity": "low"}, ), Domain( - uuid="uuid-2", + uuid="123e4567-e89b-12d3-a456-426614174002", name="marketing", description="Marketing domain", content_types=["ad_copy"], @@ -119,7 +119,7 @@ async def test_list_domains(client: AsyncClient, mocker): @pytest.mark.asyncio async def test_get_domain_by_name(client: AsyncClient, mocker): mock_domain = Domain( - uuid="uuid-1", + uuid="123e4567-e89b-12d3-a456-426614174001", name="ui", description="UI domain", content_types=["button"], @@ -156,7 +156,7 @@ async def test_get_domain_not_found(client: AsyncClient, mocker): @pytest.mark.asyncio async def test_update_domain(client: AsyncClient, mocker): mock_domain = Domain( - uuid="uuid-1", + uuid="123e4567-e89b-12d3-a456-426614174001", name="ui", description="Updated UI domain", content_types=["button"], diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..a7c12d0 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,397 @@ +import json +import pytest +from unittest.mock import AsyncMock, MagicMock +from httpx import AsyncClient +from typer.testing import CliRunner + +from app.brands.models import Brand +from app.domains.models import Domain +from scripts.translate_json import app as cli_app + + +# ===================================================================== # +# CLI E2E Tests +# ===================================================================== # + +def test_cli_init_success(mocker): + """Test the translator init command end-to-end under successful conditions.""" + mock_init_db = mocker.patch("scripts.translate_json.init_db", new_callable=AsyncMock) + + mock_conn = AsyncMock() + mock_conn.execute = AsyncMock() + + mock_begin = MagicMock() + mock_begin.__aenter__ = AsyncMock(return_value=mock_conn) + mock_begin.__aexit__ = AsyncMock() + + mock_engine = MagicMock() + mock_engine.begin = MagicMock(return_value=mock_begin) + mocker.patch("scripts.translate_json.engine", mock_engine) + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_http_post = mocker.patch( + "httpx.AsyncClient.post", + new_callable=AsyncMock, + return_value=mock_response + ) + + mock_llm = AsyncMock() + mock_llm.ainvoke = AsyncMock() + mocker.patch("app.llms.model.get_llm", return_value=mock_llm) + + mocker.patch("app.machine_translation.nllb_service.NLLBService.preload_models", return_value=None) + mocker.patch("app.pipeline.embeddings.get_embedding_model", return_value=None) + mocker.patch("app.pipeline.quality._get_model", return_value=None) + mocker.patch("nltk.data.find", return_value=True) + + runner = CliRunner() + result = runner.invoke(cli_app, ["init"]) + assert result.exit_code == 0 + assert "Init complete" in result.output + + # Assert mock invocations + mock_init_db.assert_called_once() + mock_http_post.assert_called_once() + mock_llm.ainvoke.assert_called_once_with("ping") + + +def test_cli_translate_success(tmp_path, mocker): + """Test translating a JSON document via the CLI end-to-end.""" + input_file = tmp_path / "en.json" + output_file = tmp_path / "ar.json" + + input_data = {"welcome": "Hello", "menu": {"title": "Main"}} + input_file.write_text(json.dumps(input_data), encoding="utf-8") + + mocker.patch("scripts.translate_json.init_db", new_callable=AsyncMock) + + mock_translate_text = AsyncMock( + side_effect=lambda payload, **kwargs: {"translation": f"TR_{payload.text}"} + ) + mocker.patch( + "scripts.translate_json.TextTranslationController.translate_text", + new=mock_translate_text + ) + + runner = CliRunner() + result = runner.invoke( + cli_app, + [ + "translate", + "-i", str(input_file), + "-o", str(output_file), + "-t", "ar", + "-s", "en" + ] + ) + + assert result.exit_code == 0 + assert "Successfully translated" in result.output + + assert output_file.exists() + output_data = json.loads(output_file.read_text(encoding="utf-8")) + assert output_data["welcome"] == "TR_Hello" + assert output_data["menu"]["title"] == "TR_Main" + + assert mock_translate_text.call_count == 2 + + +def test_cli_translate_missing_input(tmp_path): + """Test CLI translate fails when the input file is missing.""" + output_file = tmp_path / "ar.json" + runner = CliRunner() + result = runner.invoke( + cli_app, + [ + "translate", + "-i", "nonexistent.json", + "-o", str(output_file), + "-t", "ar" + ] + ) + assert result.exit_code == 1 + assert "does not exist" in result.output + + +# ===================================================================== # +# Endpoint E2E Tests +# ===================================================================== # + +@pytest.mark.asyncio +async def test_endpoint_health(client: AsyncClient, mocker): + """Test health check route.""" + mocker.patch( + "app.main.health_status", + {"db": "ok", "duckling": "ok", "models": "ok", "llm": "ok"} + ) + + response = await client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + + +@pytest.mark.asyncio +async def test_endpoint_translate_success(client: AsyncClient, mocker): + """Test translating text via translation route.""" + mock_translate = mocker.patch( + "app.text_translation.router.TextTranslationController.translate_text", + new_callable=AsyncMock, + return_value={ + "message": "Translation completed", + "translation": "Bonjour", + "score": 0.9, + "complexity_score": 20, + "detected_input_lang": "en" + } + ) + response = await client.post( + "/api/v1/translate", + json={"text": "Hello", "source_lang": "en", "target_lang": "fr"} + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["translation"] == "Bonjour" + mock_translate.assert_called_once() + + +@pytest.mark.asyncio +async def test_endpoint_document_success(client: AsyncClient, mocker): + """Test translating a document via document route.""" + mock_translate_doc = mocker.patch( + "app.document_translation.router.DocumentTranslationController.translate_document", + new_callable=AsyncMock, + return_value={ + "message": "Document translation completed successfully", + "data": { + "document_url": "https://example.com/doc.json", + "source_lang": "en", + "target_lang": "fr" + }, + "translated_document": {"greeting": "Bonjour"} + } + ) + response = await client.post( + "/api/v1/document", + json={ + "document_url": "https://example.com/doc.json", + "source_lang": "en", + "target_lang": "fr" + } + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["data"]["translated_document"]["greeting"] == "Bonjour" + mock_translate_doc.assert_called_once() + + +# ===================================================================== # +# Brand CRUD Endpoint Tests +# ===================================================================== # + +@pytest.fixture +def mock_brand(): + return Brand( + id=1, + uuid="123e4567-e89b-12d3-a456-426614174000", + name="BrandName", + industry="Tech", + keywords=[], + entities=[] + ) + + +@pytest.mark.asyncio +async def test_endpoint_create_brand(client: AsyncClient, mock_brand, mocker): + mock_create = mocker.patch( + "app.brands.router.BrandController.create", + new_callable=AsyncMock, + return_value=mock_brand + ) + response = await client.post( + "/api/v1/brands", + json={"name": "BrandName", "industry": "Tech"} + ) + assert response.status_code == 201 + assert response.json()["data"]["uuid"] == "123e4567-e89b-12d3-a456-426614174000" + mock_create.assert_called_once() + + +@pytest.mark.asyncio +async def test_endpoint_get_brand(client: AsyncClient, mock_brand, mocker): + mock_get = mocker.patch( + "app.brands.router.BrandController.get_by_uuid", + new_callable=AsyncMock, + return_value=mock_brand + ) + response = await client.get("/api/v1/brands/123e4567-e89b-12d3-a456-426614174000") + assert response.status_code == 200 + assert response.json()["data"]["name"] == "BrandName" + mock_get.assert_called_once_with("123e4567-e89b-12d3-a456-426614174000") + + +@pytest.mark.asyncio +async def test_endpoint_update_brand(client: AsyncClient, mock_brand, mocker): + mock_update = mocker.patch( + "app.brands.router.BrandController.update", + new_callable=AsyncMock, + return_value=mock_brand + ) + response = await client.put( + "/api/v1/brands/123e4567-e89b-12d3-a456-426614174000", + json={"name": "NewBrandName"} + ) + assert response.status_code == 200 + mock_update.assert_called_once() + + +@pytest.mark.asyncio +async def test_endpoint_delete_brand(client: AsyncClient, mocker): + mock_delete = mocker.patch( + "app.brands.router.BrandController.delete", + new_callable=AsyncMock, + return_value=True + ) + response = await client.delete("/api/v1/brands/123e4567-e89b-12d3-a456-426614174000") + assert response.status_code == 200 + mock_delete.assert_called_once_with("123e4567-e89b-12d3-a456-426614174000") + + +# ===================================================================== # +# Domain CRUD Endpoint Tests +# ===================================================================== # + +@pytest.fixture +def mock_domain(): + return Domain( + uuid="123e4567-e89b-12d3-a456-426614174001", + name="ui", + description="UI elements", + content_types=["button"], + rules={"creativity": "low"} + ) + + +@pytest.mark.asyncio +async def test_endpoint_create_domain(client: AsyncClient, mock_domain, mocker): + mocker.patch( + "app.domains.router.DomainController.get_by_name", + new_callable=AsyncMock, + return_value=None + ) + mock_create = mocker.patch( + "app.domains.router.DomainController.create", + new_callable=AsyncMock, + return_value=mock_domain + ) + response = await client.post( + "/api/v1/domains/", + json={ + "name": "ui", + "description": "UI elements", + "content_types": ["button"], + "rules": {"creativity": "low"} + } + ) + assert response.status_code == 201 + assert response.json()["data"]["name"] == "ui" + mock_create.assert_called_once() + + +@pytest.mark.asyncio +async def test_endpoint_list_domains(client: AsyncClient, mock_domain, mocker): + mock_list = mocker.patch( + "app.domains.router.DomainController.list_domains", + new_callable=AsyncMock, + return_value=[mock_domain] + ) + response = await client.get("/api/v1/domains/") + assert response.status_code == 200 + assert len(response.json()["data"]) == 1 + mock_list.assert_called_once() + + +@pytest.mark.asyncio +async def test_endpoint_get_domain(client: AsyncClient, mock_domain, mocker): + mock_get = mocker.patch( + "app.domains.router.DomainController.get_by_name", + new_callable=AsyncMock, + return_value=mock_domain + ) + response = await client.get("/api/v1/domains/ui") + assert response.status_code == 200 + assert response.json()["data"]["uuid"] == "123e4567-e89b-12d3-a456-426614174001" + mock_get.assert_called_once_with("ui") + + +@pytest.mark.asyncio +async def test_endpoint_update_domain(client: AsyncClient, mock_domain, mocker): + mock_update = mocker.patch( + "app.domains.router.DomainController.update", + new_callable=AsyncMock, + return_value=mock_domain + ) + response = await client.put( + "/api/v1/domains/ui", + json={"description": "New description"} + ) + assert response.status_code == 200 + mock_update.assert_called_once() + + +@pytest.mark.asyncio +async def test_endpoint_delete_domain(client: AsyncClient, mocker): + mock_delete = mocker.patch( + "app.domains.router.DomainController.delete", + new_callable=AsyncMock, + return_value=True + ) + response = await client.delete("/api/v1/domains/ui") + assert response.status_code == 200 + mock_delete.assert_called_once_with("ui") + + +# ===================================================================== # +# Reviewer & Security Endpoint Tests +# ===================================================================== # + +@pytest.mark.asyncio +async def test_endpoint_review_start(client: AsyncClient, mocker): + """Test triggering background review batch without real DB execution.""" + mock_add_task = mocker.patch("fastapi.BackgroundTasks.add_task") + + response = await client.post("/api/v1/review/start") + assert response.status_code == 202 + data = response.json() + assert data["success"] is True + assert "started" in data["message"] + mock_add_task.assert_called_once() + + +@pytest.mark.asyncio +async def test_endpoint_unauthorized(client: AsyncClient): + """Test endpoints reject requests with invalid/missing authorization.""" + client.auth = None + + response = await client.post( + "/api/v1/translate", + json={"text": "Hello", "source_lang": "en", "target_lang": "fr"} + ) + assert response.status_code == 401 + assert response.json()["success"] is False + + +@pytest.mark.asyncio +async def test_endpoint_translate_validation_error(client: AsyncClient): + """Test translation endpoint rejects requests with missing fields.""" + response = await client.post( + "/api/v1/translate", + json={"text": "Hello"} + ) + assert response.status_code == 422 + data = response.json() + assert data["success"] is False + assert "Validation error" in data["error"]