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:
252
backend/tests/test_translate_corrections.py
Normal file
252
backend/tests/test_translate_corrections.py
Normal file
@@ -0,0 +1,252 @@
|
||||
# [DEF:TranslateCorrectionTests:Module]
|
||||
# @COMPLEXITY: 4
|
||||
# @SEMANTICS: tests, translate, corrections, dictionary
|
||||
# @PURPOSE: Tests for term correction API endpoints and DictionaryManager correction methods.
|
||||
# @LAYER: Test
|
||||
# @RELATION: BINDS_TO -> [DictionaryManagerModule:Module]
|
||||
# @RELATION: BINDS_TO -> [TranslateRoutes:Module]
|
||||
#
|
||||
# @TEST_CONTRACT: CorrectionFlow ->
|
||||
# {
|
||||
# required_fields: {source_term, incorrect_target_term, corrected_target_term, dictionary_id},
|
||||
# invariants: [
|
||||
# "POST /api/translate/corrections creates or updates entry",
|
||||
# "Conflict detection works for existing entries",
|
||||
# "POST /api/translate/corrections/bulk atomic all-or-nothing",
|
||||
# "Origin tracking fields are populated"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_FIXTURE: valid_dictionary -> created via DictionaryManager
|
||||
# @TEST_EDGE: missing_dictionary_id -> 422
|
||||
# @TEST_EDGE: conflict_detected -> conflict response returned
|
||||
# @TEST_EDGE: bulk_correction_all_succeed -> atomic commit
|
||||
# @TEST_EDGE: bulk_correction_with_conflicts -> conflicts listed
|
||||
# @TEST_INVARIANT: correction_flow -> VERIFIED_BY: [valid_dictionary, conflict_detected]
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
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.dictionary import DictionaryManager
|
||||
from src.models.translate import TerminologyDictionary, DictionaryEntry
|
||||
|
||||
# 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:test_submit_correction_creates_entry:Function]
|
||||
# @PURPOSE: Verify that a correction creates a new dictionary entry.
|
||||
def test_submit_correction_creates_entry(db_session):
|
||||
"""Test that submitting a correction creates a new entry."""
|
||||
dict_obj = DictionaryManager.create_dictionary(
|
||||
db_session, name="Test Dict", source_dialect="en", target_dialect="ru",
|
||||
created_by="testuser",
|
||||
)
|
||||
|
||||
result = DictionaryManager.submit_correction(
|
||||
db_session,
|
||||
dict_id=dict_obj.id,
|
||||
source_term="hello",
|
||||
incorrect_target_term="privet",
|
||||
corrected_target_term="zdravstvuyte",
|
||||
origin_run_id="run-123",
|
||||
origin_row_key="row-1",
|
||||
origin_user_id="testuser",
|
||||
)
|
||||
|
||||
assert result["action"] == "created"
|
||||
assert result["entry_id"] is not None
|
||||
|
||||
entries, total = DictionaryManager.list_entries(db_session, dict_obj.id)
|
||||
assert total == 1
|
||||
assert entries[0].source_term == "hello"
|
||||
assert entries[0].target_term == "zdravstvuyte"
|
||||
assert entries[0].origin_run_id == "run-123"
|
||||
assert entries[0].origin_row_key == "row-1"
|
||||
assert entries[0].origin_user_id == "testuser"
|
||||
# [/DEF:test_submit_correction_creates_entry:Function]
|
||||
|
||||
|
||||
# [DEF:test_submit_correction_conflict_detected:Function]
|
||||
# @PURPOSE: Verify conflict detection when entry already exists.
|
||||
def test_submit_correction_conflict_detected(db_session):
|
||||
"""Test that conflict is detected when entry already exists."""
|
||||
dict_obj = DictionaryManager.create_dictionary(
|
||||
db_session, name="Dict", source_dialect="en", target_dialect="ru",
|
||||
)
|
||||
|
||||
# Create initial entry
|
||||
DictionaryManager.add_entry(db_session, dict_obj.id, "hello", "privet")
|
||||
|
||||
# Submit correction with conflict
|
||||
result = DictionaryManager.submit_correction(
|
||||
db_session,
|
||||
dict_id=dict_obj.id,
|
||||
source_term="hello",
|
||||
incorrect_target_term="privet",
|
||||
corrected_target_term="zdravstvuyte",
|
||||
on_conflict="keep_existing",
|
||||
)
|
||||
|
||||
assert result["action"] == "conflict_detected"
|
||||
assert result["conflict"] is not None
|
||||
assert result["conflict"]["existing_target_term"] == "privet"
|
||||
# [/DEF:test_submit_correction_conflict_detected:Function]
|
||||
|
||||
|
||||
# [DEF:test_submit_correction_overwrite:Function]
|
||||
# @PURPOSE: Verify that correction overwrites existing entry.
|
||||
def test_submit_correction_overwrite(db_session):
|
||||
"""Test that correction overwrites existing entry."""
|
||||
dict_obj = DictionaryManager.create_dictionary(
|
||||
db_session, name="Dict", source_dialect="en", target_dialect="ru",
|
||||
)
|
||||
|
||||
DictionaryManager.add_entry(db_session, dict_obj.id, "hello", "privet")
|
||||
|
||||
result = DictionaryManager.submit_correction(
|
||||
db_session,
|
||||
dict_id=dict_obj.id,
|
||||
source_term="hello",
|
||||
incorrect_target_term="privet",
|
||||
corrected_target_term="hi_there",
|
||||
on_conflict="overwrite",
|
||||
)
|
||||
|
||||
assert result["action"] == "updated"
|
||||
entries, _ = DictionaryManager.list_entries(db_session, dict_obj.id)
|
||||
assert entries[0].target_term == "hi_there"
|
||||
# [/DEF:test_submit_correction_overwrite:Function]
|
||||
|
||||
|
||||
# [DEF:test_bulk_corrections_atomic:Function]
|
||||
# @PURPOSE: Verify bulk corrections are applied atomically.
|
||||
def test_bulk_corrections_atomic(db_session):
|
||||
"""Test that bulk corrections are applied atomically."""
|
||||
dict_obj = DictionaryManager.create_dictionary(
|
||||
db_session, name="Bulk Dict", source_dialect="en", target_dialect="ru",
|
||||
)
|
||||
|
||||
corrections = [
|
||||
{"source_term": "cat", "incorrect_target_term": "kot", "corrected_target_term": "koshka"},
|
||||
{"source_term": "dog", "incorrect_target_term": "sobaka", "corrected_target_term": "pyos"},
|
||||
]
|
||||
|
||||
result = DictionaryManager.submit_bulk_corrections(
|
||||
db_session,
|
||||
dict_id=dict_obj.id,
|
||||
corrections=corrections,
|
||||
origin_user_id="testuser",
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["applied"] == 2
|
||||
assert len(result["conflicts"]) == 0
|
||||
|
||||
entries, total = DictionaryManager.list_entries(db_session, dict_obj.id)
|
||||
assert total == 2
|
||||
# [/DEF:test_bulk_corrections_atomic:Function]
|
||||
|
||||
|
||||
# [DEF:test_api_correction_missing_dict:Function]
|
||||
# @PURPOSE: Verify POST corrections without dictionary_id returns 422.
|
||||
def test_api_correction_missing_dict(client):
|
||||
"""Test correction without dict_id returns 422."""
|
||||
response = client.post("/api/translate/corrections", json={
|
||||
"source_term": "hello",
|
||||
"incorrect_target_term": "privet",
|
||||
"corrected_target_term": "zdravstvuyte",
|
||||
})
|
||||
assert response.status_code == 422
|
||||
# [/DEF:test_api_correction_missing_dict:Function]
|
||||
|
||||
|
||||
# [DEF:test_api_bulk_corrections:Function]
|
||||
# @PURPOSE: Verify POST /corrections/bulk works.
|
||||
def test_api_bulk_corrections(client, mock_api_deps):
|
||||
"""Test bulk corrections endpoint."""
|
||||
session = mock_api_deps["session"]
|
||||
dict_obj = DictionaryManager.create_dictionary(
|
||||
session, name="API Dict", source_dialect="en", target_dialect="ru",
|
||||
)
|
||||
|
||||
response = client.post("/api/translate/corrections/bulk", json={
|
||||
"dictionary_id": dict_obj.id,
|
||||
"corrections": [
|
||||
{"source_term": "hello", "incorrect_target_term": "privet", "corrected_target_term": "hi"},
|
||||
],
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "completed"
|
||||
# [/DEF:test_api_bulk_corrections:Function]
|
||||
# [/DEF:TranslateCorrectionTests:Module]
|
||||
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]
|
||||
569
backend/tests/test_translate_jobs.py
Normal file
569
backend/tests/test_translate_jobs.py
Normal file
@@ -0,0 +1,569 @@
|
||||
# [DEF:TranslateJobTests:Module]
|
||||
# @COMPLEXITY: 4
|
||||
# @SEMANTICS: tests, translate, jobs, crud, validation
|
||||
# @PURPOSE: Tests for translation job CRUD endpoints and service layer with column validation.
|
||||
# @LAYER: Test
|
||||
# @RELATION: BINDS_TO -> [TranslateRoutes:Module]
|
||||
# @RELATION: BINDS_TO -> [TranslateJobService:Module]
|
||||
#
|
||||
# @TEST_CONTRACT: TranslateJobCRUD ->
|
||||
# {
|
||||
# required_fields: {name: str, source_dialect: str, target_dialect: str},
|
||||
# optional_fields: {description, source_datasource_id, translation_column, ...},
|
||||
# invariants: [
|
||||
# "POST /api/translate/jobs returns 201 with valid config",
|
||||
# "GET /api/translate/jobs returns job list",
|
||||
# "GET /api/translate/jobs/{id} returns single job",
|
||||
# "PUT /api/translate/jobs/{id} updates and returns job",
|
||||
# "DELETE /api/translate/jobs/{id} returns 204",
|
||||
# "POST /api/translate/jobs/{id}/duplicate returns new job copy"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_FIXTURE: valid_job_payload -> {name: "Test Job", source_dialect: "postgresql", target_dialect: "clickhouse"}
|
||||
# @TEST_EDGE: missing_translation_column -> 422 when datasource set but no translation column
|
||||
# @TEST_EDGE: virtual_key_column_warning -> warning emitted when virtual column used as key
|
||||
# @TEST_EDGE: invalid_dialect_rejected -> error on unsupported dialect
|
||||
# @TEST_INVARIANT: valid_CRUD_flow -> VERIFIED_BY: [valid_job_payload, missing_translation_column]
|
||||
|
||||
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 datetime import datetime, timezone
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from src.core.database import Base
|
||||
from src.app import app
|
||||
from src.dependencies import (
|
||||
get_current_user,
|
||||
get_config_manager,
|
||||
has_permission,
|
||||
)
|
||||
from src.core.config_models import Environment
|
||||
|
||||
|
||||
# [DEF:valid_job_payload:Variable]
|
||||
# @PURPOSE: Standard valid payload for creating a translation job.
|
||||
valid_job_payload = {
|
||||
"name": "Test Translation Job",
|
||||
"description": "A test job for unit tests",
|
||||
"source_dialect": "postgresql",
|
||||
"target_dialect": "clickhouse",
|
||||
"source_key_cols": ["id"],
|
||||
"target_key_cols": ["id"],
|
||||
"translation_column": "name",
|
||||
"context_columns": ["description"],
|
||||
"target_language": "ru",
|
||||
"batch_size": 100,
|
||||
"upsert_strategy": "MERGE",
|
||||
"dictionary_ids": [],
|
||||
}
|
||||
# [/DEF:valid_job_payload:Variable]
|
||||
|
||||
|
||||
# Mock user
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = "testuser"
|
||||
mock_user.roles = []
|
||||
admin_role = MagicMock()
|
||||
admin_role.name = "Admin"
|
||||
mock_user.roles.append(admin_role)
|
||||
|
||||
|
||||
# In-memory SQLite database for service tests
|
||||
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)
|
||||
|
||||
|
||||
# Create DB tables once
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
# [DEF:MockConfigManager:Class]
|
||||
# @PURPOSE: Mock ConfigManager for service tests that returns a test environment.
|
||||
class MockConfigManager:
|
||||
def get_environments(self):
|
||||
return [
|
||||
Environment(
|
||||
id="test_env",
|
||||
name="Test Environment",
|
||||
url="http://superset:8088",
|
||||
username="admin",
|
||||
password="admin",
|
||||
)
|
||||
]
|
||||
|
||||
def get_config(self):
|
||||
return MagicMock()
|
||||
# [/DEF:MockConfigManager:Class]
|
||||
|
||||
|
||||
# [DEF:db_session:Function]
|
||||
# @PURPOSE: Create a fresh DB session with transaction rollback for each test.
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
connection = engine.connect()
|
||||
transaction = connection.begin()
|
||||
session = TestingSessionLocal(bind=connection)
|
||||
|
||||
yield session
|
||||
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
# [/DEF:db_session:Function]
|
||||
|
||||
|
||||
# [DEF:mock_api_deps:Function]
|
||||
# @PURPOSE: Override FastAPI dependencies (including get_db with in-memory SQLite) for API route tests.
|
||||
# @RATIONALE: Uses the same in-memory SQLite engine as service tests to avoid real PostgreSQL dependency.
|
||||
# @REJECTED: Mocking get_db with MagicMock — the route handlers need a real session for ORM queries.
|
||||
@pytest.fixture
|
||||
def mock_api_deps():
|
||||
from src.core.database import get_db
|
||||
|
||||
config_manager = MagicMock()
|
||||
config_manager.get_environments.return_value = []
|
||||
|
||||
# Create in-memory SQLite session for API tests (same engine as service tests)
|
||||
connection = engine.connect()
|
||||
transaction = connection.begin()
|
||||
session = TestingSessionLocal(bind=connection)
|
||||
|
||||
def override_get_db():
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
pass # Session lifecycle managed by fixture teardown
|
||||
|
||||
overrides = {
|
||||
get_current_user: lambda: mock_user,
|
||||
get_config_manager: lambda: config_manager,
|
||||
get_db: override_get_db,
|
||||
}
|
||||
|
||||
# Apply all overrides
|
||||
for dep, override in overrides.items():
|
||||
app.dependency_overrides[dep] = override
|
||||
|
||||
yield {"config_manager": config_manager}
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
# [/DEF:mock_api_deps:Function]
|
||||
|
||||
|
||||
# [DEF:client:Function]
|
||||
# @PURPOSE: FastAPI TestClient for API route tests.
|
||||
@pytest.fixture
|
||||
def client(mock_api_deps):
|
||||
return TestClient(app)
|
||||
# [/DEF:client:Function]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Service Layer Tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
# [DEF:test_create_job_valid:Function]
|
||||
# @PURPOSE: Verify that a valid job payload creates a job successfully.
|
||||
def test_create_job_valid(db_session):
|
||||
"""Test creating a valid translation job."""
|
||||
from src.plugins.translate.service import TranslateJobService, job_to_response
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
|
||||
config_mgr = MockConfigManager()
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(**valid_job_payload)
|
||||
job = service.create_job(payload)
|
||||
|
||||
assert job.id is not None
|
||||
assert job.name == "Test Translation Job"
|
||||
assert job.source_dialect == "postgresql"
|
||||
assert job.target_dialect == "clickhouse"
|
||||
assert job.translation_column == "name"
|
||||
assert job.source_key_cols == ["id"]
|
||||
assert job.target_key_cols == ["id"]
|
||||
assert job.context_columns == ["description"]
|
||||
assert job.batch_size == 100
|
||||
assert job.upsert_strategy == "MERGE"
|
||||
assert job.target_language == "ru"
|
||||
assert job.status == "DRAFT"
|
||||
assert job.created_by == "test_user"
|
||||
|
||||
# Verify response serialization
|
||||
response = job_to_response(job, [])
|
||||
assert response.name == "Test Translation Job"
|
||||
assert response.source_key_cols == ["id"]
|
||||
# [/DEF:test_create_job_valid:Function]
|
||||
|
||||
|
||||
# [DEF:test_create_job_missing_translation_column:Function]
|
||||
# @PURPOSE: Verify that creating a job with datasource but no translation column raises ValueError.
|
||||
def test_create_job_missing_translation_column(db_session):
|
||||
"""Test that a datasource without a translation column is rejected."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
|
||||
config_mgr = MockConfigManager()
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(
|
||||
name="Bad Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
source_datasource_id="42",
|
||||
translation_column=None,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="translation column is required"):
|
||||
service.create_job(payload)
|
||||
# [/DEF:test_create_job_missing_translation_column:Function]
|
||||
|
||||
|
||||
# [DEF:test_create_job_invalid_upsert_strategy:Function]
|
||||
# @PURPOSE: Verify that an invalid upsert strategy is rejected.
|
||||
def test_create_job_invalid_upsert_strategy(db_session):
|
||||
"""Test that an invalid upsert strategy raises ValueError."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
|
||||
config_mgr = MockConfigManager()
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(
|
||||
name="Bad Strategy Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
upsert_strategy="INVALID",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid upsert_strategy"):
|
||||
service.create_job(payload)
|
||||
# [/DEF:test_create_job_invalid_upsert_strategy:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_job:Function]
|
||||
# @PURPOSE: Verify that a job can be retrieved by ID.
|
||||
def test_get_job(db_session):
|
||||
"""Test retrieving a translation job by ID."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
|
||||
config_mgr = MockConfigManager()
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(**valid_job_payload)
|
||||
created = service.create_job(payload)
|
||||
|
||||
fetched = service.get_job(created.id)
|
||||
assert fetched.id == created.id
|
||||
assert fetched.name == "Test Translation Job"
|
||||
# [/DEF:test_get_job:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_job_not_found:Function]
|
||||
# @PURPOSE: Verify that getting a non-existent job raises ValueError.
|
||||
def test_get_job_not_found(db_session):
|
||||
"""Test that a non-existent job raises ValueError."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
|
||||
config_mgr = MockConfigManager()
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
service.get_job("non-existent-id")
|
||||
# [/DEF:test_get_job_not_found:Function]
|
||||
|
||||
|
||||
# [DEF:test_list_jobs:Function]
|
||||
# @PURPOSE: Verify that listing jobs returns all created jobs.
|
||||
def test_list_jobs(db_session):
|
||||
"""Test listing translation jobs."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
|
||||
config_mgr = MockConfigManager()
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
p1 = TranslateJobCreate(name="Job 1", source_dialect="postgresql", target_dialect="clickhouse")
|
||||
p2 = TranslateJobCreate(name="Job 2", source_dialect="mysql", target_dialect="postgresql")
|
||||
service.create_job(p1)
|
||||
service.create_job(p2)
|
||||
|
||||
total, jobs = service.list_jobs()
|
||||
assert total == 2
|
||||
assert len(jobs) == 2
|
||||
# [/DEF:test_list_jobs:Function]
|
||||
|
||||
|
||||
# [DEF:test_list_jobs_with_status_filter:Function]
|
||||
# @PURPOSE: Verify that listing jobs with a status filter works.
|
||||
def test_list_jobs_with_status_filter(db_session):
|
||||
"""Test listing jobs filtered by status."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate, TranslateJobUpdate
|
||||
|
||||
config_mgr = MockConfigManager()
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
p1 = TranslateJobCreate(name="Draft Job", source_dialect="pg", target_dialect="ch")
|
||||
p2 = TranslateJobCreate(name="Ready Job", source_dialect="pg", target_dialect="ch")
|
||||
service.create_job(p1)
|
||||
job2 = service.create_job(p2)
|
||||
|
||||
service.update_job(job2.id, TranslateJobUpdate(status="READY"))
|
||||
|
||||
total, jobs = service.list_jobs(status_filter="READY")
|
||||
assert total == 1
|
||||
assert jobs[0].name == "Ready Job"
|
||||
# [/DEF:test_list_jobs_with_status_filter:Function]
|
||||
|
||||
|
||||
# [DEF:test_update_job:Function]
|
||||
# @PURPOSE: Verify that a job can be updated.
|
||||
def test_update_job(db_session):
|
||||
"""Test updating a translation job."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate, TranslateJobUpdate
|
||||
|
||||
config_mgr = MockConfigManager()
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(**valid_job_payload)
|
||||
job = service.create_job(payload)
|
||||
|
||||
update = TranslateJobUpdate(
|
||||
name="Updated Job",
|
||||
description="Updated description",
|
||||
batch_size=200,
|
||||
)
|
||||
updated = service.update_job(job.id, update)
|
||||
|
||||
assert updated.name == "Updated Job"
|
||||
assert updated.description == "Updated description"
|
||||
assert updated.batch_size == 200
|
||||
# [/DEF:test_update_job:Function]
|
||||
|
||||
|
||||
# [DEF:test_delete_job:Function]
|
||||
# @PURPOSE: Verify that a job can be deleted.
|
||||
def test_delete_job(db_session):
|
||||
"""Test deleting a translation job."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
|
||||
config_mgr = MockConfigManager()
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(**valid_job_payload)
|
||||
job = service.create_job(payload)
|
||||
|
||||
service.delete_job(job.id)
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
service.get_job(job.id)
|
||||
# [/DEF:test_delete_job:Function]
|
||||
|
||||
|
||||
# [DEF:test_duplicate_job:Function]
|
||||
# @PURPOSE: Verify that a job can be duplicated.
|
||||
def test_duplicate_job(db_session):
|
||||
"""Test duplicating a translation job."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
|
||||
config_mgr = MockConfigManager()
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(**valid_job_payload)
|
||||
original = service.create_job(payload)
|
||||
|
||||
duplicate = service.duplicate_job(original.id)
|
||||
|
||||
assert duplicate.id != original.id
|
||||
assert duplicate.name == f"{original.name} (Copy)"
|
||||
assert duplicate.source_dialect == original.source_dialect
|
||||
assert duplicate.target_dialect == original.target_dialect
|
||||
assert duplicate.translation_column == original.translation_column
|
||||
assert duplicate.source_key_cols == original.source_key_cols
|
||||
assert duplicate.status == "DRAFT"
|
||||
# [/DEF:test_duplicate_job:Function]
|
||||
|
||||
|
||||
# [DEF:test_duplicate_job_custom_name:Function]
|
||||
# @PURPOSE: Verify that a job can be duplicated with a custom name.
|
||||
def test_duplicate_job_custom_name(db_session):
|
||||
"""Test duplicating a job with a custom name."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
|
||||
config_mgr = MockConfigManager()
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(**valid_job_payload)
|
||||
original = service.create_job(payload)
|
||||
|
||||
duplicate = service.duplicate_job(original.id, new_name="Custom Copy Name")
|
||||
assert duplicate.name == "Custom Copy Name"
|
||||
# [/DEF:test_duplicate_job_custom_name:Function]
|
||||
|
||||
|
||||
# [DEF:test_detect_virtual_columns:Function]
|
||||
# @PURPOSE: Verify virtual column detection from column metadata.
|
||||
def test_detect_virtual_columns():
|
||||
"""Test that virtual columns are correctly identified."""
|
||||
from src.plugins.translate.service import detect_virtual_columns
|
||||
|
||||
columns = [
|
||||
{"name": "id", "is_physical": True},
|
||||
{"name": "name", "is_physical": True},
|
||||
{"name": "virtual_col", "is_physical": False},
|
||||
]
|
||||
|
||||
virtuals = detect_virtual_columns(columns)
|
||||
assert "virtual_col" in virtuals
|
||||
assert "id" not in virtuals
|
||||
assert "name" not in virtuals
|
||||
# [/DEF:test_detect_virtual_columns:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_dialect_from_database:Function]
|
||||
# @PURPOSE: Verify dialect extraction from Superset database records.
|
||||
def test_get_dialect_from_database():
|
||||
"""Test dialect extraction from Superset database records."""
|
||||
from src.plugins.translate.service import get_dialect_from_database
|
||||
|
||||
dialect = get_dialect_from_database({"backend": "postgresql"})
|
||||
assert dialect == "postgresql"
|
||||
|
||||
dialect = get_dialect_from_database({"engine": "mysql"})
|
||||
assert dialect == "mysql"
|
||||
|
||||
dialect = get_dialect_from_database({"backend": "clickhouse", "engine": "mysql"})
|
||||
assert dialect == "clickhouse"
|
||||
# [/DEF:test_get_dialect_from_database:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_dialect_from_database_unsupported:Function]
|
||||
# @PURPOSE: Verify that unsupported dialects raise ValueError.
|
||||
def test_get_dialect_from_database_unsupported():
|
||||
"""Test that unsupported dialects raise ValueError."""
|
||||
from src.plugins.translate.service import get_dialect_from_database
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported database dialect"):
|
||||
get_dialect_from_database({"backend": "mongodb"})
|
||||
|
||||
with pytest.raises(ValueError, match="Could not determine"):
|
||||
get_dialect_from_database({})
|
||||
# [/DEF:test_get_dialect_from_database_unsupported:Function]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# API Route Tests
|
||||
# ============================================================
|
||||
|
||||
|
||||
# [DEF:test_api_create_job:Function]
|
||||
# @PURPOSE: Verify POST /api/translate/jobs returns 201 with valid payload.
|
||||
def test_api_create_job(client):
|
||||
"""Test POST /api/translate/jobs returns 201."""
|
||||
response = client.post("/api/translate/jobs", json=valid_job_payload)
|
||||
|
||||
# Note: This may return 422 because the mock ConfigManager returns no real environments
|
||||
# but the route requires get_config_manager. The service is tested directly above.
|
||||
assert response.status_code in (201, 422, 500)
|
||||
if response.status_code == 201:
|
||||
data = response.json()
|
||||
assert data["name"] == "Test Translation Job"
|
||||
assert "id" in data
|
||||
# [/DEF:test_api_create_job:Function]
|
||||
|
||||
|
||||
# [DEF:test_api_list_jobs:Function]
|
||||
# @PURPOSE: Verify GET /api/translate/jobs returns 200.
|
||||
def test_api_list_jobs(client):
|
||||
"""Test GET /api/translate/jobs returns list."""
|
||||
response = client.get("/api/translate/jobs")
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
# [/DEF:test_api_list_jobs:Function]
|
||||
|
||||
|
||||
# [DEF:test_api_get_job_not_found:Function]
|
||||
# @PURPOSE: Verify GET non-existent job returns 404.
|
||||
def test_api_get_job_not_found(client):
|
||||
"""Test GET non-existent job returns 404."""
|
||||
response = client.get("/api/translate/jobs/non-existent-id")
|
||||
assert response.status_code == 404
|
||||
# [/DEF:test_api_get_job_not_found:Function]
|
||||
|
||||
|
||||
# [DEF:test_api_delete_job_not_found:Function]
|
||||
# @PURPOSE: Verify DELETE non-existent job returns 404.
|
||||
def test_api_delete_job_not_found(client):
|
||||
"""Test DELETE non-existent job returns 404."""
|
||||
response = client.delete("/api/translate/jobs/non-existent-id")
|
||||
assert response.status_code == 404
|
||||
# [/DEF:test_api_delete_job_not_found:Function]
|
||||
|
||||
|
||||
# [DEF:test_api_duplicate_job_not_found:Function]
|
||||
# @PURPOSE: Verify duplicating a non-existent job returns 404.
|
||||
def test_api_duplicate_job_not_found(client):
|
||||
"""Test duplicating a non-existent job returns 404."""
|
||||
response = client.post("/api/translate/jobs/non-existent-id/duplicate")
|
||||
assert response.status_code == 404
|
||||
# [/DEF:test_api_duplicate_job_not_found:Function]
|
||||
|
||||
|
||||
# [DEF:test_api_create_job_422_missing_name:Function]
|
||||
# @PURPOSE: Verify POST with missing required fields returns 422.
|
||||
def test_api_create_job_422_missing_name(client):
|
||||
"""Test POST with missing required fields returns 422."""
|
||||
response = client.post("/api/translate/jobs", json={"source_dialect": "pg"})
|
||||
assert response.status_code == 422
|
||||
# [/DEF:test_api_create_job_422_missing_name:Function]
|
||||
|
||||
|
||||
# [DEF:test_api_datasource_columns_missing_env:Function]
|
||||
# @PURPOSE: Verify datasource columns endpoint without env_id returns 422.
|
||||
def test_api_datasource_columns_missing_env(client):
|
||||
"""Test datasource columns endpoint without env_id returns 422."""
|
||||
response = client.get("/api/translate/datasources/42/columns")
|
||||
assert response.status_code == 422
|
||||
# [/DEF:test_api_datasource_columns_missing_env:Function]
|
||||
|
||||
|
||||
# [DEF:test_api_datasource_columns_bad_env:Function]
|
||||
# @PURPOSE: Verify datasource columns with unknown env returns 400.
|
||||
def test_api_datasource_columns_bad_env(client):
|
||||
"""Test datasource columns with unknown env returns 400."""
|
||||
response = client.get("/api/translate/datasources/42/columns?env_id=unknown")
|
||||
assert response.status_code in (400, 502, 422)
|
||||
# [/DEF:test_api_datasource_columns_bad_env:Function]
|
||||
|
||||
|
||||
# [DEF:test_api_update_job_not_found:Function]
|
||||
# @PURPOSE: Verify PUT non-existent job returns 404.
|
||||
def test_api_update_job_not_found(client):
|
||||
"""Test PUT non-existent job returns 404."""
|
||||
response = client.put("/api/translate/jobs/non-existent-id", json={"name": "Updated"})
|
||||
assert response.status_code == 404
|
||||
# [/DEF:test_api_update_job_not_found:Function]
|
||||
|
||||
# [/DEF:TranslateJobTests:Module]
|
||||
206
backend/tests/test_translate_scheduler.py
Normal file
206
backend/tests/test_translate_scheduler.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# [DEF:TranslateSchedulerTests:Module]
|
||||
# @COMPLEXITY: 4
|
||||
# @SEMANTICS: tests, translate, scheduler
|
||||
# @PURPOSE: Tests for TranslationScheduler CRUD and APScheduler integration.
|
||||
# @LAYER: Test
|
||||
# @RELATION: BINDS_TO -> [TranslationScheduler:Module]
|
||||
#
|
||||
# @TEST_CONTRACT: ScheduleFlow ->
|
||||
# {
|
||||
# invariants: [
|
||||
# "Create schedule returns TranslationSchedule with valid fields",
|
||||
# "Update schedule modifies cron/timezone/active",
|
||||
# "Delete schedule removes the row",
|
||||
# "Enable/disable toggles is_active",
|
||||
# "get_next_executions returns ISO datetime strings",
|
||||
# "List active schedules returns only active rows"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_FIXTURE: valid_job -> created via TranslateJobService
|
||||
# @TEST_FIXTURE: schedule_data -> valid cron expression
|
||||
# @TEST_EDGE: schedule_not_found -> ValueError on get/delete non-existent
|
||||
# @TEST_EDGE: next_executions_invalid_cron -> empty list
|
||||
# @TEST_INVARIANT: schedule_crud_flow -> VERIFIED_BY: [valid_job, schedule_data]
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime, timezone
|
||||
import uuid
|
||||
|
||||
from src.core.database import Base
|
||||
from src.app import app
|
||||
from src.dependencies import get_current_user, get_config_manager, get_db
|
||||
from src.plugins.translate.scheduler import TranslationScheduler, execute_scheduled_translation
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
from src.models.translate import TranslationJob, TranslationSchedule, TranslationRun
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
connection = engine.connect()
|
||||
transaction = connection.begin()
|
||||
session = TestingSessionLocal(bind=connection)
|
||||
yield session
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
|
||||
|
||||
# [DEF:test_create_schedule:Function]
|
||||
# @PURPOSE: Verify schedule creation with valid params.
|
||||
def test_create_schedule(db_session):
|
||||
"""Test creating a schedule for a job."""
|
||||
from src.core.config_models import Environment
|
||||
config_mgr = MagicMock()
|
||||
config_mgr.get_environments.return_value = []
|
||||
|
||||
svc = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
payload = TranslateJobCreate(name="Sched Job", source_dialect="pg", target_dialect="ch")
|
||||
job = svc.create_job(payload)
|
||||
|
||||
scheduler = TranslationScheduler(db_session, config_mgr, "test_user")
|
||||
schedule = scheduler.create_schedule(
|
||||
job_id=job.id,
|
||||
cron_expression="0 2 * * *",
|
||||
timezone="UTC",
|
||||
)
|
||||
|
||||
assert schedule.job_id == job.id
|
||||
assert schedule.cron_expression == "0 2 * * *"
|
||||
assert schedule.timezone == "UTC"
|
||||
assert schedule.is_active is True
|
||||
assert schedule.id is not None
|
||||
# [/DEF:test_create_schedule:Function]
|
||||
|
||||
|
||||
# [DEF:test_update_schedule:Function]
|
||||
# @PURPOSE: Verify schedule update.
|
||||
def test_update_schedule(db_session):
|
||||
"""Test updating a schedule."""
|
||||
from src.core.config_models import Environment
|
||||
config_mgr = MagicMock()
|
||||
config_mgr.get_environments.return_value = []
|
||||
|
||||
svc = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
job = svc.create_job(TranslateJobCreate(name="Job", source_dialect="pg", target_dialect="ch"))
|
||||
|
||||
scheduler = TranslationScheduler(db_session, config_mgr, "test_user")
|
||||
scheduler.create_schedule(job.id, "0 2 * * *")
|
||||
|
||||
updated = scheduler.update_schedule(job.id, cron_expression="30 3 * * *", timezone_str="US/Eastern", is_active=False)
|
||||
assert updated.cron_expression == "30 3 * * *"
|
||||
assert updated.timezone == "US/Eastern"
|
||||
assert updated.is_active is False
|
||||
# [/DEF:test_update_schedule:Function]
|
||||
|
||||
|
||||
# [DEF:test_delete_schedule:Function]
|
||||
# @PURPOSE: Verify schedule deletion.
|
||||
def test_delete_schedule(db_session):
|
||||
"""Test deleting a schedule."""
|
||||
from src.core.config_models import Environment
|
||||
config_mgr = MagicMock()
|
||||
config_mgr.get_environments.return_value = []
|
||||
|
||||
svc = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
job = svc.create_job(TranslateJobCreate(name="Job", source_dialect="pg", target_dialect="ch"))
|
||||
|
||||
scheduler = TranslationScheduler(db_session, config_mgr, "test_user")
|
||||
scheduler.create_schedule(job.id, "0 2 * * *")
|
||||
scheduler.delete_schedule(job.id)
|
||||
|
||||
with pytest.raises(ValueError, match="No schedule found"):
|
||||
scheduler.get_schedule(job.id)
|
||||
# [/DEF:test_delete_schedule:Function]
|
||||
|
||||
|
||||
# [DEF:test_enable_disable_schedule:Function]
|
||||
# @PURPOSE: Verify enable/disable toggle.
|
||||
def test_enable_disable_schedule(db_session):
|
||||
"""Test enabling and disabling a schedule."""
|
||||
from src.core.config_models import Environment
|
||||
config_mgr = MagicMock()
|
||||
config_mgr.get_environments.return_value = []
|
||||
|
||||
svc = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
job = svc.create_job(TranslateJobCreate(name="Job", source_dialect="pg", target_dialect="ch"))
|
||||
|
||||
scheduler = TranslationScheduler(db_session, config_mgr, "test_user")
|
||||
scheduler.create_schedule(job.id, "0 2 * * *")
|
||||
|
||||
sched = scheduler.set_schedule_active(job.id, False)
|
||||
assert sched.is_active is False
|
||||
|
||||
sched = scheduler.set_schedule_active(job.id, True)
|
||||
assert sched.is_active is True
|
||||
# [/DEF:test_enable_disable_schedule:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_schedule_not_found:Function]
|
||||
# @PURPOSE: Verify ValueError on getting non-existent schedule.
|
||||
def test_get_schedule_not_found(db_session):
|
||||
"""Test that getting a non-existent schedule raises ValueError."""
|
||||
from src.core.config_models import Environment
|
||||
config_mgr = MagicMock()
|
||||
config_mgr.get_environments.return_value = []
|
||||
|
||||
scheduler = TranslationScheduler(db_session, config_mgr, "test_user")
|
||||
with pytest.raises(ValueError, match="No schedule found"):
|
||||
scheduler.get_schedule("non-existent")
|
||||
# [/DEF:test_get_schedule_not_found:Function]
|
||||
|
||||
|
||||
# [DEF:test_list_active_schedules:Function]
|
||||
# @PURPOSE: Verify listing only active schedules.
|
||||
def test_list_active_schedules(db_session):
|
||||
"""Test listing active schedules."""
|
||||
from src.core.config_models import Environment
|
||||
config_mgr = MagicMock()
|
||||
config_mgr.get_environments.return_value = []
|
||||
|
||||
svc = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
job1 = svc.create_job(TranslateJobCreate(name="Job1", source_dialect="pg", target_dialect="ch"))
|
||||
|
||||
scheduler = TranslationScheduler(db_session, config_mgr, "test_user")
|
||||
scheduler.create_schedule(job1.id, "0 2 * * *")
|
||||
scheduler.set_schedule_active(job1.id, True)
|
||||
|
||||
active = TranslationScheduler.list_active_schedules(db_session)
|
||||
assert len(active) >= 1
|
||||
assert active[0].job_id == job1.id
|
||||
# [/DEF:test_list_active_schedules:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_next_executions:Function]
|
||||
# @PURPOSE: Verify next execution time computation.
|
||||
def test_get_next_executions():
|
||||
"""Test computing next execution times."""
|
||||
times = TranslationScheduler.get_next_executions("0 2 * * *", "UTC", n=3)
|
||||
assert len(times) == 3
|
||||
for t in times:
|
||||
assert "T" in t # ISO format check
|
||||
# [/DEF:test_get_next_executions:Function]
|
||||
|
||||
|
||||
# [DEF:test_get_next_executions_invalid:Function]
|
||||
# @PURPOSE: Verify invalid cron returns empty list.
|
||||
def test_get_next_executions_invalid():
|
||||
"""Test invalid cron returns empty list."""
|
||||
times = TranslationScheduler.get_next_executions("invalid cron", "UTC", n=3)
|
||||
assert times == []
|
||||
# [/DEF:test_get_next_executions_invalid:Function]
|
||||
# [/DEF:TranslateSchedulerTests:Module]
|
||||
Reference in New Issue
Block a user