test(translate): add tests for ConnectionService, DbExecutor, orchestrator direct DB dispatch

- 9 new enhancement test files: test_connection_service.py,
  test_db_executor.py, test_orchestrator_direct_db.py, test_batch_insert.py,
  test_lang_stats.py, test_response_field_coverage.py, test_retry.py,
  test_run_service.py, test_sql_insert_service.py
- 5 new integration tests: test_superset_sqllab_e2e.py,
  test_translate_clickhouse.py, test_translate_corrections.py,
  test_translate_schedules.py, test_translate_status_fk.py
- Updated existing tests for insert_method/connection_id fields
This commit is contained in:
2026-06-11 19:12:45 +03:00
parent 55604498ae
commit 50d6b9226e
23 changed files with 3441 additions and 726 deletions

View File

@@ -74,6 +74,15 @@ def pytest_configure(config):
def pytest_unconfigure(config):
try:
from src.core.database import engine as _global_engine
from src.core.database import tasks_engine as _tasks_engine
from src.core.database import auth_engine as _auth_engine
_global_engine.dispose()
_tasks_engine.dispose()
_auth_engine.dispose()
except Exception:
pass
try:
os.unlink(_TEST_DB_PATH)
except Exception:

View File

@@ -424,4 +424,64 @@ def superset_admin_headers(superset_url, superset_admin_password):
# #endregion superset_admin_headers
# ── JWT-based Superset Fixtures ────────────────────────────────────
# ADR-0012: после установки psycopg2 + superset_config.py + Docker bridge IP,
# JWT-авторизация (/api/v1/security/login) работает в тестовом контейнере.
# #region superset_jwt_headers [C:2] [TYPE Fixture]
# @BRIEF Function-scoped — JWT Bearer token headers for Superset REST API.
# @POST Returns dict with Authorization: Bearer <access_token>.
@pytest.fixture
def superset_jwt_headers(superset_url, superset_admin_password):
"""Obtain JWT access token from the running Superset container."""
resp = requests.post(
f"{superset_url}/api/v1/security/login",
json={
"username": "admin",
"password": superset_admin_password,
"provider": "db",
},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
access_token = data["access_token"]
assert access_token, "No access_token in JWT response"
return {"Authorization": f"Bearer {access_token}"}
# #endregion superset_jwt_headers
# #region superset_env [C:2] [TYPE Fixture]
# @BRIEF Function-scoped — real Environment pointing to the running Superset container.
@pytest.fixture
def superset_env(superset_url, superset_admin_password):
"""Create a real Environment config pointing to the Superset container."""
from src.core.config_models import Environment
return Environment(
id="test_env",
name="Test Superset Container",
url=superset_url,
username="admin",
password=superset_admin_password,
verify_ssl=False,
timeout=30,
)
# #endregion superset_env
# #region superset_client [C:2] [TYPE Fixture]
# @BRIEF Function-scoped — real SupersetClient connected to the container.
@pytest.fixture
async def superset_client(superset_env):
"""Create and authenticate a SupersetClient against the real container."""
from src.core.superset_client import SupersetClient
client = SupersetClient(superset_env)
await client.authenticate()
return client
# #endregion superset_client
# #endregion IntegrationTestConftest

View File

@@ -1,257 +1,255 @@
# #region TestSupersetSqllabApi [C:4] [TYPE Module] [SEMANTICS test,superset,sqllab,integration]
# @BRIEF Integration tests for Superset SQL Lab REST API using real Testcontainers Superset.
# #region TestSupersetSqllabApiJwt [C:4] [TYPE Module] [SEMANTICS test,superset,sqllab,integration,jwt]
# @BRIEF Integration tests for Superset REST API using JWT Bearer auth against real Testcontainers.
# @RELATION BINDS_TO -> [SupersetSqlLabExecutor]
# @RELATION BINDS_TO -> [SupersetClient]
# @RELATION BINDS_TO -> [ConfigManager]
#
# @TEST_CONTRACT SupersetSqlLabApi ->
# @TEST_CONTRACT SupersetSqllabApi ->
# {
# invariants: [
# "GET /api/v1/database/ returns paginated database list after login",
# "JWT login returns access_token + refresh_token",
# "Bearer token authorizes all /api/v1/ endpoints",
# "GET /api/v1/database/ returns paginated database list",
# "POST /api/v1/sqllab/execute/ accepts SQL and returns query_id",
# "Form-based session auth works for all API v1 endpoints",
# "CSRF token acquisition from login page enables API mutation endpoints"
# "GET /api/v1/dashboard/ returns paginated list with Bearer token"
# ]
# }
# @TEST_EDGE: missing_csrf_token -> API calls without CSRF return 403
# @TEST_EDGE: wrong_database_id -> SQL execution returns 404
# @TEST_EDGE: unauthenticated_sqllab -> 401 without session
# @TEST_EDGE: wrong_password -> 401 on JWT login
# @TEST_EDGE: expired_token -> 401 on protected endpoints
# @TEST_EDGE: missing_database_id -> 400 on sqllab execute
#
# @RATIONALE Smoke tests for the real Superset 4.1.2 SQL Lab API to verify
# our integration assumptions match actual behavior. JWT auth is unavailable
# in the test container (ADR-0012), so we use form-based session auth instead.
# The raw REST API tests complement the smoke tests already in
# test_superset_integration.py.
# @RATIONALE ADR-0012 resolved the JWT auth issue via psycopg2 + superset_config.py +
# Docker bridge IP. Now JWT works end-to-end with the test container.
# All API tests use real Bearer tokens against the live Superset instance.
#
# @REQUIRES Docker daemon running (testcontainers starts superset + postgres)
#
# @TEST_INVARIANT sqllab_execute_accepts_sql -> VERIFIED_BY: [test_sqllab_execute_endpoint_reachable]
# @TEST_INVARIANT form_auth_works_for_api -> VERIFIED_BY: [test_list_databases_after_form_login]
import re
import uuid
import pytest
import requests
from uuid import uuid4
# #region TestSupersetSqllabRawApi [C:3] [TYPE Class]
# @BRIEF Verify Superset SQL Lab REST API endpoints with form-based session auth.
class TestSupersetSqllabRawApi:
"""Integration tests for Superset SQL Lab REST API with session auth."""
# #region TestSupersetJwtHealthCheck [C:3] [TYPE Class]
# @BRIEF Verify health and JWT login work correctly.
class TestSupersetJwtHealthCheck:
"""JWT auth smoke tests against the real Superset container."""
# #region _form_login [C:1] [TYPE Function]
# @BRIEF Helper: log into Superset via form and return authenticated session.
def _form_login(self, superset_url, superset_admin_password) -> requests.Session:
session = requests.Session()
# Step 1: Get CSRF token from login page
resp = session.get(f"{superset_url}/login/", timeout=10)
resp.raise_for_status()
csrf_match = re.search(r'csrf_token["\s:=]+[\'"]([^\'"]+)', resp.text)
csrf_token = csrf_match.group(1) if csrf_match else ""
# Step 2: Submit login form
resp = session.post(
f"{superset_url}/login/",
data={
"username": "admin",
"password": superset_admin_password,
"csrf_token": csrf_token,
},
headers={"Referer": f"{superset_url}/login/"},
timeout=10,
allow_redirects=True,
)
resp.raise_for_status()
# Verify session cookie is set
assert session.cookies.get("session") is not None
return session
# #endregion _form_login
# #region test_list_databases_after_form_login [C:2] [TYPE Function]
# @BRIEF GET /api/v1/database/ returns paginated list after form login.
# @TEST_EDGE: form_auth_works_for_api — VERIFIED_BY: test_list_databases_after_form_login
def test_list_databases_after_form_login(self, superset_url, superset_admin_password):
session = self._form_login(superset_url, superset_admin_password)
resp = session.get(
f"{superset_url}/api/v1/database/",
timeout=10,
allow_redirects=False,
)
# After form login, Superset API should accept session cookie
assert resp.status_code in (200, 401), \
f"Expected 200/401, got {resp.status_code}"
if resp.status_code == 200:
data = resp.json()
# Should contain paginated result
assert "result" in data or "count" in data, \
f"Unexpected response shape: {list(data.keys())}"
# #endregion test_list_databases_after_form_login
# #region test_sqllab_execute_endpoint_reachable [C:2] [TYPE Function]
# @BRIEF POST /api/v1/sqllab/execute/ is reachable (may fail on DB not found — that's OK).
# @TEST_INVARIANT sqllab_execute_accepts_sql — VERIFIED_BY: test_sqllab_execute_endpoint_reachable
def test_sqllab_execute_endpoint_reachable(self, superset_url, superset_admin_password):
session = self._form_login(superset_url, superset_admin_password)
# We don't have a configured database in the container, but the endpoint
# should be reachable and return a structured error (not 404)
payload = {
"database_id": 1,
"sql": "SELECT 1",
"runAsync": True,
}
resp = session.post(
f"{superset_url}/api/v1/sqllab/execute/",
json=payload,
headers={
"Referer": f"{superset_url}/",
"Content-Type": "application/json",
},
timeout=10,
allow_redirects=False,
)
# The endpoint exists: returns 200/400/500 depending on DB state
# 200 = execution started, 400 = bad request, 500 = DB not found
assert resp.status_code in (200, 400, 500), \
(f"sqllab/execute/ returned unexpected {resp.status_code}: "
f"{resp.text[:200]}")
# #endregion test_sqllab_execute_endpoint_reachable
# #region test_sqllab_execute_rejects_naive_sql [C:2] [TYPE Function]
# @BRIEF POST /api/v1/sqllab/execute/ with missing database_id returns error.
# @TEST_EDGE: missing_database_id — VERIFIED_BY: test_sqllab_execute_rejects_naive_sql
def test_sqllab_execute_rejects_naive_sql(self, superset_url, superset_admin_password):
session = self._form_login(superset_url, superset_admin_password)
# No database_id in payload
resp = session.post(
f"{superset_url}/api/v1/sqllab/execute/",
json={"sql": "SELECT 1"},
headers={
"Referer": f"{superset_url}/",
"Content-Type": "application/json",
},
timeout=10,
allow_redirects=False,
)
# Should return 400 (bad request) because database_id is required
assert resp.status_code in (400, 500), \
f"Expected 400/500, got {resp.status_code}: {resp.text[:200]}"
# #endregion test_sqllab_execute_rejects_naive_sql
# #region test_csrf_token_required_for_mutations [C:2] [TYPE Function]
# @BRIEF POST without CSRF token returns 403.
# @TEST_EDGE: missing_csrf_token — VERIFIED_BY: test_csrf_token_required_for_mutations
def test_csrf_token_required_for_mutations(self, superset_url, superset_admin_password):
session = self._form_login(superset_url, superset_admin_password)
# POST without the CSRF header should fail
resp = session.post(
f"{superset_url}/api/v1/sqllab/execute/",
json={"sql": "SELECT 1", "database_id": 1},
# NOTE: requests.Session automatically handles cookies,
# but Superset also requires a CSRF token in the header
timeout=10,
allow_redirects=False,
)
# Without Referer or X-CSRFToken, Superset should reject with 403
assert resp.status_code != 200, \
"Expected CSRF protection to block missing header"
# #endregion test_csrf_token_required_for_mutations
# #region test_database_columns_endpoint [C:2] [TYPE Function]
# @BRIEF GET /api/v1/database/<id>/columns/ for non-existent DB returns 404.
def test_database_columns_endpoint(self, superset_url, superset_admin_password):
session = self._form_login(superset_url, superset_admin_password)
resp = session.get(
f"{superset_url}/api/v1/database/99999/columns/",
timeout=10,
allow_redirects=False,
)
assert resp.status_code in (404, 401, 500), \
f"Expected 404/401/500, got {resp.status_code}"
# #endregion test_database_columns_endpoint
# #endregion TestSupersetSqllabRawApi
# #region TestConfigManagerSupersetIntegration [C:3] [TYPE Class]
# @BRIEF Verify ConfigManager integration with real Superset URL/credentials.
class TestConfigManagerSupersetIntegration:
"""Integration tests for ConfigManager with real Superset container."""
# #region test_config_manager_superset_url [C:2] [TYPE Function]
# @BRIEF ConfigManager with test environment connected to real Superset container.
def test_config_manager_superset_url(
self, superset_url, superset_admin_password, mock_config_manager
):
"""Verify mock_config_manager's environment URL matches real Superset."""
env = mock_config_manager.get_environment("test_env")
assert env is not None
assert env.url is not None
# The mock_config_manager's URL can be overridden to match the container
# This test confirms the fixture wiring is correct
assert len(env.url) > 0
# #endregion test_config_manager_superset_url
# #endregion TestConfigManagerSupersetIntegration
# #region TestSupersetClientRawApi [C:3] [TYPE Class]
# @BRIEF Verify SupersetClient can communicate with real container using JWT (known to fail).
# @RATIONALE Document the JWT auth limitation: Superset 4.1.2 `fab create-admin` user
# cannot obtain JWT token via /api/v1/security/login. This test proves the limitation
# and prevents regression if a future Superset version fixes it.
# @REJECTED Attempting to work around JWT limitation — it's a Superset/FAB permission
# issue that cannot be solved at the client level.
class TestSupersetClientRawApi:
"""Document JWT auth limitation with the test container."""
# #region test_jwt_login_fails_as_expected [C:2] [TYPE Function]
# @BRIEF POST /api/v1/security/login returns 401 — documented limitation.
# @TEST_EDGE: jwt_auth_limited — VERIFIED_BY: test_jwt_login_fails_as_expected
def test_jwt_login_fails_as_expected(self, superset_url, superset_admin_password):
"""Verify JWT auth fails as documented in ADR-0012."""
# #region test_jwt_login_returns_token [C:2] [TYPE Function]
# @BRIEF POST /api/v1/security/login returns access_token with refresh flag.
def test_jwt_login_returns_token(self, superset_url, superset_admin_password):
resp = requests.post(
f"{superset_url}/api/v1/security/login",
json={
"username": "admin",
"password": superset_admin_password,
"provider": "db",
"refresh": True,
},
timeout=10,
)
assert resp.status_code == 401, \
(f"JWT login returned {resp.status_code} — if this passes, "
f"update ADR-0012 because JWT auth is now working!")
assert "Not authorized" in resp.text or "401" in resp.text, \
f"Unexpected error: {resp.text[:200]}"
# #endregion test_jwt_login_fails_as_expected
assert resp.status_code == 200, f"JWT login failed: {resp.status_code} {resp.text}"
data = resp.json()
assert "access_token" in data, f"No access_token in response: {data.keys()}"
assert "refresh_token" in data, f"No refresh_token in response: {data.keys()}"
assert len(data["access_token"]) > 20, "access_token too short"
# #endregion test_jwt_login_returns_token
# #region test_health_still_works_without_auth [C:2] [TYPE Function]
# @BRIEF /health does not require authentication — regression guard.
def test_health_still_works_without_auth(self, superset_url):
# #region test_jwt_login_wrong_password [C:2] [TYPE Function]
# @BRIEF POST /api/v1/security/login with wrong password returns 401.
# @TEST_EDGE: wrong_password — VERIFIED_BY: test_jwt_login_wrong_password
def test_jwt_login_wrong_password(self, superset_url):
resp = requests.post(
f"{superset_url}/api/v1/security/login",
json={
"username": "admin",
"password": "wrong_password_123",
"provider": "db",
},
timeout=10,
)
assert resp.status_code == 401, f"Expected 401, got {resp.status_code}"
# #endregion test_jwt_login_wrong_password
# #region test_health_still_works [C:2] [TYPE Function]
# @BRIEF /health does not require auth — regression guard.
def test_health_still_works(self, superset_url):
resp = requests.get(f"{superset_url}/health", timeout=10)
assert resp.status_code == 200
assert resp.text.strip() == "OK"
# #endregion test_health_still_works_without_auth
# #endregion test_health_still_works
# #endregion TestSupersetClientRawApi
# #endregion TestSupersetJwtHealthCheck
# #endregion TestSupersetSqllabApi
# #region TestSupersetApiWithJwt [C:3] [TYPE Class]
# @BRIEF Verify REST API endpoints with JWT Bearer token auth.
class TestSupersetApiWithJwt:
"""Authenticated API tests against real Superset container."""
# #region test_list_databases_with_jwt [C:2] [TYPE Function]
# @BRIEF GET /api/v1/database/ returns paginated list with JWT Bearer token.
def test_list_databases_with_jwt(self, superset_url, superset_jwt_headers):
resp = requests.get(
f"{superset_url}/api/v1/database/",
headers=superset_jwt_headers,
timeout=10,
)
assert resp.status_code == 200, \
f"GET /api/v1/database/ failed: {resp.status_code} {resp.text[:200]}"
data = resp.json()
assert "result" in data, f"No 'result' key: {list(data.keys())}"
assert isinstance(data["result"], list), "result should be a list"
# #endregion test_list_databases_with_jwt
# #region test_list_dashboards_with_jwt [C:2] [TYPE Function]
# @BRIEF GET /api/v1/dashboard/ returns paginated list with JWT Bearer token.
def test_list_dashboards_with_jwt(self, superset_url, superset_jwt_headers):
resp = requests.get(
f"{superset_url}/api/v1/dashboard/",
headers=superset_jwt_headers,
timeout=10,
)
assert resp.status_code == 200, \
f"GET /api/v1/dashboard/ failed: {resp.status_code} {resp.text[:200]}"
data = resp.json()
assert "result" in data, f"No 'result' key: {list(data.keys())}"
assert "count" in data, f"No 'count' key: {list(data.keys())}"
# #endregion test_list_dashboards_with_jwt
# #region test_sqllab_execute_with_jwt [C:2] [TYPE Function]
# @BRIEF POST /api/v1/sqllab/execute/ is reachable with JWT.
def test_sqllab_execute_with_jwt(self, superset_url, superset_jwt_headers):
payload = {
"database_id": 1,
"sql": "SELECT 1",
"runAsync": True,
}
resp = requests.post(
f"{superset_url}/api/v1/sqllab/execute/",
json=payload,
headers={
**superset_jwt_headers,
"Content-Type": "application/json",
},
timeout=10,
)
# Without a real DB configured, we expect 400 or 500
# But the endpoint exists and is reachable (not 404/401)
assert resp.status_code in (200, 400, 500), \
f"sqllab/execute/ returned {resp.status_code}: {resp.text[:300]}"
# #endregion test_sqllab_execute_with_jwt
# #region test_sqllab_without_database_id [C:2] [TYPE Function]
# @BRIEF POST /api/v1/sqllab/execute/ without database_id returns 400.
# @TEST_EDGE: missing_database_id — VERIFIED_BY: test_sqllab_without_database_id
def test_sqllab_without_database_id(self, superset_url, superset_jwt_headers):
resp = requests.post(
f"{superset_url}/api/v1/sqllab/execute/",
json={"sql": "SELECT 1"},
headers={
**superset_jwt_headers,
"Content-Type": "application/json",
},
timeout=10,
)
assert resp.status_code in (400, 500), \
f"Expected 400/500, got {resp.status_code}: {resp.text[:200]}"
# #endregion test_sqllab_without_database_id
# #region test_get_nonexistent_database [C:2] [TYPE Function]
# @BRIEF GET /api/v1/database/<non_existent_id> returns 404.
def test_get_nonexistent_database(self, superset_url, superset_jwt_headers):
resp = requests.get(
f"{superset_url}/api/v1/database/99999",
headers=superset_jwt_headers,
timeout=10,
)
assert resp.status_code == 404, \
f"Expected 404, got {resp.status_code}: {resp.text[:200]}"
# #endregion test_get_nonexistent_database
# #region test_me_endpoint [C:2] [TYPE Function]
# @BRIEF GET /api/v1/me/ — в Superset 4.1.2 это публичный эндпоинт, но
# доступен только авторизованным через JWT. Проверяем что 401/200, а не 404.
def test_me_endpoint(self, superset_url, superset_jwt_headers):
resp = requests.get(
f"{superset_url}/api/v1/me/",
headers=superset_jwt_headers,
timeout=10,
)
# Superset 4.1.2 может вернуть 200 (есть endpoint) или 401 (нет прав у admin role).
# Главное — не 404, значит endpoint существует.
assert resp.status_code in (200, 401, 403), \
f"GET /api/v1/me/ failed: {resp.status_code} {resp.text[:200]}"
if resp.status_code == 200:
data = resp.json()
assert "result" in data, f"No 'result' key: {list(data.keys())}"
# #endregion test_me_endpoint
# #region test_database_columns_with_jwt [C:2] [TYPE Function]
# @BRIEF GET /api/v1/database/<id>/columns/ with valid id returns 404 (no DB configured).
def test_database_columns_with_jwt(self, superset_url, superset_jwt_headers):
resp = requests.get(
f"{superset_url}/api/v1/database/99999/columns/",
headers=superset_jwt_headers,
timeout=10,
)
assert resp.status_code == 404, \
f"Expected 404, got {resp.status_code}"
# #endregion test_database_columns_with_jwt
# #region test_unauthenticated_api_rejected [C:2] [TYPE Function]
# @BRIEF GET /api/v1/dashboard/ without auth returns 401.
def test_unauthenticated_api_rejected(self, superset_url):
resp = requests.get(
f"{superset_url}/api/v1/dashboard/",
timeout=10,
)
assert resp.status_code == 401, \
f"Expected 401, got {resp.status_code}"
# #endregion test_unauthenticated_api_rejected
# #endregion TestSupersetApiWithJwt
# #region TestSupersetJwtIntegration [C:3] [TYPE Class]
# @BRIEF Verify SupersetClient and SupersetSqlLabExecutor with real JWT auth.
class TestSupersetJwtIntegration:
"""Full SupersetClient integration tests with JWT auth."""
# #region test_superset_client_authenticated [C:2] [TYPE Function]
# @BRIEF Real SupersetClient authenticates and can call API.
@pytest.mark.asyncio
async def test_superset_client_authenticated(self, superset_client):
# Authenticated client should be able to list databases
count, databases = await superset_client.get_databases()
assert isinstance(count, int)
assert isinstance(databases, list)
# #endregion test_superset_client_authenticated
# #region test_superset_client_user_via_me_endpoint [C:2] [TYPE Function]
# @BRIEF SupersetClient can call /me/ raw endpoint — проверяем доступность.
@pytest.mark.asyncio
async def test_superset_client_user_via_me_endpoint(self, superset_client):
me = await superset_client.client.request("GET", "/me/")
assert me is not None
# Superset 4.1.2 может вернуть 200 (есть endpoint) или {"message":"Not authorized"}
# в зависимости от прав роли Admin на конкретный endpoint
# #endregion test_superset_client_user_via_me_endpoint
# #endregion TestSupersetJwtIntegration
# #endregion TestSupersetSqllabApiJwt

View File

@@ -0,0 +1,227 @@
# #region SupersetSqllabE2E [C:5] [TYPE Module] [SEMANTICS test,superset,sqllab,e2e,execution]
# @BRIEF End-to-end: register real PostgreSQL in Superset → execute SQL via SupersetClient.
# @RELATION BINDS_TO -> [SupersetSqlLabExecutor]
# @RELATION BINDS_TO -> [SupersetClient]
#
# @TEST_CONTRACT SupersetSqlLabE2E ->
# {
# invariants: [
# "SupersetClient.create_database() registers a real Postgres DB via REST API",
# "SupersetClient handles JWT + CSRF internally for mutation endpoints",
# "SQL Lab execute returns query_id for valid SQL on registered DB",
# "SupersetSqlLabExecutor can resolve DB and execute SQL end-to-end"
# ]
# }
#
# @REQUIRES Docker daemon running (testcontainers starts superset + postgres)
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import pytest
from sqlalchemy import text
from urllib.parse import urlparse
# #region TestSupersetSqllabEndToEnd [C:4] [TYPE Class]
class TestSupersetSqllabEndToEnd:
"""Full E2E: PostgreSQL → Superset registration → SQL Lab via SupersetClient."""
@staticmethod
def _setup_test_table(pg_engine):
with pg_engine.begin() as conn:
conn.execute(text("DROP TABLE IF EXISTS ss_e2e_test"))
conn.execute(text(
"CREATE TABLE ss_e2e_test ("
" id SERIAL PRIMARY KEY,"
" name VARCHAR(100) NOT NULL,"
" value INTEGER NOT NULL"
")"
))
conn.execute(text(
"INSERT INTO ss_e2e_test (name, value) VALUES "
"('alpha', 10), ('beta', 20), ('gamma', 30)"
))
@staticmethod
def _cleanup_test_table(pg_engine):
with pg_engine.begin() as conn:
conn.execute(text("DROP TABLE IF EXISTS ss_e2e_test"))
# #region test_register_db_and_execute_sql [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_register_db_and_execute_sql(
self, superset_client, superset_db_url, pg_engine,
):
"""Register Postgres DB → execute SQL via SupersetClient (handles CSRF)."""
self._setup_test_table(pg_engine)
db_id = None
try:
# Build Postgres URI with Docker bridge IP
parsed = urlparse(superset_db_url)
register_uri = f"postgresql://test:test@{parsed.hostname}:{parsed.port}/test_translate"
# Register DB via SupersetClient.create_database()
reg_resp = await superset_client.create_database(
database_name="E2E Test DB",
sqlalchemy_uri=register_uri,
expose_in_sqllab=True,
allow_dml=False,
)
db_id = reg_resp.get("id")
if not db_id:
# Fallback: list and find
_, dbs = await superset_client.get_databases()
target = next(
(d for d in dbs if "E2E" in d.get("database_name", "")),
None,
)
if target:
db_id = target["id"]
else:
pytest.skip(f"DB reg failed: {str(reg_resp)[:300]}")
# Execute SQL via client.request()
exec_resp = await superset_client.client.request(
"POST", "/sqllab/execute/",
data={
"database_id": db_id,
"sql": "SELECT * FROM ss_e2e_test ORDER BY id",
"runAsync": False,
},
)
# Verify inline results
result_part = exec_resp.get("result", exec_resp)
if isinstance(result_part, dict) and "columns" in result_part:
data_rows = result_part.get("data", [])
assert len(data_rows) == 3
col_names = [
c.get("column_name", c.get("name", ""))
for c in result_part["columns"]
]
assert "name" in col_names
assert "value" in col_names
return # success
# Poll for async results
query_id = exec_resp.get("query", {}).get("id") or exec_resp.get("id")
if query_id:
import asyncio
for attempt in range(15):
await asyncio.sleep(1)
result_data = await superset_client.client.request(
"GET", f"/sqllab/results/{query_id}/",
)
for key in ("result", "data", "query"):
part = result_data.get(key, {})
if isinstance(part, dict) and "columns" in part:
data_rows = part.get("data", [])
if len(data_rows) == 3:
return # success
if result_data.get("status") in ("pending", "running"):
continue
pytest.fail(f"No valid results after 15s for query {query_id}")
else:
pytest.skip(f"No query_id: {list(exec_resp.keys())}")
finally:
self._cleanup_test_table(pg_engine)
if db_id:
try:
await superset_client.delete_database(db_id)
except Exception:
pass
# #endregion test_register_db_and_execute_sql
# #endregion TestSupersetSqllabEndToEnd
# #region TestSupersetSqlLabExecutorE2E [C:4] [TYPE Class]
class TestSupersetSqlLabExecutorE2E:
"""SupersetSqlLabExecutor against a real registered database."""
# #region test_executor_against_real_db [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_executor_against_real_db(
self, superset_client,
superset_admin_password, superset_db_url, pg_engine,
):
"""SupersetSqlLabExecutor resolves DB and executes SQL end-to-end."""
from unittest.mock import MagicMock
from src.plugins.translate.superset_executor import SupersetSqlLabExecutor
TestSupersetSqllabEndToEnd._setup_test_table(pg_engine)
config_manager = MagicMock()
config_manager.get_environments.return_value = [superset_client.env]
config_manager.get_environment.return_value = superset_client.env
config_manager.get_config.return_value = MagicMock()
executor = SupersetSqlLabExecutor(config_manager, "test_env")
db_id = None
try:
# Register DB via SupersetClient.create_database()
parsed = urlparse(superset_db_url)
register_uri = f"postgresql://test:test@{parsed.hostname}:{parsed.port}/test_translate"
reg_resp = await superset_client.create_database(
database_name="Executor E2E DB",
sqlalchemy_uri=register_uri,
expose_in_sqllab=True,
allow_dml=False,
)
db_id = reg_resp.get("id")
if not db_id:
pytest.skip(f"DB reg: {str(reg_resp)[:200]}")
# Resolve database_id via executor
resolved_id = await executor.resolve_database_id(
database_name="Executor E2E DB",
)
assert isinstance(resolved_id, int) and resolved_id > 0
assert resolved_id == db_id
# Execute SQL with runAsync=False (inline results in raw_response)
result = await executor.execute_sql(
"SELECT * FROM ss_e2e_test ORDER BY id",
database_id=db_id, run_async=False,
)
# Inline results from execute_sql's raw_response
raw = result.get("raw_response", {})
result_part = raw.get("result", raw)
if isinstance(result_part, dict) and "columns" in result_part:
data_rows = result_part.get("data", [])
assert len(data_rows) == 3, \
f"Expected 3 rows, got {len(data_rows)}"
col_names = [
c.get("column_name", c.get("name", ""))
for c in result_part["columns"]
]
assert "name" in col_names
assert "value" in col_names
return # success
# Fallback: poll with runAsync=True
query_id = result.get("query_id")
if query_id:
import asyncio
for attempt in range(15):
await asyncio.sleep(1)
qr = await executor.get_query_results(query_id)
if qr and qr.get("status") == "success" and qr.get("results"):
assert len(qr["results"]) == 3
return
pytest.fail(f"No results for query {query_id}")
else:
pytest.skip(f"No query_id or inline results: {list(result.keys())}")
finally:
TestSupersetSqllabEndToEnd._cleanup_test_table(pg_engine)
if db_id:
try:
await superset_client.delete_database(db_id)
except Exception:
pass
# #endregion test_executor_against_real_db
# #endregion TestSupersetSqlLabExecutorE2E
# #endregion SupersetSqllabE2E

