Files
ss-tools/backend/tests/api/test_translate_run_routes.py

397 lines
15 KiB
Python

# #region Test.Api.TranslateRunRoutes [C:3] [TYPE Module] [SEMANTICS test,translate,runs,execution,api]
# @BRIEF Tests for _run_routes.py — run execution, retry, cancel.
# @RELATION BINDS_TO -> [TranslateRunRoutesModule]
# @TEST_EDGE: run_value_error -> 400
# @TEST_EDGE: run_generic_error -> 502
# @TEST_EDGE: retry_value_error -> 400
# @TEST_EDGE: retry_generic_error -> 502
import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
if _src not in sys.path:
sys.path.insert(0, _src)
def _make_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.translate._run_routes import router
from src.dependencies import (
get_config_manager, get_current_user, get_db,
has_permission, require_feature,
)
from src.schemas.auth import User, RoleSchema
app = FastAPI()
app.include_router(router)
mock_user = User(
id="user-1", username="admin", email="admin@x.com",
auth_source="LOCAL",
created_at=__import__("datetime").datetime.now(),
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
)
defaults = {
get_current_user: lambda: mock_user,
get_config_manager: lambda: MagicMock(),
get_db: lambda: MagicMock(),
has_permission: lambda *a, **kw: lambda: None,
require_feature: lambda *a, **kw: lambda: None,
}
if overrides:
defaults.update(overrides)
for dep, mock_fn in defaults.items():
app.dependency_overrides[dep] = mock_fn
return TestClient(app)
def _mock_run(id="run-1", job_id="job-1", status="PENDING"):
r = MagicMock()
r.id = id
r.job_id = job_id
r.status = status
r.trigger_type = "manual"
r.started_at = None
r.completed_at = None
r.error_message = None
r.total_records = 0
r.successful_records = 0
r.failed_records = 0
r.skipped_records = 0
r.cache_hits = 0
r.insert_status = None
r.superset_execution_id = None
r.config_snapshot = None
r.key_hash = None
r.config_hash = None
r.dict_snapshot_hash = None
r.created_by = "admin"
r.created_at = __import__("datetime").datetime(2026, 1, 1)
return r
# =============================================================================
# Run Translation
# =============================================================================
class TestRunTranslation:
"""POST /api/translate/jobs/{job_id}/run"""
def test_run_translation_success(self):
mock_orch = MagicMock()
mock_orch.start_run.return_value = _mock_run()
with (
patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch),
patch("src.api.routes.translate._run_routes.asyncio.create_task"),
):
client = _make_client()
resp = client.post("/api/translate/jobs/job-1/run")
assert resp.status_code == 201
data = resp.json()
assert data["id"] == "run-1"
assert data["status"] == "PENDING"
def test_run_translation_full(self):
mock_orch = MagicMock()
mock_orch.start_run.return_value = _mock_run()
with (
patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch),
patch("src.api.routes.translate._run_routes.asyncio.create_task"),
):
client = _make_client()
resp = client.post("/api/translate/jobs/job-1/run?full_translation=true")
assert resp.status_code == 201
# Verify start_run was called with full_translation=True
mock_orch.start_run.assert_called_once_with(
job_id="job-1", is_scheduled=False, full_translation=True
)
def test_run_translation_value_error_400(self):
mock_orch = MagicMock()
mock_orch.start_run.side_effect = ValueError("Job not found")
with (
patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch),
patch("src.api.routes.translate._run_routes.asyncio.create_task"),
):
client = _make_client()
resp = client.post("/api/translate/jobs/job-missing/run")
assert resp.status_code == 400
assert "Job not found" in resp.text
def test_run_translation_generic_error_502(self):
mock_orch = MagicMock()
mock_orch.start_run.side_effect = RuntimeError("Unexpected error")
with (
patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch),
patch("src.api.routes.translate._run_routes.asyncio.create_task"),
):
client = _make_client()
resp = client.post("/api/translate/jobs/job-1/run")
assert resp.status_code == 502
assert "Run failed" in resp.text
# =============================================================================
# Retry Run
# =============================================================================
class TestRetryRun:
"""POST /api/translate/runs/{run_id}/retry"""
def test_retry_success(self):
mock_orch = MagicMock()
mock_orch.retry_failed_batches = AsyncMock(return_value=_mock_run(status="RUNNING"))
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch):
client = _make_client()
resp = client.post("/api/translate/runs/run-1/retry")
assert resp.status_code == 200
assert resp.json()["status"] == "RUNNING"
def test_retry_value_error_400(self):
mock_orch = MagicMock()
mock_orch.retry_failed_batches.side_effect = ValueError("Run not retriable")
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch):
client = _make_client()
resp = client.post("/api/translate/runs/run-1/retry")
assert resp.status_code == 400
def test_retry_generic_error_502(self):
mock_orch = MagicMock()
mock_orch.retry_failed_batches.side_effect = RuntimeError("Internal error")
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch):
client = _make_client()
resp = client.post("/api/translate/runs/run-1/retry")
assert resp.status_code == 502
assert "Retry failed" in resp.text
# =============================================================================
# Retry Insert
# =============================================================================
class TestRetryInsert:
"""POST /api/translate/runs/{run_id}/retry-insert"""
def test_retry_insert_success(self):
mock_orch = MagicMock()
mock_orch.retry_insert = AsyncMock(return_value=_mock_run(status="COMPLETED"))
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch):
client = _make_client()
resp = client.post("/api/translate/runs/run-1/retry-insert")
assert resp.status_code == 200
assert resp.json()["status"] == "COMPLETED"
def test_retry_insert_value_error_400(self):
mock_orch = MagicMock()
mock_orch.retry_insert.side_effect = ValueError("Insert not needed")
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch):
client = _make_client()
resp = client.post("/api/translate/runs/run-1/retry-insert")
assert resp.status_code == 400
def test_retry_insert_generic_error_502(self):
mock_orch = MagicMock()
mock_orch.retry_insert.side_effect = RuntimeError("Insert failed")
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch):
client = _make_client()
resp = client.post("/api/translate/runs/run-1/retry-insert")
assert resp.status_code == 502
assert "Retry insert failed" in resp.text
# =============================================================================
# Background Execute — covers _background_execute inner function (lines 57-120)
# =============================================================================
class TestBackgroundExecute:
"""Execute the _background_execute coroutine via real asyncio.create_task."""
def test_bg_run_not_found(self):
"""Run not found in background: early return (lines 71-77)."""
mock_orch = MagicMock()
mock_orch.start_run.return_value = _mock_run()
mock_bg_db = MagicMock()
mock_bg_db.query.return_value.filter.return_value.first.return_value = None
with (
patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch),
patch("src.api.routes.translate._run_routes.SessionLocal",
return_value=mock_bg_db),
patch("src.api.routes.translate._run_routes.seed_trace_id"),
):
client = _make_client()
resp = client.post("/api/translate/jobs/job-1/run")
assert resp.status_code == 201
mock_bg_db.close.assert_called_once()
def test_bg_execute_success(self):
"""Background execute succeeds (lines 65-85)."""
mock_run = _mock_run(status="COMPLETED")
mock_run.completed_at = __import__("datetime").datetime(2026, 1, 1)
mock_orch = MagicMock()
mock_orch.start_run.return_value = _mock_run()
mock_orch.execute_run = AsyncMock()
mock_bg_db = MagicMock()
# Two queries: first to find run, second to verify
mock_bg_db.query.return_value.filter.return_value.first.side_effect = [mock_run, mock_run]
with (
patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch),
patch("src.api.routes.translate._run_routes.SessionLocal",
return_value=mock_bg_db),
patch("src.api.routes.translate._run_routes.seed_trace_id"),
patch("src.api.routes.translate._run_routes.get_trace_id",
return_value="trace-abc-123"),
):
client = _make_client()
resp = client.post("/api/translate/jobs/job-1/run")
assert resp.status_code == 201
mock_orch.execute_run.assert_awaited_once()
mock_bg_db.close.assert_called_once()
def test_bg_execute_not_terminal_marks_failed(self):
"""Run not in terminal state after commit: marked FAILED (lines 86-95)."""
mock_run_pending = _mock_run(status="RUNNING")
mock_orch = MagicMock()
mock_orch.start_run.return_value = _mock_run()
mock_orch.execute_run = AsyncMock()
mock_bg_db = MagicMock()
mock_bg_db.query.return_value.filter.return_value.first.side_effect = [mock_run_pending, mock_run_pending]
with (
patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch),
patch("src.api.routes.translate._run_routes.SessionLocal",
return_value=mock_bg_db),
patch("src.api.routes.translate._run_routes.seed_trace_id"),
):
client = _make_client()
resp = client.post("/api/translate/jobs/job-1/run")
assert resp.status_code == 201
# Run should have been marked FAILED
assert mock_run_pending.status == "FAILED"
mock_bg_db.commit.assert_called()
mock_bg_db.close.assert_called_once()
def test_bg_execute_exception_marks_failed(self):
"""Exception in background marks run FAILED via fallback (lines 97-120)."""
mock_run = _mock_run()
mock_orch = MagicMock()
mock_orch.start_run.return_value = mock_run
mock_orch.execute_run = AsyncMock(side_effect=RuntimeError("LLM timeout"))
mock_bg_db = MagicMock()
mock_bg_db.query.return_value.filter.return_value.first.side_effect = [mock_run]
# Second session for fallback
mock_fb_db = MagicMock()
mock_fb_db.query.return_value.filter.return_value.first.return_value = mock_run
with (
patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch),
patch("src.api.routes.translate._run_routes.SessionLocal",
side_effect=[mock_bg_db, mock_fb_db]),
patch("src.api.routes.translate._run_routes.seed_trace_id"),
):
client = _make_client()
resp = client.post("/api/translate/jobs/job-1/run")
assert resp.status_code == 201
assert mock_run.status == "FAILED"
assert mock_run.error_message is not None
mock_fb_db.close.assert_called_once()
mock_bg_db.close.assert_called_once()
def test_bg_execute_exception_fallback_fails(self):
"""Exception in background AND fallback: handled gracefully (lines 115-117)."""
mock_run = _mock_run()
mock_orch = MagicMock()
mock_orch.start_run.return_value = mock_run
mock_orch.execute_run = AsyncMock(side_effect=RuntimeError("LLM timeout"))
mock_bg_db = MagicMock()
mock_bg_db.query.return_value.filter.return_value.first.return_value = mock_run
# Fallback db also raises
bad_fb_db = MagicMock()
bad_fb_db.query.side_effect = RuntimeError("DB closed")
with (
patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch),
patch("src.api.routes.translate._run_routes.SessionLocal",
side_effect=[mock_bg_db, bad_fb_db]),
patch("src.api.routes.translate._run_routes.seed_trace_id"),
):
client = _make_client()
resp = client.post("/api/translate/jobs/job-1/run")
assert resp.status_code == 201
mock_bg_db.close.assert_called_once()
# =============================================================================
# Cancel Run
# =============================================================================
class TestCancelRun:
"""POST /api/translate/runs/{run_id}/cancel"""
def test_cancel_success(self):
mock_orch = MagicMock()
mock_orch.cancel_run.return_value = _mock_run(status="CANCELLED")
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch):
client = _make_client()
resp = client.post("/api/translate/runs/run-1/cancel")
assert resp.status_code == 200
assert resp.json()["status"] == "CANCELLED"
def test_cancel_value_error_400(self):
mock_orch = MagicMock()
mock_orch.cancel_run.side_effect = ValueError("Run not found")
with patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
return_value=mock_orch):
client = _make_client()
resp = client.post("/api/translate/runs/run-missing/cancel")
assert resp.status_code == 400
# #endregion Test.Api.TranslateRunRoutes