# #region TranslateHistoryTests [C:2] [TYPE Module] # @SEMANTICS: tests, translate, history, metrics # @BRIEF Tests for run history list/detail endpoints and metrics aggregation. # @LAYER Tests # @RELATION BINDS_TO -> [TranslateRoutes] # @RELATION BINDS_TO -> [TranslationMetrics] # # @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] from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from datetime import UTC, datetime import pytest from unittest.mock import MagicMock import uuid from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from src.app import app from src.core.database import Base from src.dependencies import get_config_manager, get_current_user, get_db from src.models.translate import TranslationEvent, TranslationJob, 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) 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(UTC), ) db_session.add(run) db_session.flush() return run # #region test_list_runs_empty [C:2] [TYPE Function] # @BRIEF 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"] == [] # #endregion test_list_runs_empty # #region test_list_runs_with_data [C:2] [TYPE Function] # @BRIEF 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 # #endregion test_list_runs_with_data # #region test_list_runs_filter_job_id [C:2] [TYPE Function] # @BRIEF 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 # #endregion test_list_runs_filter_job_id # #region test_list_runs_filter_status [C:2] [TYPE Function] # @BRIEF 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("/api/translate/runs?run_status=FAILED") assert response.status_code == 200 data = response.json() for item in data["items"]: assert item["status"] == "FAILED" # #endregion test_list_runs_filter_status # #region test_get_run_detail [C:2] [TYPE Function] # @BRIEF 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(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" # #endregion test_get_run_detail # #region test_get_job_metrics [C:2] [TYPE Function] # @BRIEF 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 # #endregion test_get_job_metrics # #region test_get_all_metrics [C:2] [TYPE Function] # @BRIEF 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 # #endregion test_get_all_metrics # #region test_metrics_empty_job [C:2] [TYPE Function] # @BRIEF 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 # #endregion test_metrics_empty_job # #region test_run_detail_not_found [C:2] [TYPE Function] # @BRIEF 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 # #endregion test_run_detail_not_found # #region test_list_runs_includes_language_info [C:2] [TYPE Function] # @BRIEF Verify run list includes target_languages, total_translated, and language_stats. def test_list_runs_includes_language_info(client, mock_api_deps): """Test run list includes per-language fields.""" from src.models.translate import TranslationRunLanguageStats session = mock_api_deps["session"] job = _create_test_job(session) run = _create_test_run(session, job.id) # Add language stats ls = TranslationRunLanguageStats( id=str(uuid.uuid4()), run_id=run.id, language_code="ru", total_rows=100, translated_rows=80, failed_rows=5, skipped_rows=15, token_count=15000, estimated_cost=0.03, ) session.add(ls) session.commit() response = client.get("/api/translate/runs") assert response.status_code == 200 data = response.json() assert len(data["items"]) >= 1 item = data["items"][0] assert "target_languages" in item assert "total_translated" in item assert "language_stats" in item assert "ru" in item["target_languages"] assert item["total_translated"] == 80 assert item["language_stats"]["ru"]["translated_rows"] == 80 # #endregion test_list_runs_includes_language_info # #region test_metrics_per_language_breakdown [C:2] [TYPE Function] # @BRIEF Verify metrics endpoint returns per_language_metrics. def test_metrics_per_language_breakdown(client, mock_api_deps): """Test metrics returns per-language breakdown.""" from src.models.translate import TranslationRunLanguageStats session = mock_api_deps["session"] job = _create_test_job(session) run = _create_test_run(session, job.id) ls = TranslationRunLanguageStats( id=str(uuid.uuid4()), run_id=run.id, language_code="ru", total_rows=100, translated_rows=80, token_count=15000, estimated_cost=0.03, ) session.add(ls) session.commit() response = client.get(f"/api/translate/jobs/{job.id}/metrics") assert response.status_code == 200 data = response.json() assert "per_language_metrics" in data assert "ru" in data["per_language_metrics"] assert data["per_language_metrics"]["ru"]["tokens"] == 15000 assert data["per_language_metrics"]["ru"]["cost"] == 0.03 # #endregion test_metrics_per_language_breakdown # #region test_metric_snapshot_stores_per_language [C:2] [TYPE Function] # @BRIEF Verify MetricSnapshot stores per_language_metrics via prune_expired. def test_metric_snapshot_stores_per_language(client, mock_api_deps): """Test MetricSnapshot stores per_language_metrics after prune.""" from datetime import timedelta from src.models.translate import MetricSnapshot, TranslationEvent from src.plugins.translate.events import TranslationEventLog session = mock_api_deps["session"] job = _create_test_job(session) session.commit() # Create an old event with per-language data old_date = datetime.now(UTC) - timedelta(days=100) event = TranslationEvent( id=str(uuid.uuid4()), job_id=job.id, event_type="TRANSLATION_PHASE_COMPLETED", event_data={ "language_code": "ru", "token_count": 5000, "cost": 0.01, "run_id": "test-run-id", }, created_at=old_date, ) session.add(event) session.commit() # Prune events older than 30 days event_log = TranslationEventLog(session) result = event_log.prune_expired(retention_days=30) assert result["snapshot_id"] is not None # Verify snapshot has per_language_metrics snapshot = session.query(MetricSnapshot).filter( MetricSnapshot.id == result["snapshot_id"] ).first() assert snapshot is not None assert snapshot.per_language_metrics is not None assert "ru" in snapshot.per_language_metrics assert snapshot.per_language_metrics["ru"]["cumulative_tokens"] == 5000 assert snapshot.per_language_metrics["ru"]["cumulative_cost"] == 0.01 # #endregion test_metric_snapshot_stores_per_language # #region test_combined_metrics_live_and_snapshot [C:2] [TYPE Function] # @BRIEF Verify combined metrics merge live + snapshot per-language data. def test_combined_metrics_live_and_snapshot(client, mock_api_deps): """Test metrics merge live data with MetricSnapshot per-language data.""" from datetime import timedelta from src.models.translate import ( MetricSnapshot, TranslationRunLanguageStats, ) from src.plugins.translate.metrics import TranslationMetrics session = mock_api_deps["session"] job = _create_test_job(session) run = _create_test_run(session, job.id) session.commit() # Add snapshot with per-language data (simulating pruned period) snapshot = MetricSnapshot( id=str(uuid.uuid4()), job_id=job.id, key_hash="test_key", per_language_metrics={ "ru": {"cumulative_tokens": 100000, "cumulative_cost": 0.20, "runs": 3}, }, covers_events_before=datetime.now(UTC) - timedelta(days=90), total_records=100, snapshot_date=datetime.now(UTC), ) session.add(snapshot) # Add live language stats (recent runs) ls = TranslationRunLanguageStats( id=str(uuid.uuid4()), run_id=run.id, language_code="ru", total_rows=50, translated_rows=40, token_count=5000, estimated_cost=0.01, ) session.add(ls) session.commit() metrics_svc = TranslationMetrics(session) result = metrics_svc.get_job_metrics(job.id) assert "per_language_metrics" in result assert "ru" in result["per_language_metrics"] # Live 5000 + snapshot 100000 = 105000 assert result["per_language_metrics"]["ru"]["tokens"] == 105000 # Live 0.01 + snapshot 0.20 = 0.21 assert abs(result["per_language_metrics"]["ru"]["cost"] - 0.21) < 0.001 # Live 1 + snapshot 3 = 4 assert result["per_language_metrics"]["ru"]["runs"] == 4 # #endregion test_combined_metrics_live_and_snapshot # #endregion TranslateHistoryTests