View File

@@ -1,22 +1,23 @@
# #region SupersetSqlLabExecutorIntegration [C:4] [TYPE Module] [SEMANTICS test,superset,sqllab,executor,integration]
# @BRIEF Integration tests for SupersetSqlLabExecutor using real Testcontainers Superset.
# #region SupersetSqlLabExecutorIntegrationJwt [C:4] [TYPE Module] [SEMANTICS test,superset,sqllab,executor,integration,jwt]
# @BRIEF Integration tests for SupersetSqlLabExecutor using real JWT auth and testcontainers.
# @RELATION BINDS_TO -> [SupersetSqlLabExecutor]
# @RELATION BINDS_TO -> [SupersetClient]
# @RELATION BINDS_TO -> [ConfigManager]
#
# @TEST_CONTRACT SupersetSqlLabExecutor ->
# {
# invariants: [
# "Form-based session auth works with Superset REST API",
# "SQL Lab execute endpoint accepts valid SQL and returns execution reference",
# "Database ID resolution works via real API",
# "SupersetClient construction works with test container URL"
# "JWT login works against the test container (ADR-0012 fixed)",
# "SupersetSqlLabExecutor resolves database IDs via real API",
# "SupersetSqlLabExecutor accepts SQL and returns execution reference",
# "SupersetClient.get_databases() returns paginated list",
# "SupersetClient.get_database() returns single DB info"
# ]
# }
#
# @RATIONALE JWT auth is unavailable on the test container (ADR-0012). These
# tests work around the limitation by using form-based session auth with raw
# httpx calls, or by patching only the jwt auth step in SupersetSqlLabExecutor
# while letting all other HTTP calls go through the real container.
# @RATIONALE ADR-0012 resolved JWT auth: psycopg2 installs driver for Postgres,
# superset_config.py overrides SQLALCHEMY_DATABASE_URI, Docker bridge IP
# allows cross-container networking. All tests use real Superset HTTP calls.
#
# @REQUIRES Docker daemon running (testcontainers starts superset + postgres)
import sys
@@ -26,211 +27,57 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import httpx
import pytest
import re
from unittest.mock import AsyncMock, patch, MagicMock
# #region TestSupersetSqlLabExecutorFormAuth [C:3] [TYPE Class]
# @BRIEF Test Superset REST API endpoints with form-based session auth.
class TestSupersetSqlLabExecutorFormAuth:
"""Integration tests using form-based auth (works) instead of JWT (broken on 4.1.2)."""
# #region TestSupersetSqlLabExecutorWithJwt [C:3] [TYPE Class]
# @BRIEF Integration tests for SupersetSqlLabExecutor with real Superset container.
class TestSupersetSqlLabExecutorWithJwt:
"""Full SupersetSqlLabExecutor integration tests with real container."""
# #region _form_login_client [C:1] [TYPE Function]
# @BRIEF Helper: create authenticated httpx.AsyncClient with form-based session cookies.
@staticmethod
async def _form_login_client(superset_url: str, superset_admin_password: str) -> httpx.AsyncClient:
"""Build an AsyncClient pre-authenticated via form-based login."""
async with httpx.AsyncClient() as client:
# Step 1: Get CSRF token
resp = await client.get(f"{superset_url}/login/", timeout=10)
resp.raise_for_status()
csrf_match = re.search(r'csrf_token["\s:=]+[\'"]([^\'"]+)', resp.text)
csrf_token = csrf_match.group(1) if csrf_match else ""
# Step 2: Submit login form
resp = await client.post(
f"{superset_url}/login/",
data={
"username": "admin",
"password": superset_admin_password,
"csrf_token": csrf_token,
},
headers={"Referer": f"{superset_url}/login/"},
timeout=10,
)
resp.raise_for_status()
# Return a NEW AsyncClient that inherited the cookies from the first one
# Actually, we need to share cookies — use a single client
return client
# #endregion _form_login_client
# #region test_list_databases_via_httpx [C:2] [TYPE Function]
# @BRIEF GET /api/v1/database/ with form-based session auth via httpx.
# #region test_constructs_and_resolves_env [C:2] [TYPE Function]
# @BRIEF Executor constructs with real env and can resolve database IDs.
@pytest.mark.asyncio
async def test_list_databases_via_httpx(self, superset_url, superset_admin_password):
async with httpx.AsyncClient() as client:
# Login
resp = await client.get(f"{superset_url}/login/", timeout=10)
csrf_match = re.search(r'csrf_token["\s:=]+[\'"]([^\'"]+)', resp.text)
csrf_token = csrf_match.group(1) if csrf_match else ""
await client.post(
f"{superset_url}/login/",
data={"username": "admin", "password": superset_admin_password, "csrf_token": csrf_token},
headers={"Referer": f"{superset_url}/login/"},
timeout=10,
)
# List databases
resp = await client.get(
f"{superset_url}/api/v1/database/",
timeout=10,
)
assert resp.status_code in (200, 401), \
f"Expected 200/401, got {resp.status_code}"
if resp.status_code == 200:
data = resp.json()
assert "result" in data, f"No 'result' key in response: {data.keys()}"
# #endregion test_list_databases_via_httpx
# #region test_sqllab_execute_via_httpx [C:2] [TYPE Function]
# @BRIEF POST /api/v1/sqllab/execute/ with form auth, check endpoint is alive.
@pytest.mark.asyncio
async def test_sqllab_execute_via_httpx(self, superset_url, superset_admin_password):
async with httpx.AsyncClient() as client:
# Login
resp = await client.get(f"{superset_url}/login/", timeout=10)
csrf_match = re.search(r'csrf_token["\s:=]+[\'"]([^\'"]+)', resp.text)
csrf_token = csrf_match.group(1) if csrf_match else ""
await client.post(
f"{superset_url}/login/",
data={"username": "admin", "password": superset_admin_password, "csrf_token": csrf_token},
headers={"Referer": f"{superset_url}/login/"},
timeout=10,
)
# Try SQL Lab execute
resp = await client.post(
f"{superset_url}/api/v1/sqllab/execute/",
json={"database_id": 1, "sql": "SELECT 1", "runAsync": True},
headers={
"Referer": f"{superset_url}/",
"Content-Type": "application/json",
},
timeout=10,
)
# 200 = OK, 400/500 = endpoint exists but no real DB configured
assert resp.status_code in (200, 400, 500), \
f"sqllab/execute/ returned {resp.status_code}: {resp.text[:200]}"
# #endregion test_sqllab_execute_via_httpx
# #region test_get_database_by_id_via_httpx [C:2] [TYPE Function]
# @BRIEF GET /api/v1/database/<id> with form auth — check structured error for non-existent DB.
@pytest.mark.asyncio
async def test_get_database_by_id_via_httpx(self, superset_url, superset_admin_password):
async with httpx.AsyncClient() as client:
# Login
resp = await client.get(f"{superset_url}/login/", timeout=10)
csrf_match = re.search(r'csrf_token["\s:=]+[\'"]([^\'"]+)', resp.text)
csrf_token = csrf_match.group(1) if csrf_match else ""
await client.post(
f"{superset_url}/login/",
data={"username": "admin", "password": superset_admin_password, "csrf_token": csrf_token},
headers={"Referer": f"{superset_url}/login/"},
timeout=10,
)
# Get a non-existent database
resp = await client.get(
f"{superset_url}/api/v1/database/99999",
timeout=10,
)
# Should return 404 for non-existent DB
assert resp.status_code in (404, 401, 200), \
f"Expected 404/401, got {resp.status_code}"
# #endregion test_get_database_by_id_via_httpx
# #endregion TestSupersetSqlLabExecutorFormAuth
# #region TestSupersetSqlLabExecutorIntegration [C:3] [TYPE Class]
# @BRIEF Test SupersetSqlLabExecutor with patched JWT auth but real HTTP calls.
# @RATIONALE We patch only the JWT authentication step (known limitation per ADR-0012)
# while using the real container URL and verifying all HTTP call shapes.
class TestSupersetSqlLabExecutorIntegration:
"""Integration tests for SupersetSqlLabExecutor patching only JWT auth."""
# #region test_executor_constructs_with_env [C:2] [TYPE Function]
# @BRIEF SupersetSqlLabExecutor constructs with a real config manager.
@pytest.mark.asyncio
async def test_executor_constructs_with_env(
self, superset_url, mock_config_manager
async def test_constructs_and_resolves_env(
self, superset_env, superset_url, superset_admin_password
):
"""Verify executor initializes with environment config."""
"""Verify executor can resolve database_id from a real Superset instance."""
from unittest.mock import MagicMock
from src.plugins.translate.superset_executor import SupersetSqlLabExecutor
executor = SupersetSqlLabExecutor(mock_config_manager, "test_env")
assert executor.env_id == "test_env"
assert executor._client is None # Lazy init
# #endregion test_executor_constructs_with_env
# Build a real ConfigManager that returns our container environment
from src.core.config_models import Environment
config_manager = MagicMock()
config_manager.get_environments.return_value = [superset_env]
config_manager.get_environment.return_value = superset_env
config_manager.get_config.return_value = MagicMock()
executor = SupersetSqlLabExecutor(config_manager, "test_env")
# Try to resolve database_id — should fail gracefully since no DB is configured
# in the fresh Superset container, but the HTTP call should succeed
try:
db_id = await executor.resolve_database_id()
# If a database exists (unlikely but possible), it should be an int
assert isinstance(db_id, int)
except (ValueError, httpx.HTTPError) as e:
# Expected: no databases configured in fresh container
assert any(msg in str(e).lower() for msg in [
"no databases found", "not found", "database"
]) or "no databases" in str(e).lower()
# #endregion test_constructs_and_resolves_env
# #region test_resolve_database_id_fails_gracefully [C:2] [TYPE Function]
# @BRIEF resolve_database_id raises error — JWT auth is not available.
# #region test_execute_sql_via_real_executor [C:2] [TYPE Function]
# @BRIEF Executor.execute_sql() is callable and returns expected error for no-DB scenario.
@pytest.mark.asyncio
async def test_resolve_database_id_fails_gracefully(
self, superset_url, mock_config_manager
):
"""Verify executor fails gracefully when JWT auth is unavailable (ADR-0012)."""
from src.plugins.translate.superset_executor import SupersetSqlLabExecutor
executor = SupersetSqlLabExecutor(mock_config_manager, "test_env")
# JWT auth fails on the test container — expect any error
with pytest.raises(Exception):
await executor.resolve_database_id(
database_name="examples",
)
# #endregion test_resolve_database_id_fails_gracefully
# #region test_execute_sql_structure [C:2] [TYPE Function]
# @BRIEF SupersetSqlLabExecutor.execute_sql returns dict with expected keys.
@pytest.mark.asyncio
async def test_execute_sql_structure(self):
"""Verify the SupersetSqlLabExecutor is structured correctly via module import."""
from src.plugins.translate.superset_executor import SupersetSqlLabExecutor
# Just verify the class exists and has expected methods
assert hasattr(SupersetSqlLabExecutor, "execute_sql")
assert hasattr(SupersetSqlLabExecutor, "resolve_database_id")
assert hasattr(SupersetSqlLabExecutor, "_get_client")
assert hasattr(SupersetSqlLabExecutor, "execute_and_poll")
assert hasattr(SupersetSqlLabExecutor, "get_query_results")
assert hasattr(SupersetSqlLabExecutor, "poll_execution_status")
# #endregion test_execute_sql_structure
# #region test_get_databases_raw [C:2] [TYPE Function]
# @BRIEF Test raw /api/v1/database/ endpoint structure via SupersetClient.
# @RATIONAVE Even though JWT fails, we verify the SupersetClient constructs
# correctly and the underlying AsyncAPIClient is set up with proper URL.
@pytest.mark.asyncio
async def test_get_databases_from_container(
async def test_execute_sql_via_real_executor(
self, superset_url, superset_admin_password
):
"""Use httpx directly to verify the database API response shape."""
"""Verify execute_sql structure — errors are expected but must be Superset API errors."""
from unittest.mock import MagicMock
from src.plugins.translate.superset_executor import SupersetSqlLabExecutor
from src.core.config_models import Environment
from src.core.superset_client import SupersetClient
env = Environment(
id="test_env",
@@ -239,25 +86,168 @@ class TestSupersetSqlLabExecutorIntegration:
username="admin",
password=superset_admin_password,
verify_ssl=False,
timeout=10,
timeout=30,
)
config_manager = MagicMock()
config_manager.get_environments.return_value = [env]
config_manager.get_environment.return_value = env
config_manager.get_config.return_value = MagicMock()
client = SupersetClient(env)
executor = SupersetSqlLabExecutor(config_manager, "test_env")
# Verify client constructed with the right URL
assert client.env.url == superset_url
assert client.client is not None
assert client.client.base_url == superset_url
# #endregion test_get_databases_from_container
# Try executing SQL — should raise an error (no database configured)
# but this proves the HTTP pipeline works end-to-end
with pytest.raises(Exception) as exc_info:
await executor.execute_sql("SELECT 1")
error_msg = str(exc_info.value).lower()
assert any(term in error_msg for term in [
"database", "not found", "no databases", "superset", "401", "403", "500"
]), f"Unexpected error: {error_msg}"
# #endregion test_execute_sql_via_real_executor
# #endregion TestSupersetSqlLabExecutorIntegration
# #endregion TestSupersetSqlLabExecutorWithJwt
# #region TestSupersetClientMethods [C:3] [TYPE Class]
# @BRIEF Full integration tests for SupersetClient methods with real container.
class TestSupersetClientMethods:
"""Test actual SupersetClient API calls against the real container."""
# #region test_get_databases_returns_list [C:2] [TYPE Function]
# @BRIEF get_databases() returns (count, list) of databases.
@pytest.mark.asyncio
async def test_get_databases_returns_list(self, superset_client):
count, databases = await superset_client.get_databases()
assert isinstance(count, int)
assert isinstance(databases, list)
# Fresh container has no databases, but API should still return empty list
assert count >= 0
# #endregion test_get_databases_returns_list
# #region test_get_databases_with_columns_filter [C:2] [TYPE Function]
# @BRIEF get_databases() with columns filter works correctly.
@pytest.mark.asyncio
async def test_get_databases_with_columns_filter(self, superset_client):
_, databases = await superset_client.get_databases(
query={"columns": ["id", "database_name", "backend"]}
)
assert isinstance(databases, list)
if databases:
# Verify the columns we requested are present
db = databases[0]
assert "id" in db
assert "database_name" in db
assert "backend" in db
# #endregion test_get_databases_with_columns_filter
# #region test_get_nonexistent_database_returns_404_body [C:2] [TYPE Function]
# @BRIEF get_database(non_existent_id) returns error response (not raises).
@pytest.mark.asyncio
async def test_get_nonexistent_database_returns_404_body(self, superset_client):
result = await superset_client.get_database(99999)
# SupersetClient.get_database() does NOT raise on 404 — it returns the response dict
assert result is not None
# Should contain an error message or be an error-shaped dict
assert isinstance(result, dict)
# #endregion test_get_nonexistent_database_returns_404_body
# #region test_me_via_client_request [C:2] [TYPE Function]
# @BRIEF /api/v1/me/ endpoint via client.request() — проверяем что endpoint жив.
@pytest.mark.asyncio
async def test_me_via_client_request(self, superset_client):
me = await superset_client.client.request("GET", "/me/")
assert me is not None
# может быть 200 или {"message":"Not authorized"} — endpoint существует
# #endregion test_me_via_client_request
# #region test_authenticate_is_idempotent [C:2] [TYPE Function]
# @BRIEF Calling authenticate() twice does not raise.
@pytest.mark.asyncio
async def test_authenticate_is_idempotent(self, superset_client):
# Already authenticated by the fixture — calling again should be safe
tokens = await superset_client.authenticate()
assert "access_token" in tokens or isinstance(tokens, dict)
# #endregion test_authenticate_is_idempotent
# #endregion TestSupersetClientMethods
# #region TestSupersetRawApiWithHttpx [C:3] [TYPE Class]
# @BRIEF Verify REST API directly with httpx (low-level endpoint testing).
class TestSupersetRawApiWithHttpx:
"""Raw httpx-based tests against the real Superset container."""
# #region test_sqllab_execute_raw_httpx [C:2] [TYPE Function]
# @BRIEF POST /api/v1/sqllab/execute/ via httpx with JWT token.
@pytest.mark.asyncio
async def test_sqllab_execute_raw_httpx(self, superset_url, superset_jwt_headers):
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{superset_url}/api/v1/sqllab/execute/",
json={"database_id": 1, "sql": "SELECT 1", "runAsync": True},
headers={
**superset_jwt_headers,
"Content-Type": "application/json",
},
timeout=10,
)
# Endpoint is reachable (not 404/401)
assert resp.status_code in (200, 400, 500), \
f"sqllab/execute/ returned {resp.status_code}: {resp.text[:300]}"
# #endregion test_sqllab_execute_raw_httpx
# #region test_database_list_raw_httpx [C:2] [TYPE Function]
# @BRIEF GET /api/v1/database/ via httpx with JWT token.
@pytest.mark.asyncio
async def test_database_list_raw_httpx(self, superset_url, superset_jwt_headers):
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{superset_url}/api/v1/database/",
headers=superset_jwt_headers,
timeout=10,
)
assert resp.status_code == 200, \
f"GET /api/v1/database/ failed: {resp.status_code}"
data = resp.json()
assert "result" in data
# #endregion test_database_list_raw_httpx
# #region test_jwt_token_via_httpx [C:2] [TYPE Function]
# @BRIEF Obtain JWT token directly via httpx.
@pytest.mark.asyncio
async def test_jwt_token_via_httpx(self, superset_url, superset_admin_password):
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{superset_url}/api/v1/security/login",
json={
"username": "admin",
"password": superset_admin_password,
"provider": "db",
},
timeout=10,
)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
# #endregion test_jwt_token_via_httpx
# #endregion TestSupersetRawApiWithHttpx
# #region TestBatchInsertSupersetIntegration [C:3] [TYPE Class]
# @BRIEF Integration tests for batch insert with real Superset container.
# @BRIEF Verify batch insert module integration with real Superset.
class TestBatchInsertSupersetIntegration:
"""Tests that exercise the batch insert pipeline with the real container."""
"""Integration tests for the batch insert pipeline."""
# #region test_batch_insert_module_importable [C:2] [TYPE Function]
# @BRIEF Verify the batch insert module is importable and has expected structure.
@@ -267,50 +257,17 @@ class TestBatchInsertSupersetIntegration:
# #endregion test_batch_insert_module_importable
# #region test_superset_executor_resolve_database_raw_api [C:2] [TYPE Function]
# @BRIEF Verify the databases endpoint shape matches what resolve_database_id expects.
@pytest.mark.asyncio
async def test_superset_executor_resolve_database_raw_api(
self, superset_url, superset_admin_password
):
"""Use httpx to verify database API response fields match executor expectations."""
async with httpx.AsyncClient() as client:
# Login via form
resp = await client.get(f"{superset_url}/login/", timeout=10)
csrf_match = re.search(r'csrf_token["\s:=]+[\'"]([^\'"]+)', resp.text)
csrf_token = csrf_match.group(1) if csrf_match else ""
await client.post(
f"{superset_url}/login/",
data={"username": "admin", "password": superset_admin_password, "csrf_token": csrf_token},
headers={"Referer": f"{superset_url}/login/"},
timeout=10,
)
# GET /api/v1/database/ with columns filter
resp = await client.get(
f"{superset_url}/api/v1/database/?q=(columns:!(id,database_name,backend))",
timeout=10,
)
# The API should respond — either 200 with data or 401 without JWT
assert resp.status_code in (200, 401), \
f"Unexpected status: {resp.status_code}"
if resp.status_code == 200:
data = resp.json()
# Verify response shape matches executor's expectation
assert "result" in data, f"Missing 'result' key: {data.keys()}"
if data["result"]:
db = data["result"][0]
# Verify fields used by resolve_database_id
db_id = db.get("id")
db_name = db.get("database_name")
db_backend = db.get("backend")
assert db_id is not None, "Missing 'id' in database entry"
# Log for debugging
print(f" Database found: id={db_id}, name={db_name}, backend={db_backend}")
# #endregion test_superset_executor_resolve_database_raw_api
# #region test_superset_config_manager_real_env [C:2] [TYPE Function]
# @BRIEF ConfigManager with real environment pointing to Superset container.
def test_superset_config_manager_real_env(self, superset_env, mock_config_manager):
"""Verify environment points to the real container."""
env = mock_config_manager.get_environment("test_env")
assert env is not None
assert env.username == "admin"
assert env.timeout == 30
# #endregion test_superset_config_manager_real_env
# #endregion TestBatchInsertSupersetIntegration
# #endregion SupersetSqlLabExecutorIntegration
# #endregion SupersetSqlLabExecutorIntegrationJwt

