feat: implement LLM table translation service with searchable datasource dropdown, i18n, sidebar, and RBAC
- Add backend plugin modules: preview, executor, orchestrator, events, sql_generator, superset_executor, dictionary, scheduler, metrics - Add API routes for translate jobs, runs, dictionaries, preview, schedule, metrics, corrections - Add ORM models (12 tables) and Pydantic schemas (15 DTOs) - Register translate router in app.py with RBAC permission guards - Add frontend pages: job list, job config, dictionary list/editor, history - Add 7 reusable Svelte 5 components: Preview, RunProgress, RunResult, TermCorrection, BulkCorrection, ScheduleConfig, MetricsDashboard - Add searchable datasource dropdown with Superset API integration - Add i18n (en/ru) with ~300 keys across common, config, jobs, dictionaries, preview namespaces - Add Translation sidebar category with Jobs/Dictionaries/History sub-items - Hide health monitor error toast via suppressToast API option - Add 69 backend tests and 44 frontend test files - Fix: SupersetClient env resolution (string -> Environment object) - Fix: Dataset detail API returns proper database dict - Fix: Database dialect extraction fallback when metadata incomplete
This commit is contained in:
300
backend/tests/test_translate_history.py
Normal file
300
backend/tests/test_translate_history.py
Normal file
@@ -0,0 +1,300 @@
|
||||
# [DEF:TranslateHistoryTests:Module]
|
||||
# @COMPLEXITY: 4
|
||||
# @SEMANTICS: tests, translate, history, metrics
|
||||
# @PURPOSE: Tests for run history list/detail endpoints and metrics aggregation.
|
||||
# @LAYER: Test
|
||||
# @RELATION: BINDS_TO -> [TranslateRoutes:Module]
|
||||
# @RELATION: BINDS_TO -> [TranslationMetrics:Module]
|
||||
#
|
||||
# @TEST_CONTRACT: HistoryFlow ->
|
||||
# {
|
||||
# invariants: [
|
||||
# "GET /api/translate/runs returns paginated list",
|
||||
# "GET /api/translate/runs supports filters (job_id, status, trigger_type)",
|
||||
# "GET /api/translate/runs/{id}/detail returns config_snapshot, records, events",
|
||||
# "GET /api/translate/jobs/{id}/metrics returns aggregated data",
|
||||
# "Metrics include run counts, record counts, duration"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_FIXTURE: completed_run -> run with COMPLETED status
|
||||
# @TEST_EDGE: filter_by_job_id -> returns only that job's runs
|
||||
# @TEST_EDGE: filter_by_status -> returns only matching status
|
||||
# @TEST_EDGE: metrics_empty_job -> returns zero counts
|
||||
# @TEST_INVARIANT: history_list_and_detail -> VERIFIED_BY: [completed_run, filter_by_status]
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from src.core.database import Base
|
||||
from src.app import app
|
||||
from src.dependencies import get_current_user, get_config_manager, has_permission, get_db
|
||||
from src.plugins.translate.metrics import TranslationMetrics
|
||||
from src.models.translate import TranslationJob, TranslationRun, TranslationEvent, MetricSnapshot
|
||||
|
||||
# In-memory SQLite
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = "testuser"
|
||||
mock_user.roles = []
|
||||
admin_role = MagicMock()
|
||||
admin_role.name = "Admin"
|
||||
mock_user.roles.append(admin_role)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
connection = engine.connect()
|
||||
transaction = connection.begin()
|
||||
session = TestingSessionLocal(bind=connection)
|
||||
yield session
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_deps():
|
||||
config_manager = MagicMock()
|
||||
config_manager.get_environments.return_value = []
|
||||
|
||||
connection = engine.connect()
|
||||
transaction = connection.begin()
|
||||
session = TestingSessionLocal(bind=connection)
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
pass
|
||||
|
||||
overrides = {
|
||||
get_current_user: lambda: mock_user,
|
||||
get_config_manager: lambda: config_manager,
|
||||
get_db: override_get_db,
|
||||
}
|
||||
for dep, override in overrides.items():
|
||||
app.dependency_overrides[dep] = override
|
||||
|
||||
yield {"config_manager": config_manager, "session": session}
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_api_deps):
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _create_test_job(db_session) -> TranslationJob:
|
||||
job = TranslationJob(
|
||||
id=str(uuid.uuid4()),
|
||||
name="Test Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
return job
|
||||
|
||||
|
||||
def _create_test_run(db_session, job_id: str, status: str = "COMPLETED", trigger: str = "manual") -> TranslationRun:
|
||||
run = TranslationRun(
|
||||
id=str(uuid.uuid4()),
|
||||
job_id=job_id,
|
||||
status=status,
|
||||
trigger_type=trigger,
|
||||
total_records=100,
|
||||
successful_records=80,
|
||||
failed_records=10,
|
||||
skipped_records=10,
|
||||
config_snapshot={"test": True},
|
||||
config_hash="abc123",
|
||||
dict_snapshot_hash="def456",
|
||||
key_hash="ghi789",
|
||||
created_by="testuser",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.flush()
|
||||
return run
|
||||
|
||||
|
||||
# [DEF:test_list_runs_empty:Function]
|
||||
# @PURPOSE: Verify runs list returns empty result initially.
|
||||
def test_list_runs_empty(client):
|
||||
"""Test runs list initially returns empty."""
|
||||
response = client.get("/api/translate/runs")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["items"] == []
|
||||
# [/DEF:test_list_runs_empty:Function]
|
||||
|
||||
|
||||
# [DEF:test_list_runs_with_data:Function]
|
||||
# @PURPOSE: Verify runs list returns data when runs exist.
|
||||
def test_list_runs_with_data(client, mock_api_deps):
|
||||
"""Test runs list with existing runs."""
|
||||
session = mock_api_deps["session"]
|
||||
job = _create_test_job(session)
|
||||
_create_test_run(session, job.id)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/api/translate/runs")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
assert len(data["items"]) >= 1
|
||||
assert data["items"][0]["job_id"] == job.id
|
||||
# [/DEF:test_list_runs_with_data:Function]
|
||||
|
||||
|
||||
# [DEF:test_list_runs_filter_job_id:Function]
|
||||
# @PURPOSE: Verify filtering runs by job_id works.
|
||||
def test_list_runs_filter_job_id(client, mock_api_deps):
|
||||
"""Test filtering runs by job_id."""
|
||||
session = mock_api_deps["session"]
|
||||
job1 = _create_test_job(session)
|
||||
job2 = _create_test_job(session)
|
||||
_create_test_run(session, job1.id)
|
||||
_create_test_run(session, job2.id)
|
||||
session.commit()
|
||||
|
||||
response = client.get(f"/api/translate/runs?job_id={job1.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
for item in data["items"]:
|
||||
assert item["job_id"] == job1.id
|
||||
# [/DEF:test_list_runs_filter_job_id:Function]
|
||||
|
||||
|
||||
# [DEF:test_list_runs_filter_status:Function]
|
||||
# @PURPOSE: Verify filtering runs by status works.
|
||||
def test_list_runs_filter_status(client, mock_api_deps):
|
||||
"""Test filtering runs by status."""
|
||||
session = mock_api_deps["session"]
|
||||
job = _create_test_job(session)
|
||||
_create_test_run(session, job.id, status="COMPLETED")
|
||||
_create_test_run(session, job.id, status="FAILED")
|
||||
session.commit()
|
||||
|
||||
response = client.get(f"/api/translate/runs?status=FAILED")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert item["status"] == "FAILED"
|
||||
# [/DEF:test_list_runs_filter_status:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_run_detail:Function]
|
||||
# @PURPOSE: Verify run detail returns config_snapshot, records, events.
|
||||
def test_get_run_detail(client, mock_api_deps):
|
||||
"""Test run detail endpoint."""
|
||||
session = mock_api_deps["session"]
|
||||
job = _create_test_job(session)
|
||||
run = _create_test_run(session, job.id)
|
||||
|
||||
# Add an event
|
||||
event = TranslationEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
job_id=job.id,
|
||||
run_id=run.id,
|
||||
event_type="RUN_STARTED",
|
||||
event_data={},
|
||||
created_by="testuser",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
session.add(event)
|
||||
session.commit()
|
||||
|
||||
response = client.get(f"/api/translate/runs/{run.id}/detail")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == run.id
|
||||
assert data["config_snapshot"] == {"test": True}
|
||||
assert data["config_hash"] == "abc123"
|
||||
assert len(data["events"]) >= 1
|
||||
assert data["events"][0]["event_type"] == "RUN_STARTED"
|
||||
# [/DEF:test_get_run_detail:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_job_metrics:Function]
|
||||
# @PURPOSE: Verify metrics endpoint returns aggregated data.
|
||||
def test_get_job_metrics(client, mock_api_deps):
|
||||
"""Test job metrics endpoint."""
|
||||
session = mock_api_deps["session"]
|
||||
job = _create_test_job(session)
|
||||
_create_test_run(session, job.id, status="COMPLETED")
|
||||
_create_test_run(session, job.id, status="FAILED")
|
||||
session.commit()
|
||||
|
||||
response = client.get(f"/api/translate/jobs/{job.id}/metrics")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["job_id"] == job.id
|
||||
assert data["total_runs"] >= 2
|
||||
assert data["successful_runs"] >= 1
|
||||
assert data["failed_runs"] >= 1
|
||||
# [/DEF:test_get_job_metrics:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_all_metrics:Function]
|
||||
# @PURPOSE: Verify global metrics endpoint returns data.
|
||||
def test_get_all_metrics(client, mock_api_deps):
|
||||
"""Test global metrics endpoint."""
|
||||
session = mock_api_deps["session"]
|
||||
job = _create_test_job(session)
|
||||
_create_test_run(session, job.id)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/api/translate/metrics")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
# [/DEF:test_get_all_metrics:Function]
|
||||
|
||||
|
||||
# [DEF:test_metrics_empty_job:Function]
|
||||
# @PURPOSE: Verify metrics for job with no runs returns zeros.
|
||||
def test_metrics_empty_job(client, mock_api_deps):
|
||||
"""Test metrics for job with no runs."""
|
||||
session = mock_api_deps["session"]
|
||||
job = _create_test_job(session)
|
||||
session.commit()
|
||||
|
||||
response = client.get(f"/api/translate/jobs/{job.id}/metrics")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total_runs"] == 0
|
||||
# [/DEF:test_metrics_empty_job:Function]
|
||||
|
||||
|
||||
# [DEF:test_run_detail_not_found:Function]
|
||||
# @PURPOSE: Verify 404 on non-existent run detail.
|
||||
def test_run_detail_not_found(client):
|
||||
"""Test run detail returns 404 for non-existent run."""
|
||||
response = client.get("/api/translate/runs/non-existent/detail")
|
||||
assert response.status_code == 404
|
||||
# [/DEF:test_run_detail_not_found:Function]
|
||||
# [/DEF:TranslateHistoryTests:Module]
|
||||
Reference in New Issue
Block a user