- 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
564 lines
20 KiB
Python
564 lines
20 KiB
Python
# #region TranslateJobTests [C:2] [TYPE Module]
|
|
# @SEMANTICS: tests, translate, jobs, crud, validation
|
|
# @BRIEF Tests for translation job CRUD endpoints and service layer with column validation.
|
|
# @LAYER Tests
|
|
# @RELATION BINDS_TO -> [TranslateRoutes]
|
|
# @RELATION BINDS_TO -> [TranslateJobService]
|
|
#
|
|
# @TEST_CONTRACT: TranslateJobCRUD ->
|
|
# {
|
|
# required_fields: {name: str, source_dialect: str, target_dialect: str},
|
|
# optional_fields: {description, source_datasource_id, translation_column, ...},
|
|
# invariants: [
|
|
# "POST /api/translate/jobs returns 201 with valid config",
|
|
# "GET /api/translate/jobs returns job list",
|
|
# "GET /api/translate/jobs/{id} returns single job",
|
|
# "PUT /api/translate/jobs/{id} updates and returns job",
|
|
# "DELETE /api/translate/jobs/{id} returns 204",
|
|
# "POST /api/translate/jobs/{id}/duplicate returns new job copy"
|
|
# ]
|
|
# }
|
|
# @TEST_FIXTURE: valid_job_payload -> {name: "Test Job", source_dialect: "postgresql", target_dialect: "clickhouse"}
|
|
# @TEST_EDGE: missing_translation_column -> 422 when datasource set but no translation column
|
|
# @TEST_EDGE: virtual_key_column_warning -> warning emitted when virtual column used as key
|
|
# @TEST_EDGE: invalid_dialect_rejected -> error on unsupported dialect
|
|
# @TEST_INVARIANT: valid_CRUD_flow -> VERIFIED_BY: [valid_job_payload, missing_translation_column]
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock
|
|
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from src.app import app
|
|
from src.core.config_models import Environment
|
|
from src.core.database import Base
|
|
from src.dependencies import (
|
|
get_config_manager,
|
|
get_current_user,
|
|
)
|
|
|
|
# #region valid_job_payload [C:2] [TYPE Variable]
|
|
# @BRIEF Standard valid payload for creating a translation job.
|
|
valid_job_payload = {
|
|
"name": "Test Translation Job",
|
|
"description": "A test job for unit tests",
|
|
"source_dialect": "postgresql",
|
|
"target_dialect": "clickhouse",
|
|
"source_key_cols": ["id"],
|
|
"target_key_cols": ["id"],
|
|
"translation_column": "name",
|
|
"context_columns": ["description"],
|
|
"target_language": "ru",
|
|
"batch_size": 100,
|
|
"upsert_strategy": "MERGE",
|
|
"dictionary_ids": [],
|
|
}
|
|
# #endregion valid_job_payload
|
|
|
|
|
|
# Mock user
|
|
mock_user = MagicMock()
|
|
mock_user.username = "testuser"
|
|
mock_user.roles = []
|
|
admin_role = MagicMock()
|
|
admin_role.name = "Admin"
|
|
mock_user.roles.append(admin_role)
|
|
|
|
|
|
# In-memory SQLite database for service tests
|
|
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
|
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
|
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
|
|
|
|
# Create DB tables once
|
|
Base.metadata.create_all(bind=engine)
|
|
|
|
|
|
# #region MockConfigManager [C:2] [TYPE Class]
|
|
# @BRIEF Mock ConfigManager for service tests that returns a test environment.
|
|
class MockConfigManager:
|
|
def get_environments(self):
|
|
return [
|
|
Environment(
|
|
id="test_env",
|
|
name="Test Environment",
|
|
url="http://superset:8088",
|
|
username="admin",
|
|
password="admin",
|
|
)
|
|
]
|
|
|
|
def get_config(self):
|
|
return MagicMock()
|
|
# #endregion MockConfigManager
|
|
|
|
|
|
# #region db_session [C:2] [TYPE Function]
|
|
# @BRIEF Create a fresh DB session with transaction rollback for each test.
|
|
@pytest.fixture
|
|
def db_session():
|
|
connection = engine.connect()
|
|
transaction = connection.begin()
|
|
session = TestingSessionLocal(bind=connection)
|
|
|
|
yield session
|
|
|
|
session.close()
|
|
transaction.rollback()
|
|
connection.close()
|
|
# #endregion db_session
|
|
|
|
|
|
# #region mock_api_deps [C:2] [TYPE Function]
|
|
# @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
|
|
def mock_api_deps():
|
|
from src.core.database import get_db
|
|
|
|
config_manager = MagicMock()
|
|
config_manager.get_environments.return_value = []
|
|
|
|
# Create in-memory SQLite session for API tests (same engine as service tests)
|
|
connection = engine.connect()
|
|
transaction = connection.begin()
|
|
session = TestingSessionLocal(bind=connection)
|
|
|
|
def override_get_db():
|
|
try:
|
|
yield session
|
|
finally:
|
|
pass # Session lifecycle managed by fixture teardown
|
|
|
|
overrides = {
|
|
get_current_user: lambda: mock_user,
|
|
get_config_manager: lambda: config_manager,
|
|
get_db: override_get_db,
|
|
}
|
|
|
|
# Apply all overrides
|
|
for dep, override in overrides.items():
|
|
app.dependency_overrides[dep] = override
|
|
|
|
yield {"config_manager": config_manager}
|
|
|
|
app.dependency_overrides.clear()
|
|
session.close()
|
|
transaction.rollback()
|
|
connection.close()
|
|
# #endregion mock_api_deps
|
|
|
|
|
|
# #region client [C:2] [TYPE Function]
|
|
# @BRIEF FastAPI TestClient for API route tests.
|
|
@pytest.fixture
|
|
def client(mock_api_deps):
|
|
return TestClient(app)
|
|
# #endregion client
|
|
|
|
|
|
# ============================================================
|
|
# Service Layer Tests
|
|
# ============================================================
|
|
|
|
|
|
# #region test_create_job_valid [C:2] [TYPE Function]
|
|
# @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
|
|
from src.plugins.translate.service_utils import job_to_response
|
|
from src.schemas.translate import TranslateJobCreate
|
|
|
|
config_mgr = MockConfigManager()
|
|
service = TranslateJobService(db_session, config_mgr, "test_user")
|
|
|
|
payload = TranslateJobCreate(**valid_job_payload)
|
|
job = await service.create_job(payload)
|
|
|
|
assert job.id is not None
|
|
assert job.name == "Test Translation Job"
|
|
assert job.source_dialect == "postgresql"
|
|
assert job.target_dialect == "clickhouse"
|
|
assert job.translation_column == "name"
|
|
assert job.source_key_cols == ["id"]
|
|
assert job.target_key_cols == ["id"]
|
|
assert job.context_columns == ["description"]
|
|
assert job.batch_size == 100
|
|
assert job.upsert_strategy == "MERGE"
|
|
assert job.target_languages == ["ru"]
|
|
assert job.status == "DRAFT"
|
|
assert job.created_by == "test_user"
|
|
|
|
# Verify response serialization
|
|
response = job_to_response(job, [])
|
|
assert response.name == "Test Translation Job"
|
|
assert response.source_key_cols == ["id"]
|
|
# #endregion test_create_job_valid
|
|
|
|
|
|
# #region test_create_job_missing_translation_column [C:2] [TYPE Function]
|
|
# @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
|
|
from src.schemas.translate import TranslateJobCreate
|
|
|
|
config_mgr = MockConfigManager()
|
|
service = TranslateJobService(db_session, config_mgr, "test_user")
|
|
|
|
payload = TranslateJobCreate(
|
|
name="Bad Job",
|
|
source_dialect="postgresql",
|
|
target_dialect="clickhouse",
|
|
source_datasource_id="42",
|
|
translation_column=None,
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="translation column is required"):
|
|
await service.create_job(payload)
|
|
# #endregion test_create_job_missing_translation_column
|
|
|
|
|
|
# #region test_create_job_invalid_upsert_strategy [C:2] [TYPE Function]
|
|
# @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
|
|
from src.schemas.translate import TranslateJobCreate
|
|
|
|
config_mgr = MockConfigManager()
|
|
service = TranslateJobService(db_session, config_mgr, "test_user")
|
|
|
|
payload = TranslateJobCreate(
|
|
name="Bad Strategy Job",
|
|
source_dialect="postgresql",
|
|
target_dialect="clickhouse",
|
|
upsert_strategy="INVALID",
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Invalid upsert_strategy"):
|
|
await service.create_job(payload)
|
|
# #endregion test_create_job_invalid_upsert_strategy
|
|
|
|
|
|
# #region test_get_job [C:2] [TYPE Function]
|
|
# @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
|
|
from src.schemas.translate import TranslateJobCreate
|
|
|
|
config_mgr = MockConfigManager()
|
|
service = TranslateJobService(db_session, config_mgr, "test_user")
|
|
|
|
payload = TranslateJobCreate(**valid_job_payload)
|
|
created = await service.create_job(payload)
|
|
|
|
fetched = service.get_job(created.id)
|
|
assert fetched.id == created.id
|
|
assert fetched.name == "Test Translation Job"
|
|
# #endregion test_get_job
|
|
|
|
|
|
# #region test_get_job_not_found [C:2] [TYPE Function]
|
|
# @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
|
|
|
|
config_mgr = MockConfigManager()
|
|
service = TranslateJobService(db_session, config_mgr, "test_user")
|
|
|
|
with pytest.raises(ValueError, match="not found"):
|
|
service.get_job("non-existent-id")
|
|
# #endregion test_get_job_not_found
|
|
|
|
|
|
# #region test_list_jobs [C:2] [TYPE Function]
|
|
# @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
|
|
from src.schemas.translate import TranslateJobCreate
|
|
|
|
config_mgr = MockConfigManager()
|
|
service = TranslateJobService(db_session, config_mgr, "test_user")
|
|
|
|
p1 = TranslateJobCreate(name="Job 1", source_dialect="postgresql", target_dialect="clickhouse")
|
|
p2 = TranslateJobCreate(name="Job 2", source_dialect="mysql", target_dialect="postgresql")
|
|
await service.create_job(p1)
|
|
await service.create_job(p2)
|
|
|
|
total, jobs = service.list_jobs()
|
|
assert total == 2
|
|
assert len(jobs) == 2
|
|
# #endregion test_list_jobs
|
|
|
|
|
|
# #region test_list_jobs_with_status_filter [C:2] [TYPE Function]
|
|
# @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
|
|
from src.schemas.translate import TranslateJobCreate, TranslateJobUpdate
|
|
|
|
config_mgr = MockConfigManager()
|
|
service = TranslateJobService(db_session, config_mgr, "test_user")
|
|
|
|
p1 = TranslateJobCreate(name="Draft Job", source_dialect="pg", target_dialect="ch")
|
|
p2 = TranslateJobCreate(name="Ready Job", source_dialect="pg", target_dialect="ch")
|
|
await service.create_job(p1)
|
|
job2 = await service.create_job(p2)
|
|
|
|
await service.update_job(job2.id, TranslateJobUpdate(status="READY"))
|
|
|
|
total, jobs = service.list_jobs(status_filter="READY")
|
|
assert total == 1
|
|
assert jobs[0].name == "Ready Job"
|
|
# #endregion test_list_jobs_with_status_filter
|
|
|
|
|
|
# #region test_update_job [C:2] [TYPE Function]
|
|
# @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
|
|
from src.schemas.translate import TranslateJobCreate, TranslateJobUpdate
|
|
|
|
config_mgr = MockConfigManager()
|
|
service = TranslateJobService(db_session, config_mgr, "test_user")
|
|
|
|
payload = TranslateJobCreate(**valid_job_payload)
|
|
job = await service.create_job(payload)
|
|
|
|
update = TranslateJobUpdate(
|
|
name="Updated Job",
|
|
description="Updated description",
|
|
batch_size=200,
|
|
)
|
|
updated = await service.update_job(job.id, update)
|
|
|
|
assert updated.name == "Updated Job"
|
|
assert updated.description == "Updated description"
|
|
assert updated.batch_size == 200
|
|
# #endregion test_update_job
|
|
|
|
|
|
# #region test_delete_job [C:2] [TYPE Function]
|
|
# @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
|
|
from src.schemas.translate import TranslateJobCreate
|
|
|
|
config_mgr = MockConfigManager()
|
|
service = TranslateJobService(db_session, config_mgr, "test_user")
|
|
|
|
payload = TranslateJobCreate(**valid_job_payload)
|
|
job = await service.create_job(payload)
|
|
|
|
service.delete_job(job.id)
|
|
|
|
with pytest.raises(ValueError, match="not found"):
|
|
service.get_job(job.id)
|
|
# #endregion test_delete_job
|
|
|
|
|
|
# #region test_duplicate_job [C:2] [TYPE Function]
|
|
# @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
|
|
from src.schemas.translate import TranslateJobCreate
|
|
|
|
config_mgr = MockConfigManager()
|
|
service = TranslateJobService(db_session, config_mgr, "test_user")
|
|
|
|
payload = TranslateJobCreate(**valid_job_payload)
|
|
original = await service.create_job(payload)
|
|
|
|
duplicate = service.duplicate_job(original.id)
|
|
|
|
assert duplicate.id != original.id
|
|
assert duplicate.name == f"{original.name} (Copy)"
|
|
assert duplicate.source_dialect == original.source_dialect
|
|
assert duplicate.target_dialect == original.target_dialect
|
|
assert duplicate.translation_column == original.translation_column
|
|
assert duplicate.source_key_cols == original.source_key_cols
|
|
assert duplicate.status == "DRAFT"
|
|
# #endregion test_duplicate_job
|
|
|
|
|
|
# #region test_duplicate_job_custom_name [C:2] [TYPE Function]
|
|
# @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
|
|
from src.schemas.translate import TranslateJobCreate
|
|
|
|
config_mgr = MockConfigManager()
|
|
service = TranslateJobService(db_session, config_mgr, "test_user")
|
|
|
|
payload = TranslateJobCreate(**valid_job_payload)
|
|
original = await service.create_job(payload)
|
|
|
|
duplicate = service.duplicate_job(original.id, new_name="Custom Copy Name")
|
|
assert duplicate.name == "Custom Copy Name"
|
|
# #endregion test_duplicate_job_custom_name
|
|
|
|
|
|
# #region test_detect_virtual_columns [C:2] [TYPE Function]
|
|
# @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
|
|
|
|
columns = [
|
|
{"name": "id", "is_physical": True},
|
|
{"name": "name", "is_physical": True},
|
|
{"name": "virtual_col", "is_physical": False},
|
|
]
|
|
|
|
virtuals = detect_virtual_columns(columns)
|
|
assert "virtual_col" in virtuals
|
|
assert "id" not in virtuals
|
|
assert "name" not in virtuals
|
|
# #endregion test_detect_virtual_columns
|
|
|
|
|
|
# #region test_get_dialect_from_database [C:2] [TYPE Function]
|
|
# @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
|
|
|
|
dialect = get_dialect_from_database({"backend": "postgresql"})
|
|
assert dialect == "postgresql"
|
|
|
|
dialect = get_dialect_from_database({"engine": "mysql"})
|
|
assert dialect == "mysql"
|
|
|
|
dialect = get_dialect_from_database({"backend": "clickhouse", "engine": "mysql"})
|
|
assert dialect == "clickhouse"
|
|
# #endregion test_get_dialect_from_database
|
|
|
|
|
|
# #region test_get_dialect_from_database_unsupported [C:2] [TYPE Function]
|
|
# @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
|
|
|
|
with pytest.raises(ValueError, match="Unsupported database dialect"):
|
|
get_dialect_from_database({"backend": "mongodb"})
|
|
|
|
with pytest.raises(ValueError, match="Could not determine"):
|
|
get_dialect_from_database({})
|
|
# #endregion test_get_dialect_from_database_unsupported
|
|
|
|
|
|
# ============================================================
|
|
# API Route Tests
|
|
# ============================================================
|
|
|
|
|
|
# #region test_api_create_job [C:2] [TYPE Function]
|
|
# @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)
|
|
|
|
# Note: This may return 422 because the mock ConfigManager returns no real environments
|
|
# but the route requires get_config_manager. The service is tested directly above.
|
|
assert response.status_code in (201, 422, 500)
|
|
if response.status_code == 201:
|
|
data = response.json()
|
|
assert data["name"] == "Test Translation Job"
|
|
assert "id" in data
|
|
# #endregion test_api_create_job
|
|
|
|
|
|
# #region test_api_list_jobs [C:2] [TYPE Function]
|
|
# @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")
|
|
assert response.status_code == 200
|
|
assert isinstance(response.json(), list)
|
|
# #endregion test_api_list_jobs
|
|
|
|
|
|
# #region test_api_get_job_not_found [C:2] [TYPE Function]
|
|
# @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")
|
|
assert response.status_code == 404
|
|
# #endregion test_api_get_job_not_found
|
|
|
|
|
|
# #region test_api_delete_job_not_found [C:2] [TYPE Function]
|
|
# @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")
|
|
assert response.status_code == 404
|
|
# #endregion test_api_delete_job_not_found
|
|
|
|
|
|
# #region test_api_duplicate_job_not_found [C:2] [TYPE Function]
|
|
# @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")
|
|
assert response.status_code == 404
|
|
# #endregion test_api_duplicate_job_not_found
|
|
|
|
|
|
# #region test_api_create_job_422_missing_name [C:2] [TYPE Function]
|
|
# @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"})
|
|
assert response.status_code == 422
|
|
# #endregion test_api_create_job_422_missing_name
|
|
|
|
|
|
# #region test_api_datasource_columns_missing_env [C:2] [TYPE Function]
|
|
# @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")
|
|
assert response.status_code == 422
|
|
# #endregion test_api_datasource_columns_missing_env
|
|
|
|
|
|
# #region test_api_datasource_columns_bad_env [C:2] [TYPE Function]
|
|
# @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")
|
|
assert response.status_code in (400, 502, 422)
|
|
# #endregion test_api_datasource_columns_bad_env
|
|
|
|
|
|
# #region test_api_update_job_not_found [C:2] [TYPE Function]
|
|
# @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"})
|
|
assert response.status_code == 404
|
|
# #endregion test_api_update_job_not_found
|
|
|
|
# #endregion TranslateJobTests
|