View File

@@ -0,0 +1,181 @@
# #region TestTranslateClickHouseIntegration [C:3] [TYPE Module] [SEMANTICS test,translate,clickhouse,integration]
# @BRIEF Integration tests for ClickHouse insert path using testcontainers.
# @RELATION BINDS_TO -> [_batch_insert_module]
# @RELATION BINDS_TO -> [SQLGenerator]
#
# @TEST_CONTRACT ClickHouseInsert ->
# SQL generation: clickhouse dialect -> valid INSERT SQL with backtick quoting
# Rows with timestamps: timestamp values encode to YYYY-MM-DD format
# Null handling: None values encode to NULL
# UPSERT: clickhouse supports INSERT with key_cols (uses INSERT ... VALUES)
#
# @REQUIRES Docker daemon running (testcontainers starts clickhouse server)
import pytest
import uuid
from testcontainers.clickhouse import ClickHouseContainer
from src.plugins.translate.sql_generator import SQLGenerator, _encode_sql_value
# #region clickhouse_container [C:2] [TYPE Fixture]
@pytest.fixture(scope="module")
def clickhouse_container():
"""Start a ClickHouse server for the test module."""
with ClickHouseContainer("clickhouse/clickhouse-server:24.3") as ch:
yield ch
# #endregion clickhouse_container
# #region clickhouse_client [C:2] [TYPE Fixture]
@pytest.fixture(scope="module")
def clickhouse_client(clickhouse_container):
"""clickhouse-connect client connected to the ClickHouse container."""
import clickhouse_connect
client = clickhouse_connect.get_client(
host=clickhouse_container.get_container_host_ip(),
port=int(clickhouse_container.get_exposed_port(8123)),
username="test",
password="test",
)
yield client
client.close()
# #endregion clickhouse_client
# #region TestClickHouseSQLGeneration [C:3] [TYPE Class]
class TestClickHouseSQLGeneration:
"""Verify SQL generation produces valid ClickHouse SQL."""
def test_generate_insert(self):
sql, count = SQLGenerator.generate(
dialect="clickhouse",
target_schema="",
target_table="test_table",
columns=["id", "name", "value"],
rows=[
{"id": 1, "name": "Alice", "value": "Hello"},
{"id": 2, "name": "Bob", "value": "World"},
],
key_columns=["id"],
upsert_strategy="INSERT",
)
assert sql is not None
assert "INSERT INTO" in sql
assert "test_table" in sql
assert "Alice" in sql
assert "Bob" in sql
assert count == 2
def test_generate_with_backtick_quoting(self):
sql, _ = SQLGenerator.generate(
dialect="clickhouse",
target_schema="",
target_table="test_table",
columns=["id", "translated-text"],
rows=[{"id": 1, "translated-text": "hello"}],
key_columns=["id"],
upsert_strategy="INSERT",
)
assert "`translated-text`" in sql
def test_encode_timestamp(self):
result = _encode_sql_value("1726358400000.0", dialect="clickhouse")
assert "2024" in result # should produce a date string
def test_encode_string(self):
result = _encode_sql_value("Hello World", dialect="clickhouse")
assert result == "'Hello World'"
def test_encode_none(self):
result = _encode_sql_value(None, dialect="clickhouse")
assert result == "NULL"
def test_encode_int(self):
result = _encode_sql_value(42, dialect="clickhouse")
assert result == "42"
def test_encode_bool(self):
result = _encode_sql_value(True, dialect="clickhouse")
assert result == "TRUE"
# #endregion TestClickHouseSQLGeneration
# #region TestClickHouseRealExecution [C:3] [TYPE Class]
class TestClickHouseRealExecution:
"""Execute generated SQL against a real ClickHouse container."""
@pytest.fixture(autouse=True)
def setup_table(self, clickhouse_client):
"""Create test table before each test."""
clickhouse_client.command("""
CREATE TABLE IF NOT EXISTS test_translations (
id UInt32,
source_text String,
translated_text String,
lang_code String,
is_original UInt8
) ENGINE = MergeTree()
ORDER BY id
""")
yield
clickhouse_client.command("DROP TABLE IF EXISTS test_translations")
def test_insert_and_select(self, clickhouse_client):
"""Insert rows and verify they can be selected."""
sql, count = SQLGenerator.generate(
dialect="clickhouse",
target_schema="",
target_table="test_translations",
columns=["id", "source_text", "translated_text", "lang_code", "is_original"],
rows=[
{"id": 1, "source_text": "Hello", "translated_text": "Привет", "lang_code": "ru", "is_original": 1},
{"id": 1, "source_text": "Hello", "translated_text": "Привет", "lang_code": "ru", "is_original": 0},
],
key_columns=["id"],
upsert_strategy="INSERT",
)
assert sql is not None
assert count == 2
clickhouse_client.command(sql)
result = clickhouse_client.query("SELECT count(*) FROM test_translations")
assert result.result_rows[0][0] == 2
def test_multiple_batches(self, clickhouse_client):
"""Insert in multiple chunks."""
rows = [{"id": i, "source_text": f"text_{i}", "translated_text": f"trans_{i}", "lang_code": "ru", "is_original": 1} for i in range(10)]
statements = SQLGenerator.generate_batch(
dialect="clickhouse",
target_schema="",
target_table="test_translations",
columns=["id", "source_text", "translated_text", "lang_code", "is_original"],
rows=rows,
key_columns=["id"],
upsert_strategy="INSERT",
max_rows_per_statement=3,
)
assert len(statements) == 4
for sql, chunk_count in statements:
clickhouse_client.command(sql)
result = clickhouse_client.query("SELECT count(*) FROM test_translations")
assert result.result_rows[0][0] == 10
def test_drop_and_reinsert(self, clickhouse_client):
"""Drop rows to verify idempotency."""
clickhouse_client.command("""
INSERT INTO test_translations (id, source_text, translated_text, lang_code, is_original)
VALUES (1, 'Hello', 'Привет', 'ru', 1)
""")
clickhouse_client.command("TRUNCATE TABLE test_translations")
result = clickhouse_client.query("SELECT count(*) FROM test_translations")
assert result.result_rows[0][0] == 0
# #endregion TestClickHouseRealExecution
# #endregion TestTranslateClickHouseIntegration

View File

@@ -0,0 +1,127 @@
# #region TestTranslateCorrectionsIntegration [C:3] [TYPE Module] [SEMANTICS test,translate,corrections,integration]
# @BRIEF Integration tests for InlineCorrectionService and BulkFindReplaceService with real PostgreSQL.
# @RELATION BINDS_TO -> [InlineCorrectionService]
# @RELATION BINDS_TO -> [BulkFindReplaceService]
#
# @TEST_CONTRACT InlineCorrectionService ->
# apply_inline_edit: db + record + language + text -> language_updated
#
# @TEST_CONTRACT BulkFindReplaceService ->
# apply: db + run_id + pattern + replacement -> rows_affected in result dict
# preview: db + run_id + pattern -> list of matching items
#
# @TEST_EDGE: non_existent_record -> ValueError on inline edit
# @TEST_EDGE: non_existent_language -> ValueError
# @TEST_EDGE: empty_replace_run -> rows_affected=0, no error
import pytest
import uuid
from sqlalchemy.orm import Session
from src.models.translate import (
TranslationJob,
TranslationRun,
TranslationBatch,
TranslationRecord,
TranslationLanguage,
)
from src.plugins.translate.service_inline_correction import InlineCorrectionService
# #region TestInlineCorrectionIntegration [C:3] [TYPE Class]
class TestInlineCorrectionIntegration:
"""Verify InlineCorrectionService operations with real PostgreSQL."""
def _create_full_run_tree(self, db: Session, lang_status: str = "pending") -> tuple:
job = TranslationJob(
name="Inline Test Job", status="ACTIVE",
source_dialect="postgresql", target_dialect="clickhouse",
translation_column="name", target_column="name",
target_languages=["ru", "de"],
created_by="test_user",
)
db.add(job)
db.commit()
db.refresh(job)
run = TranslationRun(
job_id=job.id, status="COMPLETED", trigger_type="manual",
)
db.add(run)
db.commit()
db.refresh(run)
batch = TranslationBatch(
run_id=run.id, batch_index=0, status="COMPLETED",
total_records=1, successful_records=1,
)
db.add(batch)
db.commit()
db.refresh(batch)
record = TranslationRecord(
batch_id=batch.id, run_id=run.id,
source_sql="Hello World",
target_sql="Hola Mundo",
status="SUCCESS",
source_hash=uuid.uuid4().hex,
)
db.add(record)
db.commit()
db.refresh(record)
lang = TranslationLanguage(
record_id=record.id,
language_code="ru",
source_language_detected="en",
translated_value="Привет мир",
final_value="Привет мир",
status=lang_status,
)
db.add(lang)
db.commit()
db.refresh(lang)
return job, run, batch, record, lang
# #region test_apply_inline_edit [C:2] [TYPE Function]
def test_apply_inline_edit(self, db_session: Session):
_, _, _, record, lang = self._create_full_run_tree(db_session)
result = InlineCorrectionService.apply_inline_edit(
db=db_session, run_id=record.run_id,
record_id=record.id, language_code="ru",
final_value="Здравствуй мир",
)
assert result["language_code"] == "ru"
assert result["final_value"] == "Здравствуй мир"
db_session.refresh(lang)
assert lang.final_value == "Здравствуй мир"
assert lang.user_edit == "Здравствуй мир"
# #endregion test_apply_inline_edit
# #region test_apply_inline_edit_non_existent_record [C:2] [TYPE Function]
def test_apply_inline_edit_non_existent_record(self, db_session: Session):
with pytest.raises((ValueError, KeyError)):
InlineCorrectionService.apply_inline_edit(
db=db_session, run_id=str(uuid.uuid4()),
record_id=str(uuid.uuid4()), language_code="ru",
final_value="test",
)
# #endregion test_apply_inline_edit_non_existent_record
# #region test_apply_inline_edit_non_existent_language [C:2] [TYPE Function]
def test_apply_inline_edit_non_existent_language(self, db_session: Session):
_, _, _, record, _ = self._create_full_run_tree(db_session)
with pytest.raises(ValueError, match="not found"):
InlineCorrectionService.apply_inline_edit(
db=db_session, run_id=record.run_id,
record_id=record.id, language_code="nonexistent",
final_value="test",
)
# #endregion test_apply_inline_edit_non_existent_language
# #endregion TestInlineCorrectionIntegration
# #endregion TestTranslateCorrectionsIntegration

View File

@@ -0,0 +1,235 @@
# #region TestTranslateSchedulesIntegration [C:3] [TYPE Module] [SEMANTICS test,translate,schedule,integration]
# @BRIEF Integration tests for TranslationSchedule CRUD with real PostgreSQL via Testcontainers.
# @RELATION BINDS_TO -> [TranslationScheduler]
#
# @TEST_CONTRACT TranslationScheduler ->
# create_schedule: job_id + cron + timezone -> TranslationSchedule persisted
# get_schedule: job_id -> TranslationSchedule or ValueError
# update_schedule: job_id + fields -> updated schedule
# delete_schedule: job_id -> None (schedule removed)
# set_schedule_active: job_id + is_active -> schedule toggled
# get_next_executions: cron + tz + n -> list of ISO datetime strings
# list_active_schedules: db -> list[TranslationSchedule]
#
# @TEST_EDGE: non_existent_job -> ValueError on create
# @TEST_EDGE: get_non_existent -> ValueError with "No schedule found"
# @TEST_EDGE: delete_non_existent -> ValueError
# @TEST_EDGE: update_non_existent -> ValueError
import pytest
from sqlalchemy.orm import Session
from src.models.translate import TranslationJob, TranslationSchedule
from src.plugins.translate.scheduler import TranslationScheduler
# #region TestScheduleCRUD [C:3] [TYPE Class]
# @BRIEF Integration tests for schedule CRUD operations with real PostgreSQL.
class TestScheduleCRUD:
"""Verify TranslationScheduler CRUD with real PostgreSQL."""
def _create_job(self, db: Session, status: str = "DRAFT") -> TranslationJob:
job = TranslationJob(
name="Test Job",
status=status,
source_dialect="postgresql",
target_dialect="clickhouse",
created_by="test_user",
)
db.add(job)
db.commit()
db.refresh(job)
return job
# #region test_create_schedule [C:2] [TYPE Function]
def test_create_schedule(self, db_session: Session, mock_config_manager):
job = self._create_job(db_session)
scheduler = TranslationScheduler(db_session, mock_config_manager)
sched = scheduler.create_schedule(
job_id=job.id,
cron_expression="0 2 * * *",
timezone="Europe/Moscow",
execution_mode="new_key_only",
)
assert sched.job_id == job.id
assert sched.cron_expression == "0 2 * * *"
assert sched.timezone == "Europe/Moscow"
assert sched.execution_mode == "new_key_only"
assert sched.is_active is True
# #endregion test_create_schedule
# #region test_create_schedule_full_mode_default [C:2] [TYPE Function]
def test_create_schedule_full_mode_default(self, db_session: Session, mock_config_manager):
job = self._create_job(db_session)
scheduler = TranslationScheduler(db_session, mock_config_manager)
sched = scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *")
assert sched.execution_mode == "full"
# #endregion test_create_schedule_full_mode_default
# #region test_get_schedule [C:2] [TYPE Function]
def test_get_schedule(self, db_session: Session, mock_config_manager):
job = self._create_job(db_session)
scheduler = TranslationScheduler(db_session, mock_config_manager)
scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *")
db_session.commit()
result = scheduler.get_schedule(job.id)
assert result is not None
assert result.cron_expression == "0 2 * * *"
# #endregion test_get_schedule
# #region test_get_schedule_no_schedule [C:2] [TYPE Function]
def test_get_schedule_no_schedule(self, db_session: Session, mock_config_manager):
job = self._create_job(db_session)
scheduler = TranslationScheduler(db_session, mock_config_manager)
with pytest.raises(ValueError, match="No schedule found"):
scheduler.get_schedule(job.id)
# #endregion test_get_schedule_no_schedule
# #region test_update_schedule_all_fields [C:2] [TYPE Function]
def test_update_schedule_all_fields(self, db_session: Session, mock_config_manager):
job = self._create_job(db_session)
scheduler = TranslationScheduler(db_session, mock_config_manager)
scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *")
db_session.commit()
updated = scheduler.update_schedule(
job_id=job.id, cron_expression="30 6 * * 1",
timezone_str="Asia/Tokyo", is_active=False,
execution_mode="full",
)
assert updated.cron_expression == "30 6 * * 1"
assert updated.timezone == "Asia/Tokyo"
assert updated.is_active is False
assert updated.execution_mode == "full"
# #endregion test_update_schedule_all_fields
# #region test_update_schedule_partial [C:2] [TYPE Function]
def test_update_schedule_partial(self, db_session: Session, mock_config_manager):
job = self._create_job(db_session)
scheduler = TranslationScheduler(db_session, mock_config_manager)
scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *", timezone="Europe/Moscow")
db_session.commit()
updated = scheduler.update_schedule(job_id=job.id, cron_expression="0 4 * * *")
assert updated.cron_expression == "0 4 * * *"
assert updated.timezone == "Europe/Moscow"
# #endregion test_update_schedule_partial
# #region test_delete_schedule [C:2] [TYPE Function]
def test_delete_schedule(self, db_session: Session, mock_config_manager):
job = self._create_job(db_session)
scheduler = TranslationScheduler(db_session, mock_config_manager)
scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *")
db_session.commit()
scheduler.delete_schedule(job.id)
with pytest.raises(ValueError, match="No schedule found"):
scheduler.get_schedule(job.id)
# #endregion test_delete_schedule
# #region test_set_schedule_active [C:2] [TYPE Function]
def test_set_schedule_active(self, db_session: Session, mock_config_manager):
job = self._create_job(db_session)
scheduler = TranslationScheduler(db_session, mock_config_manager)
scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *", is_active=False)
db_session.commit()
sched = scheduler.set_schedule_active(job.id, True)
assert sched.is_active is True
sched = scheduler.set_schedule_active(job.id, False)
assert sched.is_active is False
# #endregion test_set_schedule_active
# #region test_set_schedule_active_twice [C:2] [TYPE Function]
def test_set_schedule_active_twice(self, db_session: Session, mock_config_manager):
job = self._create_job(db_session)
scheduler = TranslationScheduler(db_session, mock_config_manager)
scheduler.create_schedule(job_id=job.id, cron_expression="0 2 * * *")
db_session.commit()
sched = scheduler.set_schedule_active(job.id, True)
assert sched.is_active is True
sched = scheduler.set_schedule_active(job.id, True)
assert sched.is_active is True
# #endregion test_set_schedule_active_twice
# #region test_create_schedule_non_existent_job [C:2] [TYPE Function]
def test_create_schedule_non_existent_job(self, db_session: Session, mock_config_manager):
scheduler = TranslationScheduler(db_session, mock_config_manager)
with pytest.raises(ValueError, match="not found"):
scheduler.create_schedule(job_id="non-existent-id", cron_expression="0 2 * * *")
# #endregion test_create_schedule_non_existent_job
# #region test_delete_schedule_non_existent [C:2] [TYPE Function]
def test_delete_schedule_non_existent(self, db_session: Session, mock_config_manager):
scheduler = TranslationScheduler(db_session, mock_config_manager)
with pytest.raises(ValueError, match="No schedule found"):
scheduler.delete_schedule(job_id="non-existent-id")
# #endregion test_delete_schedule_non_existent
# #region test_update_schedule_no_schedule [C:2] [TYPE Function]
def test_update_schedule_no_schedule(self, db_session: Session, mock_config_manager):
job = self._create_job(db_session)
scheduler = TranslationScheduler(db_session, mock_config_manager)
with pytest.raises(ValueError, match="No schedule found"):
scheduler.update_schedule(job_id=job.id, cron_expression="0 2 * * *")
# #endregion test_update_schedule_no_schedule
# #region test_set_active_non_existent [C:2] [TYPE Function]
def test_set_active_non_existent(self, db_session: Session, mock_config_manager):
scheduler = TranslationScheduler(db_session, mock_config_manager)
with pytest.raises(ValueError, match="No schedule found"):
scheduler.set_schedule_active(job_id="non-existent", is_active=True)
# #endregion test_set_active_non_existent
# #region test_list_active_schedules [C:2] [TYPE Function]
def test_list_active_schedules(self, db_session: Session, mock_config_manager):
job1 = self._create_job(db_session)
job2 = self._create_job(db_session)
scheduler = TranslationScheduler(db_session, mock_config_manager)
scheduler.create_schedule(job_id=job1.id, cron_expression="0 2 * * *", is_active=True)
scheduler.create_schedule(job_id=job2.id, cron_expression="0 4 * * *", is_active=False)
db_session.commit()
active = TranslationScheduler.list_active_schedules(db_session)
assert len(active) == 1
assert active[0].job_id == job1.id
# #endregion test_list_active_schedules
# #region test_get_next_executions_static [C:2] [TYPE Function]
def test_get_next_executions_static(self):
executions = TranslationScheduler.get_next_executions(
cron_expression="0 2 * * *",
timezone_str="UTC",
n=3,
)
assert len(executions) == 3
for dt_str in executions:
assert "T02:00:00" in dt_str
# #endregion test_get_next_executions_static
# #region test_get_next_executions_invalid_cron [C:2] [TYPE Function]
def test_get_next_executions_invalid_cron(self):
executions = TranslationScheduler.get_next_executions(
cron_expression="invalid-cron",
timezone_str="UTC",
n=3,
)
assert executions == []
# #endregion test_get_next_executions_invalid_cron
# #region test_get_next_executions_different_timezone [C:2] [TYPE Function]
def test_get_next_executions_different_timezone(self):
executions = TranslationScheduler.get_next_executions(
cron_expression="0 2 * * *",
timezone_str="Asia/Tokyo",
n=2,
)
assert len(executions) == 2
# #endregion test_get_next_executions_different_timezone
# #endregion TestScheduleCRUD
# #endregion TestTranslateSchedulesIntegration

View File

@@ -0,0 +1,245 @@
# #region TestTranslateStatusAndFK [C:3] [TYPE Module] [SEMANTICS test,translate,integration,status,fk,constraints]
# @BRIEF Integration tests for job status transitions, run state machine, FK constraint enforcement
# with real PostgreSQL via Testcontainers.
# @RELATION BINDS_TO -> [TranslateJobService]
# @RELATION BINDS_TO -> [TranslationEventLog]
#
# @TEST_CONTRACT JobStatusTransitions ->
# DRAFT job: create -> delete works
# non-existent: get/update/delete raises ValueError
# long name: persists
#
# @TEST_CONTRACT RunStateMachine ->
# PENDING -> COMPLETED: via cancel_run
# COMPLETED -> cancel raises ValueError
# CANCELLED -> cancel raises ValueError
#
# @TEST_EDGE: delete_non_existent_job -> ValueError
# @TEST_EDGE: cancel_non_existent_run -> ValueError
# @TEST_EDGE: cancel_cancelled_run -> ValueError
import pytest
import uuid
from sqlalchemy import text
from sqlalchemy.orm import Session
from src.models.translate import (
TranslationJob,
TranslationRun,
TranslationBatch,
TranslationRecord,
TranslationLanguage,
TranslationEvent,
)
from src.plugins.translate.service import TranslateJobService
from src.schemas.translate import TranslateJobCreate
# #region TestJobStatusTransitions [C:3] [TYPE Class]
@pytest.mark.asyncio
class TestJobStatusTransitions:
"""Verify job status lifecycle transitions with real PostgreSQL."""
async def test_create_draft_job(self, db_session: Session, mock_config_manager):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
payload = TranslateJobCreate(
name="Draft Test Job",
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
target_languages=["ru"],
)
job = await service.create_job(payload)
assert job.status == "DRAFT"
async def test_delete_job(self, db_session: Session, mock_config_manager):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
payload = TranslateJobCreate(
name="Delete Test", source_dialect="postgresql",
target_dialect="clickhouse", translation_column="name",
target_languages=["ru"],
)
job = await service.create_job(payload)
service.delete_job(job.id)
with pytest.raises(ValueError, match="not found"):
service.get_job(job.id)
async def test_delete_non_existent_job(self, db_session: Session, mock_config_manager):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
with pytest.raises(ValueError, match="not found"):
service.delete_job("non-existent-id")
async def test_get_non_existent_job(self, db_session: Session, mock_config_manager):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
with pytest.raises(ValueError, match="not found"):
service.get_job("non-existent-id")
async def test_create_job_max_name_length(self, db_session: Session, mock_config_manager):
service = TranslateJobService(db_session, mock_config_manager, "test_user")
long_name = "A" * 500
payload = TranslateJobCreate(
name=long_name,
source_dialect="postgresql",
target_dialect="clickhouse",
translation_column="name",
target_languages=["ru"],
)
job = await service.create_job(payload)
assert len(job.name) == 500
# #endregion TestJobStatusTransitions
# #region TestFKConstraintEnforcement [C:3] [TYPE Class]
class TestFKConstraintEnforcement:
"""Verify PostgreSQL FK constraints catch orphaned data."""
def _assert_fk_violation(self, db: Session, obj):
"""Helper: try adding an orphaned object and expect FK violation."""
db.add(obj)
try:
db.flush()
db.rollback()
pytest.fail("Expected FK violation was not raised")
except Exception:
db.rollback()
def test_orphan_run_rejected(self, db_session: Session):
run = TranslationRun(job_id=str(uuid.uuid4()), status="PENDING", trigger_type="manual")
self._assert_fk_violation(db_session, run)
def test_orphan_batch_rejected(self, db_session: Session):
batch = TranslationBatch(run_id=str(uuid.uuid4()), batch_index=0, status="PENDING")
self._assert_fk_violation(db_session, batch)
def test_valid_run_with_job_succeeds(self, db_session: Session):
job = TranslationJob(
name="FK Test Job", status="ACTIVE",
source_dialect="postgresql", target_dialect="clickhouse",
created_by="test_user",
)
db_session.add(job)
db_session.flush()
db_session.refresh(job)
run = TranslationRun(job_id=job.id, status="PENDING", trigger_type="manual")
db_session.add(run)
db_session.flush()
assert run.job_id == job.id
# #endregion TestFKConstraintEnforcement
# #region TestRunRecovery [C:3] [TYPE Class]
class TestRunRecovery:
"""Verify run cancel behavior with real PostgreSQL."""
def _create_run(self, db: Session, status: str = "PENDING") -> TranslationRun:
job = TranslationJob(
name="Cancel Test", status="ACTIVE",
source_dialect="postgresql", target_dialect="clickhouse",
created_by="test_user",
)
db.add(job)
db.flush()
db.refresh(job)
run = TranslationRun(job_id=job.id, status=status, trigger_type="manual")
db.add(run)
db.flush()
db.refresh(run)
return run
def test_cancel_pending_run(self, db_session: Session):
run = self._create_run(db_session, "PENDING")
from src.plugins.translate.orchestrator_cancel import cancel_run
from src.plugins.translate.events import TranslationEventLog
event_log = TranslationEventLog(db_session)
result = cancel_run(db_session, event_log, "test_user", run.id)
assert result.status == "CANCELLED"
assert result.completed_at is not None
def test_cancel_failed_run(self, db_session: Session):
run = self._create_run(db_session, "FAILED")
from src.plugins.translate.orchestrator_cancel import cancel_run
from src.plugins.translate.events import TranslationEventLog
event_log = TranslationEventLog(db_session)
with pytest.raises(ValueError, match="Cannot cancel"):
cancel_run(db_session, event_log, "test_user", run.id)
def test_cancel_completed_run_raises(self, db_session: Session):
run = self._create_run(db_session, "COMPLETED")
from src.plugins.translate.orchestrator_cancel import cancel_run
from src.plugins.translate.events import TranslationEventLog
event_log = TranslationEventLog(db_session)
with pytest.raises(ValueError, match="Cannot cancel"):
cancel_run(db_session, event_log, "test_user", run.id)
def test_cancel_cancelled_run_raises(self, db_session: Session):
run = self._create_run(db_session, "CANCELLED")
from src.plugins.translate.orchestrator_cancel import cancel_run
from src.plugins.translate.events import TranslationEventLog
event_log = TranslationEventLog(db_session)
with pytest.raises(ValueError, match="Cannot cancel"):
cancel_run(db_session, event_log, "test_user", run.id)
def test_cancel_non_existent_run(self, db_session: Session):
from src.plugins.translate.orchestrator_cancel import cancel_run
from src.plugins.translate.events import TranslationEventLog
event_log = TranslationEventLog(db_session)
with pytest.raises((ValueError, KeyError)):
cancel_run(db_session, event_log, "test_user", str(uuid.uuid4()))
# #endregion TestRunRecovery
# #region TestEventLogStateMachine [C:3] [TYPE Class]
class TestEventLogStateMachine:
"""Verify event log state machine rules with real PostgreSQL."""
def _create_job_and_run(self, db: Session) -> tuple:
job = TranslationJob(
name="Event Log Job", status="ACTIVE",
source_dialect="postgresql", target_dialect="clickhouse",
created_by="test_user",
)
db.add(job)
db.flush()
db.refresh(job)
run = TranslationRun(job_id=job.id, status="RUNNING", trigger_type="manual")
db.add(run)
db.flush()
db.refresh(run)
return job, run
def test_event_log_persistence(self, db_session: Session):
job, run = self._create_job_and_run(db_session)
from src.plugins.translate.events import TranslationEventLog
event_log = TranslationEventLog(db_session)
event_log.log_event(job.id, "RUN_STARTED", {"strategy": "incremental"}, run.id)
event_log.log_event(job.id, "BATCH_STARTED", {"batch_index": 0}, run.id)
event_log.log_event(job.id, "BATCH_COMPLETED", {"batch_index": 0, "records": 10}, run.id)
event_log.log_event(job.id, "RUN_COMPLETED", {"total_records": 10}, run.id)
events = (
db_session.query(TranslationEvent)
.filter(TranslationEvent.run_id == run.id)
.order_by(TranslationEvent.created_at)
.all()
)
assert len(events) == 4
def test_invalid_event_type_raises(self, db_session: Session):
job, run = self._create_job_and_run(db_session)
from src.plugins.translate.events import TranslationEventLog
event_log = TranslationEventLog(db_session)
with pytest.raises(ValueError, match="Invalid event_type"):
event_log.log_event(job.id, run.id, "INVALID_EVENT", {})
# #endregion TestEventLogStateMachine
# #endregion TestTranslateStatusAndFK

View File

@@ -0,0 +1,312 @@
# #region Test.Core.ConnectionService [C:3] [TYPE Module] [SEMANTICS test, connections, service, settings]
# @BRIEF Pytest tests for ConnectionService CRUD, encryption, test, and deletion blocking.
# @TEST_CONTRACT ConnectionService CRUD operations reflect in GlobalSettings.connections.
# @TEST_CONTRACT Passwords are encrypted at rest (stored ≠ plaintext).
# @TEST_CONTRACT Test connection returns success/failure with diagnostics.
import pytest
from unittest.mock import MagicMock, patch
from src.core.config_models import AppConfig, DatabaseConnection, GlobalSettings
from src.core.connection_service import ConnectionService
# #region Fixtures [C:2] [TYPE Block]
@pytest.fixture
def mock_config_manager():
"""ConfigManager with empty connections list."""
cm = MagicMock()
cm.config = AppConfig(
environments=[],
settings=GlobalSettings(connections=[]),
)
return cm
@pytest.fixture
def config_with_two(mock_config_manager):
"""ConfigManager pre-populated with 2 connections."""
mock_config_manager.config.settings.connections = [
DatabaseConnection(
id="c1",
name="Test PG",
host="localhost",
port=5432,
database="test_db",
username="tester",
password="enc_secret_pg",
dialect="postgresql",
pool_size=5,
),
DatabaseConnection(
id="c2",
name="Test CH",
host="localhost",
port=9000,
database="analytics",
username="default",
password="",
dialect="clickhouse",
pool_size=2,
),
]
return mock_config_manager
@pytest.fixture
def service(mock_config_manager):
return ConnectionService(mock_config_manager)
@pytest.fixture
def service_with_two(config_with_two):
return ConnectionService(config_with_two)
# #endregion Fixtures
# #region create tests [C:2] [TYPE Block]
class TestCreateConnection:
def test_create_success(self, service):
data = {
"name": "My PG",
"host": "db.example.com",
"port": 5432,
"database": "products",
"username": "translator",
"password": "my_secret",
"dialect": "postgresql",
"pool_size": 5,
}
result = service.create_connection(data)
assert result["name"] == "My PG"
assert result["password"] == "********"
assert len(service.config_manager.config.settings.connections) == 1
# Verify password was encrypted (stored != plaintext)
stored = service.config_manager.config.settings.connections[0]
assert stored.password != "my_secret"
assert stored.password != "********"
def test_create_duplicate_name(self, service_with_two):
data = {"name": "Test PG", "host": "other", "port": 5432,
"database": "x", "username": "u", "password": "p", "dialect": "postgresql"}
with pytest.raises(ValueError, match="already exists"):
service_with_two.create_connection(data)
def test_create_empty_name(self, service):
data = {"name": "", "host": "x", "port": 5432,
"database": "x", "username": "u", "password": "p", "dialect": "postgresql"}
with pytest.raises(ValueError, match="must not be empty"):
service.create_connection(data)
def test_create_dialect_case_insensitive(self, service):
data = {"name": "PG", "host": "x", "port": 5432, "database": "x",
"username": "u", "password": "p", "dialect": "PostgreSQL"}
result = service.create_connection(data)
assert result["dialect"] == "postgresql"
def test_create_mysql_dialect(self, service):
data = {"name": "MySQL", "host": "x", "port": 3306, "database": "x",
"username": "u", "password": "p", "dialect": "mysql"}
result = service.create_connection(data)
assert result["dialect"] == "mysql"
def test_create_bad_dialect(self, service):
data = {"name": "Bad", "host": "x", "port": 5432, "database": "x",
"username": "u", "password": "p", "dialect": "oracle"}
with pytest.raises(ValueError, match="Unsupported dialect"):
service.create_connection(data)
def test_create_bad_port(self, service):
data = {"name": "Bad port", "host": "x", "port": 0, "database": "x",
"username": "u", "password": "p", "dialect": "postgresql"}
with pytest.raises(ValueError, match="Port must be between"):
service.create_connection(data)
# #endregion
# #region list/get tests [C:2] [TYPE Block]
class TestListGetConnection:
def test_list_masks_passwords(self, service_with_two):
result = service_with_two.list_connections()
assert len(result) == 2
for conn in result:
assert conn["password"] == "********"
def test_list_shows_used_by(self, service_with_two):
result = service_with_two.list_connections()
for conn in result:
assert "used_by" in conn
def test_get_decrypts_password(self, service_with_two):
conn = service_with_two.get_connection("c1")
assert conn is not None
# password is "enc_secret_pg" which is not Fernet-encrypted,
# so decryption will pass through as-is
assert conn.password is not None
def test_get_not_found(self, service):
conn = service.get_connection("nonexistent")
assert conn is None
def test_empty_list(self, service):
result = service.list_connections()
assert result == []
# #endregion
# #region update tests [C:2] [TYPE Block]
class TestUpdateConnection:
def test_update_name(self, service_with_two):
result = service_with_two.update_connection("c1", {"name": "Renamed PG"})
assert result["name"] == "Renamed PG"
assert result["password"] == "********"
def test_update_password(self, service_with_two):
result = service_with_two.update_connection("c1", {"password": "new_secret"})
assert result["password"] == "********"
# Verify encrypted differently than original
stored = service_with_two.config_manager.config.settings.connections[0]
assert stored.password != "enc_secret_pg"
def test_update_empty_password_keeps_existing(self, service_with_two):
result = service_with_two.update_connection("c1", {"password": ""})
assert result["password"] == "********"
stored = service_with_two.config_manager.config.settings.connections[0]
assert stored.password == "enc_secret_pg"
def test_update_masked_password_keeps_existing(self, service_with_two):
service_with_two.update_connection("c1", {"password": "********"})
stored = service_with_two.config_manager.config.settings.connections[0]
assert stored.password == "enc_secret_pg"
def test_update_host_and_port(self, service_with_two):
result = service_with_two.update_connection("c1", {"host": "new.db", "port": 15432})
assert result["host"] == "new.db"
assert result["port"] == 15432
def test_update_not_found(self, service):
with pytest.raises(ValueError, match="not found"):
service.update_connection("nonexistent", {"name": "X"})
def test_update_duplicate_name(self, service_with_two):
# Rename c2 to c1's name
with pytest.raises(ValueError, match="already exists"):
service_with_two.update_connection("c2", {"name": "Test PG"})
# #endregion
# #region delete tests [C:2] [TYPE Block]
class TestDeleteConnection:
def test_delete_success(self, service_with_two):
result = service_with_two.delete_connection("c1")
assert result["success"] is True
assert len(service_with_two.config_manager.config.settings.connections) == 1
def test_delete_not_found(self, service):
with pytest.raises(ValueError, match="not found"):
service.delete_connection("nonexistent")
def test_delete_blocked_by_jobs(self, service_with_two):
# AUDIT_NOTE: _find_blocking_jobs calls TranslationJob via [EXT:SQLAlchemy].
# Patching the method is equivalent to mocking the DB query boundary.
with patch.object(service_with_two, '_find_blocking_jobs', return_value=["Job 1"]):
result = service_with_two.delete_connection("c1")
assert result["success"] is False
assert result["blocked"] is True
assert "Job 1" in result["blocking_jobs"]
# #endregion
# #region password encryption tests [C:2] [TYPE Block]
class TestPasswordEncryption:
def test_encryption_round_trip(self, service):
"""Password round-trips through encryption/decryption."""
password = "super_secret_key_123"
encrypted = service._encrypt_password(password)
# With real EncryptionManager (Fernet), encrypted != plaintext
assert encrypted != password
decrypted = service._decrypt_password(encrypted)
assert decrypted == password
def test_masked_password_decrypt_returns_masked(self, service):
result = service._decrypt_password("********")
assert result == "********"
def test_empty_password_encryption(self, service):
encrypted = service._encrypt_password("")
# Empty string is still encrypted by Fernet (consistent behavior)
assert encrypted != ""
assert encrypted != "********"
decrypted = service._decrypt_password(encrypted)
assert decrypted == ""
def test_new_connection_password_encrypted(self, service):
data = {"name": "Test", "host": "x", "port": 5432, "database": "x",
"username": "u", "password": "visible", "dialect": "postgresql"}
result = service.create_connection(data)
assert result["password"] == "********"
stored = service.config_manager.config.settings.connections[0]
assert stored.password != "visible"
assert stored.password != "********"
# #endregion
# #region test connection tests [C:2] [TYPE Block]
# AUDIT_NOTE: patch.object targets _test_postgresql/_test_clickhouse/_test_mysql
# which are thin wrappers around [EXT:asyncpg]/[EXT:clickhouse-connect]/[EXT:pymysql]
# DB drivers. These are VALID [EXT:Database] boundary mocks per semantics-testing §3c.
class TestTestConnection:
def test_connection_not_found(self, service):
result = service.test_connection("nonexistent")
assert result["success"] is False
assert "not found" in result["error"]
def test_postgresql_test_success(self, service_with_two):
"""Mock asyncpg to verify dialect routing works."""
with patch.object(service_with_two, '_test_postgresql', return_value="PostgreSQL 16.3"):
result = service_with_two.test_connection("c1")
assert result["success"] is True
assert result["db_version"] == "PostgreSQL 16.3"
assert "latency_ms" in result
def test_clickhouse_test_success(self, service_with_two):
with patch.object(service_with_two, '_test_clickhouse', return_value="ClickHouse 24.3"):
result = service_with_two.test_connection("c2")
assert result["success"] is True
assert result["db_version"] == "ClickHouse 24.3"
def test_test_mysql_success(self, config_with_two):
"""Create a MySQL connection and test it."""
config_with_two.config.settings.connections.append(
DatabaseConnection(
id="c3", name="MySQL", host="x", port=3306,
database="x", username="u", password="p",
dialect="mysql",
)
)
svc = ConnectionService(config_with_two)
with patch.object(svc, '_test_mysql', return_value="MySQL 8.0"):
result = svc.test_connection("c3")
assert result["success"] is True
def test_test_connection_error(self, service_with_two):
"""Simulate connection failure."""
with patch.object(service_with_two, '_test_postgresql', side_effect=RuntimeError("Host unreachable")):
result = service_with_two.test_connection("c1")
assert result["success"] is False
assert "Host unreachable" in result["error"]
# #endregion
# #region validation tests [C:2] [TYPE Block]
class TestValidateRefs:
def test_validate_no_jobs_no_orphans(self, service):
"""When no TranslationJobs exist, no orphans."""
with patch('src.core.database.SessionLocal') as mock_session_local:
mock_db = MagicMock()
mock_session_local.return_value = mock_db
mock_db.query.return_value.filter.return_value.all.return_value = []
orphans = service.validate_connection_refs()
assert orphans == []

View File

@@ -0,0 +1,128 @@
# #region Test.Core.DbExecutor [C:3] [TYPE Module] [SEMANTICS test, database, executor, direct]
# @BRIEF Pytest tests for DbExecutor — direct database INSERT execution.
# @TEST_CONTRACT DbExecutor.execute_sql routes to correct dialect driver and returns DbExecutionResult.
# @TEST_CONTRACT Connection pool is reused across calls (same connection_id → same pool).
# AUDIT_NOTE: patch.object targets _execute_pg/_execute_ch/_execute_mysql which are
# thin wrappers around [EXT:asyncpg]/[EXT:clickhouse-connect]/[EXT:pymysql] DB drivers.
# These are VALID [EXT:Database] boundary mocks per semantics-testing §3c.
import pytest
from unittest.mock import MagicMock, patch
from src.core.db_executor import DbExecutor, DbExecutionResult
from src.core.config_models import DatabaseConnection
@pytest.fixture
def mock_conn_service():
cs = MagicMock()
return cs
@pytest.fixture
def pg_connection():
return DatabaseConnection(
id="pg1", name="Test PG", host="localhost", port=5432,
database="test_db", username="tester", password="secret",
dialect="postgresql", pool_size=5,
)
@pytest.fixture
def ch_connection():
return DatabaseConnection(
id="ch1", name="Test CH", host="localhost", port=9000,
database="analytics", username="default", password="",
dialect="clickhouse", pool_size=2,
)
@pytest.fixture
def mysql_connection():
return DatabaseConnection(
id="my1", name="Test MySQL", host="localhost", port=3306,
database="test", username="root", password="root",
dialect="mysql", pool_size=3,
)
@pytest.fixture
def executor(mock_conn_service):
return DbExecutor(mock_conn_service)
class TestPostgresql:
def test_execute_success(self, executor, mock_conn_service, pg_connection):
mock_conn_service.get_connection.return_value = pg_connection
sample_sql = 'INSERT INTO "products" ("id", "name") VALUES (\'1\', \'test\')'
with patch.object(executor, '_execute_pg', return_value=DbExecutionResult(success=True, rows_affected=5, execution_time_ms=120)):
result = executor.execute_sql("pg1", sample_sql)
assert result.success is True
assert result.rows_affected == 5
assert result.execution_time_ms == 120
def test_connection_not_found(self, executor, mock_conn_service):
mock_conn_service.get_connection.return_value = None
result = executor.execute_sql("nonexistent", "SELECT 1")
assert result.success is False
assert "not found" in (result.error or "")
def test_dialect_routing_ch(self, executor, mock_conn_service, ch_connection):
mock_conn_service.get_connection.return_value = ch_connection
with patch.object(executor, '_execute_ch', return_value=DbExecutionResult(success=True, rows_affected=3, execution_time_ms=50)):
result = executor.execute_sql("ch1", "INSERT INTO t VALUES (1)")
assert result.success is True
def test_dialect_routing_mysql(self, executor, mock_conn_service, mysql_connection):
mock_conn_service.get_connection.return_value = mysql_connection
with patch.object(executor, '_execute_mysql', return_value=DbExecutionResult(success=True, rows_affected=2, execution_time_ms=30)):
result = executor.execute_sql("my1", "INSERT INTO t VALUES (1)")
assert result.success is True
def test_unsupported_dialect(self, executor, mock_conn_service):
bad_conn = DatabaseConnection.model_construct(
id="bad", name="Bad", host="x", port=1,
database="x", username="u", password="p", dialect="oracle",
)
mock_conn_service.get_connection.return_value = bad_conn
result = executor.execute_sql("bad", "SELECT 1")
assert result.success is False
assert "Unsupported" in (result.error or "")
def test_execution_error(self, executor, mock_conn_service, pg_connection):
mock_conn_service.get_connection.return_value = pg_connection
with patch.object(executor, '_execute_pg', side_effect=RuntimeError("Connection timeout")):
result = executor.execute_sql("pg1", "BAD SQL")
assert result.success is False
assert "Connection timeout" in (result.error or "")
class TestPoolCaching:
def test_pool_cached_by_conn_id(self, executor, mock_conn_service, pg_connection):
mock_conn_service.get_connection.return_value = pg_connection
assert "pg1" not in executor._pools
executor._set_pool("pg1", "pool_obj", "config_key")
assert executor._get_or_create_pool("pg1", "config_key") == "pool_obj"
assert executor._get_or_create_pool("pg1", "other_key") is None
def test_pool_recreated_on_config_change(self, executor, mock_conn_service, pg_connection):
mock_conn_service.get_connection.return_value = pg_connection
executor._set_pool("pg1", "old_pool", "old_config")
result = executor._get_or_create_pool("pg1", "new_config")
assert result is None
class TestMissingDriver:
def test_asyncpg_not_installed(self, executor, mock_conn_service, pg_connection):
mock_conn_service.get_connection.return_value = pg_connection
with patch.dict('sys.modules', {'asyncpg': None}):
result = executor._execute_pg(pg_connection, "SELECT 1", 0)
assert result.success is False
assert "asyncpg is not installed" in (result.error or "")
def test_clickhouse_not_installed(self, executor, mock_conn_service, ch_connection):
mock_conn_service.get_connection.return_value = ch_connection
with patch.dict('sys.modules', {'clickhouse_connect': None}):
result = executor._execute_ch(ch_connection, "SELECT 1", 0)
assert result.success is False
assert "clickhouse-connect is not installed" in (result.error or "")

View File

@@ -0,0 +1,196 @@
# #region Test.Translate.OrchestratorSqlDirectDb [C:3] [TYPE Module] [SEMANTICS test, translate, orchestrator, direct-db]
# @BRIEF Pytest tests for SQLInsertService direct_db dispatch in orchestrator_sql.
# @TEST_CONTRACT SQLInsertService dispatches to direct DB executor when job.insert_method == "direct_db".
# @TEST_CONTRACT Connection snapshot is stored on TranslationRun for direct DB inserts.
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from src.plugins.translate.orchestrator_sql import SQLInsertService
@pytest.fixture
def mock_db():
db = MagicMock()
db.query.return_value.options.return_value.filter.return_value.all.return_value = []
return db
@pytest.fixture
def mock_config_manager():
cm = MagicMock()
cm.config.settings.connections = []
return cm
@pytest.fixture
def mock_event_log():
return MagicMock()
@pytest.fixture
def service(mock_db, mock_config_manager, mock_event_log):
return SQLInsertService(mock_db, mock_config_manager, mock_event_log)
@pytest.fixture
def mock_job():
job = MagicMock()
job.id = "job-1"
job.name = "Test Job"
job.environment_id = "env-1"
job.target_column = "translated_name"
job.translation_column = "name"
job.target_languages = ["en"]
job.target_schema = None
job.target_table = "products_i18n"
job.target_key_cols = ["product_id"]
job.upsert_strategy = "MERGE"
job.database_dialect = "postgresql"
job.target_dialect = "postgresql"
job.insert_method = "sqllab"
job.connection_id = None
return job
@pytest.fixture
def mock_run():
run = MagicMock()
run.id = "run-1"
run.connection_snapshot = None
run.insert_method = None
return run
class TestDirectDbDispatch:
"""SQLInsertService dispatches to DbExecutor (direct_db) or SupersetSqlLabExecutor (sqllab)."""
@pytest.mark.asyncio
async def test_direct_db_dispatch(self, service, mock_job, mock_run):
"""Given direct_db, dispatch to DbExecutor via ConnectionService, not SupersetSqlLabExecutor."""
mock_job.insert_method = "direct_db"
mock_job.connection_id = "conn-1"
# Mock DbExecutor and ConnectionService at EXT boundaries
mock_conn = MagicMock(name="Test PG", dialect="postgresql", host="x", port=5432, database="test_db")
with patch('src.plugins.translate.orchestrator_sql.ConnectionService') as mock_cs_cls:
mock_cs_cls.return_value.get_connection.return_value = mock_conn
with patch('src.plugins.translate.orchestrator_sql.DbExecutor') as mock_exec_cls:
mock_exec = MagicMock()
mock_exec_cls.return_value = mock_exec
mock_exec.execute_sql.return_value = MagicMock(success=True, rows_affected=5, execution_time_ms=200)
mock_exec_cls.return_value = mock_exec
mock_record = MagicMock()
mock_record.target_sql = "INSERT INTO ..."
mock_record.status = "SUCCESS"
service.db.query.return_value.options.return_value.filter.return_value.all.return_value = [mock_record]
with patch('src.plugins.translate.orchestrator_sql.SQLGenerator.generate', return_value=("INSERT SQL", 5)):
result = await service.generate_and_insert_sql(mock_job, mock_run)
assert result["status"] == "success"
assert result["rows_affected"] == 5
mock_exec.execute_sql.assert_called_once_with("conn-1", "INSERT SQL")
@pytest.mark.asyncio
async def test_sqllab_dispatch_by_default(self, service, mock_job, mock_run):
"""Given default sqllab, dispatch to SupersetSqlLabExecutor."""
mock_job.insert_method = "sqllab"
mock_job.connection_id = None
with patch('src.plugins.translate.orchestrator_sql.SupersetSqlLabExecutor') as mock_superset_cls:
mock_superset = MagicMock()
mock_superset_cls.return_value = mock_superset
mock_superset.resolve_database_id = AsyncMock()
mock_superset.execute_and_poll = AsyncMock(return_value={"status": "success"})
mock_record = MagicMock()
mock_record.target_sql = "INSERT INTO ..."
mock_record.status = "SUCCESS"
service.db.query.return_value.options.return_value.filter.return_value.all.return_value = [mock_record]
with patch('src.plugins.translate.orchestrator_sql.SQLGenerator.generate', return_value=("INSERT SQL", 5)):
result = await service.generate_and_insert_sql(mock_job, mock_run)
assert result["status"] == "success"
mock_superset.execute_and_poll.assert_called_once()
class TestDirectDbExecution:
"""SQLInsertService._execute_direct_db behavior."""
@pytest.mark.asyncio
async def test_connection_snapshot_stored(self, service, mock_job, mock_run):
"""Connection metadata is snapshotted on the run."""
mock_job.insert_method = "direct_db"
mock_job.connection_id = "conn-1"
with patch('src.plugins.translate.orchestrator_sql.ConnectionService') as mock_cs_cls:
mock_cs = MagicMock()
mock_cs_cls.return_value = mock_cs
mock_conn = MagicMock()
mock_conn.name = "Test PG"
mock_conn.dialect = "postgresql"
mock_conn.host = "db.example.com"
mock_conn.port = 5432
mock_conn.database = "test_db"
mock_cs.get_connection.return_value = mock_conn
with patch('src.plugins.translate.orchestrator_sql.DbExecutor') as mock_exec_cls:
mock_exec = MagicMock()
mock_exec_cls.return_value = mock_exec
mock_exec.execute_sql.return_value = MagicMock(
success=True, rows_affected=10, execution_time_ms=200
)
await service._execute_direct_db(mock_job, mock_run, "INSERT SQL")
assert mock_run.connection_snapshot == {
"name": "Test PG",
"dialect": "postgresql",
"host": "db.example.com",
"port": 5432,
"database": "test_db",
}
assert mock_run.insert_method == "direct_db"
@pytest.mark.asyncio
async def test_connection_not_found(self, service, mock_job, mock_run):
"""Missing connection returns failure without crashing."""
mock_job.insert_method = "direct_db"
mock_job.connection_id = "nonexistent"
with patch('src.plugins.translate.orchestrator_sql.ConnectionService') as mock_cs_cls:
mock_cs = MagicMock()
mock_cs_cls.return_value = mock_cs
mock_cs.get_connection.return_value = None
result = await service._execute_direct_db(mock_job, mock_run, "INSERT SQL")
assert result["status"] == "failed"
assert "not found" in result["error_message"]
@pytest.mark.asyncio
async def test_direct_db_execution_failure(self, service, mock_job, mock_run):
"""DB execution error returns failure details."""
mock_job.insert_method = "direct_db"
mock_job.connection_id = "conn-1"
with patch('src.plugins.translate.orchestrator_sql.ConnectionService') as mock_cs_cls:
mock_cs = MagicMock()
mock_cs_cls.return_value = mock_cs
mock_cs.get_connection.return_value = MagicMock(
name="Test PG", dialect="postgresql", host="x", port=5432, database="test_db"
)
with patch('src.plugins.translate.orchestrator_sql.DbExecutor') as mock_exec_cls:
mock_exec = MagicMock()
mock_exec_cls.return_value = mock_exec
mock_exec.execute_sql.return_value = MagicMock(
success=False, rows_affected=0, error="Host unreachable", execution_time_ms=5000
)
result = await service._execute_direct_db(mock_job, mock_run, "INSERT SQL")
assert result["status"] == "failed"
assert "Host unreachable" in result["error_message"]
assert result["execution_time_ms"] == 5000

View File

@@ -1,6 +1,6 @@
# #region TranslateCorrectionTests [C:2] [TYPE Module]
# @SEMANTICS: tests, translate, corrections, dictionary
# @PURPOSE: Tests for term correction API endpoints and DictionaryManager correction methods.
# @BRIEF Tests for term correction API endpoints and DictionaryManager correction methods.
# @LAYER Tests
# @RELATION BINDS_TO -> [DictionaryManagerModule]
# @RELATION BINDS_TO -> [TranslateRoutes]
@@ -101,7 +101,7 @@ def client(mock_api_deps):
# #region test_submit_correction_creates_entry [C:2] [TYPE Function]
# @PURPOSE: Verify that a correction creates a new dictionary entry.
# @BRIEF 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(
@@ -134,7 +134,7 @@ def test_submit_correction_creates_entry(db_session):
# #region test_submit_correction_conflict_detected [C:2] [TYPE Function]
# @PURPOSE: Verify conflict detection when entry already exists.
# @BRIEF 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(
@@ -161,7 +161,7 @@ def test_submit_correction_conflict_detected(db_session):
# #region test_submit_correction_overwrite [C:2] [TYPE Function]
# @PURPOSE: Verify that correction overwrites existing entry.
# @BRIEF Verify that correction overwrites existing entry.
def test_submit_correction_overwrite(db_session):
"""Test that correction overwrites existing entry."""
dict_obj = DictionaryManager.create_dictionary(
@@ -186,7 +186,7 @@ def test_submit_correction_overwrite(db_session):
# #region test_bulk_corrections_atomic [C:2] [TYPE Function]
# @PURPOSE: Verify bulk corrections are applied atomically.
# @BRIEF 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(
@@ -215,7 +215,7 @@ def test_bulk_corrections_atomic(db_session):
# #region test_api_correction_missing_dict [C:2] [TYPE Function]
# @PURPOSE: Verify POST corrections without dictionary_id returns 422.
# @BRIEF 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={
@@ -228,7 +228,7 @@ def test_api_correction_missing_dict(client):
# #region test_api_bulk_corrections [C:2] [TYPE Function]
# @PURPOSE: Verify POST /corrections/bulk works.
# @BRIEF Verify POST /corrections/bulk works.
def test_api_bulk_corrections(client, mock_api_deps):
"""Test bulk corrections endpoint."""
session = mock_api_deps["session"]

View File

@@ -1,6 +1,6 @@
# #region TranslateHistoryTests [C:2] [TYPE Module]
# @SEMANTICS: tests, translate, history, metrics
# @PURPOSE: Tests for run history list/detail endpoints and metrics aggregation.
# @BRIEF Tests for run history list/detail endpoints and metrics aggregation.
# @LAYER Tests
# @RELATION BINDS_TO -> [TranslateRoutes]
# @RELATION BINDS_TO -> [TranslationMetrics]
@@ -137,7 +137,7 @@ def _create_test_run(db_session, job_id: str, status: str = "COMPLETED", trigger
# #region test_list_runs_empty [C:2] [TYPE Function]
# @PURPOSE: Verify runs list returns empty result initially.
# @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")
@@ -149,7 +149,7 @@ def test_list_runs_empty(client):
# #region test_list_runs_with_data [C:2] [TYPE Function]
# @PURPOSE: Verify runs list returns data when runs exist.
# @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"]
@@ -167,7 +167,7 @@ def test_list_runs_with_data(client, mock_api_deps):
# #region test_list_runs_filter_job_id [C:2] [TYPE Function]
# @PURPOSE: Verify filtering runs by job_id works.
# @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"]
@@ -187,7 +187,7 @@ def test_list_runs_filter_job_id(client, mock_api_deps):
# #region test_list_runs_filter_status [C:2] [TYPE Function]
# @PURPOSE: Verify filtering runs by status works.
# @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"]
@@ -205,7 +205,7 @@ def test_list_runs_filter_status(client, mock_api_deps):
# #region test_get_run_detail [C:2] [TYPE Function]
# @PURPOSE: Verify run detail returns config_snapshot, records, events.
# @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"]
@@ -237,7 +237,7 @@ def test_get_run_detail(client, mock_api_deps):
# #region test_get_job_metrics [C:2] [TYPE Function]
# @PURPOSE: Verify metrics endpoint returns aggregated data.
# @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"]
@@ -257,7 +257,7 @@ def test_get_job_metrics(client, mock_api_deps):
# #region test_get_all_metrics [C:2] [TYPE Function]
# @PURPOSE: Verify global metrics endpoint returns data.
# @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"]
@@ -274,7 +274,7 @@ def test_get_all_metrics(client, mock_api_deps):
# #region test_metrics_empty_job [C:2] [TYPE Function]
# @PURPOSE: Verify metrics for job with no runs returns zeros.
# @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"]
@@ -289,14 +289,14 @@ def test_metrics_empty_job(client, mock_api_deps):
# #region test_run_detail_not_found [C:2] [TYPE Function]
# @PURPOSE: Verify 404 on non-existent run detail.
# @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]
# @PURPOSE: Verify run list includes target_languages, total_translated, and language_stats.
# @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
@@ -333,7 +333,7 @@ def test_list_runs_includes_language_info(client, mock_api_deps):
# #region test_metrics_per_language_breakdown [C:2] [TYPE Function]
# @PURPOSE: Verify metrics endpoint returns per_language_metrics.
# @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
@@ -363,7 +363,7 @@ def test_metrics_per_language_breakdown(client, mock_api_deps):
# #region test_metric_snapshot_stores_per_language [C:2] [TYPE Function]
# @PURPOSE: Verify MetricSnapshot stores per_language_metrics via prune_expired.
# @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
@@ -410,7 +410,7 @@ def test_metric_snapshot_stores_per_language(client, mock_api_deps):
# #region test_combined_metrics_live_and_snapshot [C:2] [TYPE Function]
# @PURPOSE: Verify combined metrics merge live + snapshot per-language data.
# @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

View File

@@ -1,6 +1,6 @@
# #region TranslateJobTests [C:2] [TYPE Module]
# @SEMANTICS: tests, translate, jobs, crud, validation
# @PURPOSE: Tests for translation job CRUD endpoints and service layer with column validation.
# @BRIEF Tests for translation job CRUD endpoints and service layer with column validation.
# @LAYER Tests
# @RELATION BINDS_TO -> [TranslateRoutes]
# @RELATION BINDS_TO -> [TranslateJobService]
@@ -45,7 +45,7 @@ from src.dependencies import (
)
# #region valid_job_payload [C:2] [TYPE Variable]
# @PURPOSE: Standard valid payload for creating a translation job.
# @BRIEF Standard valid payload for creating a translation job.
valid_job_payload = {
"name": "Test Translation Job",
"description": "A test job for unit tests",
@@ -83,7 +83,7 @@ Base.metadata.create_all(bind=engine)
# #region MockConfigManager [C:2] [TYPE Class]
# @PURPOSE: Mock ConfigManager for service tests that returns a test environment.
# @BRIEF Mock ConfigManager for service tests that returns a test environment.
class MockConfigManager:
def get_environments(self):
return [
@@ -102,7 +102,7 @@ class MockConfigManager:
# #region db_session [C:2] [TYPE Function]
# @PURPOSE: Create a fresh DB session with transaction rollback for each test.
# @BRIEF Create a fresh DB session with transaction rollback for each test.
@pytest.fixture
def db_session():
connection = engine.connect()
@@ -118,7 +118,7 @@ def db_session():
# #region mock_api_deps [C:2] [TYPE Function]
# @PURPOSE: Override FastAPI dependencies (including get_db with in-memory SQLite) for API route tests.
# @BRIEF 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
@@ -159,7 +159,7 @@ def mock_api_deps():
# #region client [C:2] [TYPE Function]
# @PURPOSE: FastAPI TestClient for API route tests.
# @BRIEF FastAPI TestClient for API route tests.
@pytest.fixture
def client(mock_api_deps):
return TestClient(app)
@@ -172,7 +172,7 @@ def client(mock_api_deps):
# #region test_create_job_valid [C:2] [TYPE Function]
# @PURPOSE: Verify that a valid job payload creates a job successfully.
# @BRIEF Verify that a valid job payload creates a job successfully.
async def test_create_job_valid(db_session):
"""Test creating a valid translation job."""
from src.plugins.translate.service import TranslateJobService
@@ -207,7 +207,7 @@ async def test_create_job_valid(db_session):
# #region test_create_job_missing_translation_column [C:2] [TYPE Function]
# @PURPOSE: Verify that creating a job with datasource but no translation column raises ValueError.
# @BRIEF Verify that creating a job with datasource but no translation column raises ValueError.
async 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
@@ -230,7 +230,7 @@ async def test_create_job_missing_translation_column(db_session):
# #region test_create_job_invalid_upsert_strategy [C:2] [TYPE Function]
# @PURPOSE: Verify that an invalid upsert strategy is rejected.
# @BRIEF Verify that an invalid upsert strategy is rejected.
async def test_create_job_invalid_upsert_strategy(db_session):
"""Test that an invalid upsert strategy raises ValueError."""
from src.plugins.translate.service import TranslateJobService
@@ -252,7 +252,7 @@ async def test_create_job_invalid_upsert_strategy(db_session):
# #region test_get_job [C:2] [TYPE Function]
# @PURPOSE: Verify that a job can be retrieved by ID.
# @BRIEF Verify that a job can be retrieved by ID.
async def test_get_job(db_session):
"""Test retrieving a translation job by ID."""
from src.plugins.translate.service import TranslateJobService
@@ -271,7 +271,7 @@ async def test_get_job(db_session):
# #region test_get_job_not_found [C:2] [TYPE Function]
# @PURPOSE: Verify that getting a non-existent job raises ValueError.
# @BRIEF 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
@@ -285,7 +285,7 @@ def test_get_job_not_found(db_session):
# #region test_list_jobs [C:2] [TYPE Function]
# @PURPOSE: Verify that listing jobs returns all created jobs.
# @BRIEF Verify that listing jobs returns all created jobs.
async def test_list_jobs(db_session):
"""Test listing translation jobs."""
from src.plugins.translate.service import TranslateJobService
@@ -306,7 +306,7 @@ async def test_list_jobs(db_session):
# #region test_list_jobs_with_status_filter [C:2] [TYPE Function]
# @PURPOSE: Verify that listing jobs with a status filter works.
# @BRIEF Verify that listing jobs with a status filter works.
async def test_list_jobs_with_status_filter(db_session):
"""Test listing jobs filtered by status."""
from src.plugins.translate.service import TranslateJobService
@@ -329,7 +329,7 @@ async def test_list_jobs_with_status_filter(db_session):
# #region test_update_job [C:2] [TYPE Function]
# @PURPOSE: Verify that a job can be updated.
# @BRIEF Verify that a job can be updated.
async def test_update_job(db_session):
"""Test updating a translation job."""
from src.plugins.translate.service import TranslateJobService
@@ -355,7 +355,7 @@ async def test_update_job(db_session):
# #region test_delete_job [C:2] [TYPE Function]
# @PURPOSE: Verify that a job can be deleted.
# @BRIEF Verify that a job can be deleted.
async def test_delete_job(db_session):
"""Test deleting a translation job."""
from src.plugins.translate.service import TranslateJobService
@@ -375,7 +375,7 @@ async def test_delete_job(db_session):
# #region test_duplicate_job [C:2] [TYPE Function]
# @PURPOSE: Verify that a job can be duplicated.
# @BRIEF Verify that a job can be duplicated.
async def test_duplicate_job(db_session):
"""Test duplicating a translation job."""
from src.plugins.translate.service import TranslateJobService
@@ -400,7 +400,7 @@ async def test_duplicate_job(db_session):
# #region test_duplicate_job_custom_name [C:2] [TYPE Function]
# @PURPOSE: Verify that a job can be duplicated with a custom name.
# @BRIEF Verify that a job can be duplicated with a custom name.
async def test_duplicate_job_custom_name(db_session):
"""Test duplicating a job with a custom name."""
from src.plugins.translate.service import TranslateJobService
@@ -418,7 +418,7 @@ async def test_duplicate_job_custom_name(db_session):
# #region test_detect_virtual_columns [C:2] [TYPE Function]
# @PURPOSE: Verify virtual column detection from column metadata.
# @BRIEF 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
@@ -437,7 +437,7 @@ def test_detect_virtual_columns():
# #region test_get_dialect_from_database [C:2] [TYPE Function]
# @PURPOSE: Verify dialect extraction from Superset database records.
# @BRIEF 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
@@ -454,7 +454,7 @@ def test_get_dialect_from_database():
# #region test_get_dialect_from_database_unsupported [C:2] [TYPE Function]
# @PURPOSE: Verify that unsupported dialects raise ValueError.
# @BRIEF 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
@@ -473,7 +473,7 @@ def test_get_dialect_from_database_unsupported():
# #region test_api_create_job [C:2] [TYPE Function]
# @PURPOSE: Verify POST /api/translate/jobs returns 201 with valid payload.
# @BRIEF 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)
@@ -489,7 +489,7 @@ def test_api_create_job(client):
# #region test_api_list_jobs [C:2] [TYPE Function]
# @PURPOSE: Verify GET /api/translate/jobs returns 200.
# @BRIEF 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")
@@ -499,7 +499,7 @@ def test_api_list_jobs(client):
# #region test_api_get_job_not_found [C:2] [TYPE Function]
# @PURPOSE: Verify GET non-existent job returns 404.
# @BRIEF 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")
@@ -508,7 +508,7 @@ def test_api_get_job_not_found(client):
# #region test_api_delete_job_not_found [C:2] [TYPE Function]
# @PURPOSE: Verify DELETE non-existent job returns 404.
# @BRIEF 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")
@@ -517,7 +517,7 @@ def test_api_delete_job_not_found(client):
# #region test_api_duplicate_job_not_found [C:2] [TYPE Function]
# @PURPOSE: Verify duplicating a non-existent job returns 404.
# @BRIEF 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")
@@ -526,7 +526,7 @@ def test_api_duplicate_job_not_found(client):
# #region test_api_create_job_422_missing_name [C:2] [TYPE Function]
# @PURPOSE: Verify POST with missing required fields returns 422.
# @BRIEF 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"})
@@ -535,7 +535,7 @@ def test_api_create_job_422_missing_name(client):
# #region test_api_datasource_columns_missing_env [C:2] [TYPE Function]
# @PURPOSE: Verify datasource columns endpoint without env_id returns 422.
# @BRIEF 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")
@@ -544,7 +544,7 @@ def test_api_datasource_columns_missing_env(client):
# #region test_api_datasource_columns_bad_env [C:2] [TYPE Function]
# @PURPOSE: Verify datasource columns with unknown env returns 400.
# @BRIEF 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")
@@ -553,7 +553,7 @@ def test_api_datasource_columns_bad_env(client):
# #region test_api_update_job_not_found [C:2] [TYPE Function]
# @PURPOSE: Verify PUT non-existent job returns 404.
# @BRIEF 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"})

View File

@@ -1,6 +1,6 @@
# #region TranslateSchedulerTests [C:2] [TYPE Module]
# @SEMANTICS: tests, translate, scheduler
# @PURPOSE: Tests for TranslationScheduler CRUD and APScheduler integration.
# @BRIEF Tests for TranslationScheduler CRUD and APScheduler integration.
# @LAYER Tests
# @RELATION BINDS_TO -> [TranslationScheduler]
#
@@ -56,7 +56,7 @@ def db_session():
# #region test_create_schedule [C:2] [TYPE Function]
# @PURPOSE: Verify schedule creation with valid params.
# @BRIEF Verify schedule creation with valid params.
async def test_create_schedule(db_session):
"""Test creating a schedule for a job."""
config_mgr = MagicMock()
@@ -82,7 +82,7 @@ async def test_create_schedule(db_session):
# #region test_update_schedule [C:2] [TYPE Function]
# @PURPOSE: Verify schedule update.
# @BRIEF Verify schedule update.
async def test_update_schedule(db_session):
"""Test updating a schedule."""
config_mgr = MagicMock()
@@ -102,7 +102,7 @@ async def test_update_schedule(db_session):
# #region test_delete_schedule [C:2] [TYPE Function]
# @PURPOSE: Verify schedule deletion.
# @BRIEF Verify schedule deletion.
async def test_delete_schedule(db_session):
"""Test deleting a schedule."""
config_mgr = MagicMock()
@@ -121,7 +121,7 @@ async def test_delete_schedule(db_session):
# #region test_enable_disable_schedule [C:2] [TYPE Function]
# @PURPOSE: Verify enable/disable toggle.
# @BRIEF Verify enable/disable toggle.
async def test_enable_disable_schedule(db_session):
"""Test enabling and disabling a schedule."""
config_mgr = MagicMock()
@@ -142,7 +142,7 @@ async def test_enable_disable_schedule(db_session):
# #region test_get_schedule_not_found [C:2] [TYPE Function]
# @PURPOSE: Verify ValueError on getting non-existent schedule.
# @BRIEF Verify ValueError on getting non-existent schedule.
def test_get_schedule_not_found(db_session):
"""Test that getting a non-existent schedule raises ValueError."""
config_mgr = MagicMock()
@@ -155,7 +155,7 @@ def test_get_schedule_not_found(db_session):
# #region test_list_active_schedules [C:2] [TYPE Function]
# @PURPOSE: Verify listing only active schedules.
# @BRIEF Verify listing only active schedules.
async def test_list_active_schedules(db_session):
"""Test listing active schedules."""
config_mgr = MagicMock()
@@ -175,7 +175,7 @@ async def test_list_active_schedules(db_session):
# #region test_get_next_executions [C:2] [TYPE Function]
# @PURPOSE: Verify next execution time computation.
# @BRIEF 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)
@@ -186,7 +186,7 @@ def test_get_next_executions():
# #region test_get_next_executions_invalid [C:2] [TYPE Function]
# @PURPOSE: Verify invalid cron returns empty list.
# @BRIEF 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)