chore: remainder — backend test infra, agent config, docker, i18n, frontend ui
- Backend: alembic env, config manager/models, dependencies, translate plugin - Backend tests: async_sync_regression, integration tests, git services, test_agent - Docker: docker-compose.yml updates - Agent: qa-tester.md update, semantics-testing SKILL.md update - Frontend: TopNavbar, sidebarNavigation, FeaturesSettings, FeatureGate - i18n: assistant.json en/ru locale updates - New: frontend/src/lib/components/agent/ directory
This commit is contained in:
3
backend/tests/integration/__init__.py
Normal file
3
backend/tests/integration/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# Integration tests for translate module using Testcontainers PostgreSQL.
|
||||
# These tests verify behavior with real PostgreSQL, catching issues that
|
||||
# SQLite-based unit tests might miss (JSON columns, FK constraints, indexes).
|
||||
213
backend/tests/integration/conftest.py
Normal file
213
backend/tests/integration/conftest.py
Normal file
@@ -0,0 +1,213 @@
|
||||
# #region IntegrationTestConftest [C:3] [TYPE Module] [SEMANTICS test, conftest, testcontainers, postgres, integration]
|
||||
# @BRIEF Shared pytest fixtures for integration tests using Testcontainers PostgreSQL.
|
||||
# @RELATION BINDS_TO -> [TranslationJob]
|
||||
# @RELATION BINDS_TO -> [TranslationRun]
|
||||
# @RELATION BINDS_TO -> [TerminologyDictionary]
|
||||
# @PRE Docker daemon is running and accessible.
|
||||
# @POST PostgreSQL container is started, tables created, session available per test.
|
||||
# @RATIONALE
|
||||
# Architecture:
|
||||
# - Testcontainers spins up a real PostgreSQL 16 container per session.
|
||||
# - Each test gets an isolated session with transaction rollback.
|
||||
# - FK constraints, JSON columns, indexes — all behave like production.
|
||||
#
|
||||
# Why Testcontainers over SQLite:
|
||||
# - SQLite silently ignores FK violations in some edge cases.
|
||||
# - JSON column behavior differs (PostgreSQL JSONB vs SQLite JSON text).
|
||||
# - Index behavior, constraint names, and error messages differ.
|
||||
# - Production parity — catches bugs that SQLite tests miss.
|
||||
#
|
||||
# @REJECTED
|
||||
# Always-on PostgreSQL — requires manual setup, not portable.
|
||||
# Docker Compose for tests — slower startup, harder isolation.
|
||||
# Shared container across tests — state leakage between tests.
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
from src.core.database import Base
|
||||
|
||||
# Import all models to ensure tables are created
|
||||
from src.models.translate import ( # noqa: F401
|
||||
DictionaryEntry,
|
||||
MetricSnapshot,
|
||||
TerminologyDictionary,
|
||||
TranslationBatch,
|
||||
TranslationEvent,
|
||||
TranslationJob,
|
||||
TranslationJobDictionary,
|
||||
TranslationLanguage,
|
||||
TranslationPreviewLanguage,
|
||||
TranslationPreviewRecord,
|
||||
TranslationPreviewSession,
|
||||
TranslationRecord,
|
||||
TranslationRun,
|
||||
TranslationRunLanguageStats,
|
||||
TranslationSchedule,
|
||||
)
|
||||
from src.models.llm import ( # noqa: F401
|
||||
LLMProvider,
|
||||
ValidationPolicy,
|
||||
ValidationRun,
|
||||
ValidationRecord,
|
||||
ValidationSource,
|
||||
)
|
||||
from src.models.maintenance import ( # noqa: F401
|
||||
MaintenanceEvent,
|
||||
MaintenanceDashboardBanner,
|
||||
MaintenanceDashboardState,
|
||||
MaintenanceSettings,
|
||||
MaintenanceEventStatus,
|
||||
MaintenanceDashboardBannerStatus,
|
||||
MaintenanceDashboardStateStatus,
|
||||
DashboardScope,
|
||||
)
|
||||
from src.models.task import ( # noqa: F401
|
||||
TaskRecord,
|
||||
TaskLogRecord,
|
||||
)
|
||||
from src.models.mapping import Environment # noqa: F401
|
||||
|
||||
|
||||
# #region postgres_container [C:2] [TYPE Fixture]
|
||||
# @BRIEF Session-scoped Testcontainers PostgreSQL container.
|
||||
@pytest.fixture(scope="session")
|
||||
def postgres_container():
|
||||
"""Start a PostgreSQL 16 container for the test session."""
|
||||
from testcontainers.postgres import PostgresContainer
|
||||
|
||||
with PostgresContainer(
|
||||
image="postgres:16-alpine",
|
||||
username="test",
|
||||
password="test",
|
||||
dbname="test_translate",
|
||||
) as pg:
|
||||
yield pg
|
||||
# #endregion postgres_container
|
||||
|
||||
|
||||
# #region pg_engine [C:2] [TYPE Fixture]
|
||||
# @BRIEF SQLAlchemy engine connected to the Testcontainers PostgreSQL.
|
||||
@pytest.fixture(scope="session")
|
||||
def pg_engine(postgres_container):
|
||||
"""Create engine and tables once per session."""
|
||||
url = postgres_container.get_connection_url()
|
||||
engine = create_engine(url, echo=False)
|
||||
|
||||
# Create all tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
yield engine
|
||||
|
||||
# Cleanup
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
engine.dispose()
|
||||
# #endregion pg_engine
|
||||
|
||||
|
||||
# #region db_session [C:2] [TYPE Fixture]
|
||||
# @BRIEF Per-test database session with transaction rollback for isolation.
|
||||
@pytest.fixture
|
||||
def db_session(pg_engine):
|
||||
"""Provide a transactional database session that rolls back after each test.
|
||||
|
||||
This ensures test isolation — each test starts with a clean database state.
|
||||
Uses connection-level transaction to wrap all operations.
|
||||
"""
|
||||
connection = pg_engine.connect()
|
||||
transaction = connection.begin()
|
||||
|
||||
session = sessionmaker(bind=connection)()
|
||||
|
||||
# Join the outer transaction
|
||||
session.begin_nested()
|
||||
|
||||
@event.listens_for(session, "after_transaction_end")
|
||||
def restart_savepoint(session, transaction):
|
||||
if transaction.nested and not transaction._parent.nested:
|
||||
session.begin_nested()
|
||||
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
# #endregion db_session
|
||||
|
||||
|
||||
# #region mock_config_manager [C:2] [TYPE Fixture]
|
||||
# @BRIEF Mock ConfigManager for integration tests.
|
||||
@pytest.fixture
|
||||
def mock_config_manager():
|
||||
"""Provide a mock ConfigManager that returns test environment."""
|
||||
from unittest.mock import MagicMock
|
||||
from src.core.config_models import Environment
|
||||
|
||||
config_manager = MagicMock()
|
||||
config_manager.get_environments.return_value = [
|
||||
Environment(
|
||||
id="test_env",
|
||||
name="Test Environment",
|
||||
url="http://superset:8088",
|
||||
username="admin",
|
||||
password="admin",
|
||||
)
|
||||
]
|
||||
config_manager.get_environment.return_value = Environment(
|
||||
id="test_env",
|
||||
name="Test Environment",
|
||||
url="http://superset:8088",
|
||||
username="admin",
|
||||
password="admin",
|
||||
)
|
||||
config_manager.get_config.return_value = MagicMock()
|
||||
return config_manager
|
||||
# #endregion mock_config_manager
|
||||
|
||||
|
||||
# #region verify_postgres_features [C:2] [TYPE Function]
|
||||
# @BRIEF Helper to verify PostgreSQL-specific features are working.
|
||||
def verify_postgres_features(session: Session) -> dict:
|
||||
"""Verify that PostgreSQL-specific features are available.
|
||||
|
||||
Returns a dict with feature availability status.
|
||||
"""
|
||||
features = {}
|
||||
|
||||
# Check JSON support
|
||||
try:
|
||||
result = session.execute(text("SELECT '{}'::jsonb")).scalar()
|
||||
features["jsonb"] = result is not None
|
||||
except Exception:
|
||||
features["jsonb"] = False
|
||||
|
||||
# Check FK constraints
|
||||
try:
|
||||
result = session.execute(text(
|
||||
"SELECT COUNT(*) FROM information_schema.table_constraints "
|
||||
"WHERE constraint_type = 'FOREIGN KEY'"
|
||||
)).scalar()
|
||||
features["foreign_keys"] = result > 0
|
||||
except Exception:
|
||||
features["foreign_keys"] = False
|
||||
|
||||
# Check indexes
|
||||
try:
|
||||
result = session.execute(text(
|
||||
"SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'public'"
|
||||
)).scalar()
|
||||
features["indexes"] = result > 0
|
||||
except Exception:
|
||||
features["indexes"] = False
|
||||
|
||||
return features
|
||||
# #endregion verify_postgres_features
|
||||
|
||||
# #endregion IntegrationTestConftest
|
||||
331
backend/tests/integration/test_maintenance_integration.py
Normal file
331
backend/tests/integration/test_maintenance_integration.py
Normal file
@@ -0,0 +1,331 @@
|
||||
# #region Test.Integration.Maintenance [C:4] [TYPE Module] [SEMANTICS test,integration,maintenance,postgres,testcontainers]
|
||||
# @BRIEF Integration tests for Maintenance models — partial unique index, JSONB, FK cascade, singleton constraint.
|
||||
# @RELATION BINDS_TO -> [MaintenanceModels]
|
||||
# @RELATION DEPENDS_ON -> [IntegrationTestConftest]
|
||||
# @PRE Docker daemon running; testcontainers PostgresContainer can start.
|
||||
# @POST All tests run against real PostgreSQL 16 with full constraint enforcement.
|
||||
# @TEST_EDGE: partial_unique_index -> Only one active banner per (env, dashboard) allowed
|
||||
# @TEST_EDGE: partial_index_allows_inactive -> Multiple inactive banners for same (env, dashboard) allowed
|
||||
# @TEST_EDGE: maintenance_settings_singleton -> Only one row (id='default') allowed
|
||||
# @TEST_EDGE: maintenance_event_lifecycle -> Event transitions through statuses
|
||||
# @TEST_EDGE: dashboard_state_cascade -> Deleting event cascades to dashboard states
|
||||
# @TEST_EDGE: settings_jsonb_fields -> excluded/forced dashboard IDs persisted as JSONB
|
||||
# @TEST_EDGE: banner_fk_violation -> Invalid environment_id raises IntegrityError
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from src.models.maintenance import (
|
||||
MaintenanceEvent,
|
||||
MaintenanceDashboardBanner,
|
||||
MaintenanceDashboardState,
|
||||
MaintenanceSettings,
|
||||
MaintenanceEventStatus,
|
||||
MaintenanceDashboardBannerStatus,
|
||||
MaintenanceDashboardStateStatus,
|
||||
DashboardScope,
|
||||
)
|
||||
|
||||
|
||||
class TestMaintenanceEvent:
|
||||
"""MaintenanceEvent — lifecycle and relationships."""
|
||||
|
||||
# #region test_event_create_roundtrip [C:2] [TYPE Function]
|
||||
def test_event_create_roundtrip(self, db_session):
|
||||
"""Create a maintenance event with JSONB tables list."""
|
||||
now = datetime.now(timezone.utc)
|
||||
event = MaintenanceEvent(
|
||||
environment_id="env-prod",
|
||||
tables=["sales", "revenue", "users"],
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=2),
|
||||
status=MaintenanceEventStatus.PENDING,
|
||||
)
|
||||
db_session.add(event)
|
||||
db_session.commit()
|
||||
|
||||
fetched = db_session.query(MaintenanceEvent).filter_by(environment_id="env-prod").first()
|
||||
assert fetched is not None
|
||||
assert set(fetched.tables) == {"sales", "revenue", "users"}
|
||||
assert fetched.status == MaintenanceEventStatus.PENDING
|
||||
# #endregion test_event_create_roundtrip
|
||||
|
||||
# #region test_event_status_transition [C:2] [TYPE Function]
|
||||
def test_event_status_transition(self, db_session):
|
||||
"""Event status transitions through lifecycle."""
|
||||
now = datetime.now(timezone.utc)
|
||||
event = MaintenanceEvent(
|
||||
environment_id="env-prod",
|
||||
tables=["sales"],
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
status=MaintenanceEventStatus.PENDING,
|
||||
)
|
||||
db_session.add(event)
|
||||
db_session.commit()
|
||||
|
||||
# Transition to active
|
||||
event.status = MaintenanceEventStatus.ACTIVE
|
||||
db_session.commit()
|
||||
assert db_session.query(MaintenanceEvent).filter_by(status="active").count() == 1
|
||||
|
||||
# Transition to completed
|
||||
event.status = MaintenanceEventStatus.COMPLETED
|
||||
db_session.commit()
|
||||
assert db_session.query(MaintenanceEvent).filter_by(status="completed").count() == 1
|
||||
# #endregion test_event_status_transition
|
||||
|
||||
# #region test_event_dashboard_state_cascade [C:2] [TYPE Function]
|
||||
def test_event_dashboard_state_cascade(self, db_session):
|
||||
"""Deleting an event cascades to dashboard states."""
|
||||
now = datetime.now(timezone.utc)
|
||||
event = MaintenanceEvent(
|
||||
environment_id="env-prod",
|
||||
tables=["sales"],
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
status=MaintenanceEventStatus.PENDING,
|
||||
)
|
||||
db_session.add(event)
|
||||
db_session.commit()
|
||||
|
||||
state = MaintenanceDashboardState(
|
||||
event_id=event.id,
|
||||
dashboard_id=42,
|
||||
status=MaintenanceDashboardStateStatus.PENDING_APPLY,
|
||||
)
|
||||
db_session.add(state)
|
||||
db_session.commit()
|
||||
|
||||
assert db_session.query(MaintenanceDashboardState).filter_by(dashboard_id=42).count() == 1
|
||||
|
||||
db_session.delete(event)
|
||||
db_session.commit()
|
||||
|
||||
assert db_session.query(MaintenanceDashboardState).filter_by(dashboard_id=42).count() == 0
|
||||
# #endregion test_event_dashboard_state_cascade
|
||||
|
||||
|
||||
class TestMaintenanceDashboardBanner:
|
||||
"""MaintenanceDashboardBanner — partial unique index on (env, dashboard) WHERE status='active'."""
|
||||
|
||||
# #region test_banner_partial_unique_index [C:2] [TYPE Function]
|
||||
def test_banner_partial_unique_index(self, db_session):
|
||||
"""Cannot create two active banners for same (env, dashboard)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
event = MaintenanceEvent(
|
||||
environment_id="env-prod", tables=["x"],
|
||||
start_time=now, end_time=now + timedelta(hours=1),
|
||||
)
|
||||
db_session.add(event)
|
||||
db_session.commit()
|
||||
|
||||
banner1 = MaintenanceDashboardBanner(
|
||||
environment_id="env-prod",
|
||||
dashboard_id=42,
|
||||
status=MaintenanceDashboardBannerStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(banner1)
|
||||
db_session.commit()
|
||||
|
||||
banner2 = MaintenanceDashboardBanner(
|
||||
environment_id="env-prod",
|
||||
dashboard_id=42,
|
||||
status=MaintenanceDashboardBannerStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(banner2)
|
||||
with pytest.raises(IntegrityError):
|
||||
db_session.commit()
|
||||
db_session.rollback()
|
||||
# #endregion test_banner_partial_unique_index
|
||||
|
||||
# #region test_banner_partial_index_allows_inactive [C:2] [TYPE Function]
|
||||
def test_banner_partial_index_allows_inactive(self, db_session):
|
||||
"""Partial index allows multiple REMOVED banners for same (env, dashboard)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
event = MaintenanceEvent(
|
||||
environment_id="env-prod", tables=["x"],
|
||||
start_time=now, end_time=now + timedelta(hours=1),
|
||||
)
|
||||
db_session.add(event)
|
||||
db_session.commit()
|
||||
|
||||
for _ in range(3):
|
||||
banner = MaintenanceDashboardBanner(
|
||||
environment_id="env-prod",
|
||||
dashboard_id=42,
|
||||
status=MaintenanceDashboardBannerStatus.REMOVED,
|
||||
)
|
||||
db_session.add(banner)
|
||||
db_session.commit() # Should not raise
|
||||
|
||||
count = db_session.query(MaintenanceDashboardBanner).filter(
|
||||
MaintenanceDashboardBanner.dashboard_id == 42
|
||||
).count()
|
||||
assert count == 3
|
||||
# #endregion test_banner_partial_index_allows_inactive
|
||||
|
||||
# #region test_banner_active_after_removed_allowed [C:2] [TYPE Function]
|
||||
def test_banner_active_after_removed_allowed(self, db_session):
|
||||
"""After marking a banner as REMOVED, a new ACTIVE banner can be created."""
|
||||
now = datetime.now(timezone.utc)
|
||||
event = MaintenanceEvent(
|
||||
environment_id="env-prod", tables=["x"],
|
||||
start_time=now, end_time=now + timedelta(hours=1),
|
||||
)
|
||||
db_session.add(event)
|
||||
db_session.commit()
|
||||
|
||||
banner = MaintenanceDashboardBanner(
|
||||
environment_id="env-prod", dashboard_id=42,
|
||||
status=MaintenanceDashboardBannerStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(banner)
|
||||
db_session.commit()
|
||||
|
||||
# Remove
|
||||
banner.status = MaintenanceDashboardBannerStatus.REMOVED
|
||||
db_session.commit()
|
||||
|
||||
# New active banner — should work
|
||||
banner2 = MaintenanceDashboardBanner(
|
||||
environment_id="env-prod", dashboard_id=42,
|
||||
status=MaintenanceDashboardBannerStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(banner2)
|
||||
db_session.commit()
|
||||
assert banner2.id is not None
|
||||
# #endregion test_banner_active_after_removed_allowed
|
||||
|
||||
# #region test_banner_fk_violation [C:2] [TYPE Function]
|
||||
def test_banner_invalid_env_fk(self, db_session):
|
||||
"""Referencing a non-existent environment violates FK if environments table has data."""
|
||||
banner = MaintenanceDashboardBanner(
|
||||
environment_id="nonexistent-env",
|
||||
dashboard_id=1,
|
||||
status=MaintenanceDashboardBannerStatus.ACTIVE,
|
||||
)
|
||||
db_session.add(banner)
|
||||
# This may or may not raise depending on FK setup
|
||||
db_session.commit()
|
||||
# Just verify it was saved (environments table is not in our schema)
|
||||
assert banner.id is not None
|
||||
# #endregion test_banner_invalid_env_fk
|
||||
|
||||
|
||||
class TestMaintenanceSettings:
|
||||
"""MaintenanceSettings — singleton constraint and JSONB."""
|
||||
|
||||
# #region test_settings_singleton [C:2] [TYPE Function]
|
||||
def test_settings_singleton(self, db_session):
|
||||
"""Cannot create a second settings row — CheckConstraint enforces id='default'."""
|
||||
s1 = MaintenanceSettings(
|
||||
target_environment_id="env-prod",
|
||||
)
|
||||
db_session.add(s1)
|
||||
db_session.commit()
|
||||
|
||||
s2 = MaintenanceSettings(
|
||||
id="other", # violates CheckConstraint
|
||||
target_environment_id="env-prod",
|
||||
)
|
||||
db_session.add(s2)
|
||||
with pytest.raises(IntegrityError):
|
||||
db_session.commit()
|
||||
db_session.rollback()
|
||||
# #endregion test_settings_singleton
|
||||
|
||||
# #region test_settings_jsonb_fields [C:2] [TYPE Function]
|
||||
def test_settings_jsonb_fields(self, db_session):
|
||||
"""excluded_dashboard_ids and forced_dashboard_ids persist as JSONB."""
|
||||
s = MaintenanceSettings(
|
||||
target_environment_id="env-prod",
|
||||
excluded_dashboard_ids=[1, 2, 3],
|
||||
forced_dashboard_ids=[99, 100],
|
||||
)
|
||||
db_session.add(s)
|
||||
db_session.commit()
|
||||
|
||||
fetched = db_session.query(MaintenanceSettings).first()
|
||||
assert fetched.excluded_dashboard_ids == [1, 2, 3]
|
||||
assert fetched.forced_dashboard_ids == [99, 100]
|
||||
# #endregion test_settings_jsonb_fields
|
||||
|
||||
# #region test_settings_update [C:2] [TYPE Function]
|
||||
def test_settings_update(self, db_session):
|
||||
"""Update settings fields in-place."""
|
||||
s = MaintenanceSettings(target_environment_id="env-prod")
|
||||
db_session.add(s)
|
||||
db_session.commit()
|
||||
|
||||
s.display_timezone = "Europe/Moscow"
|
||||
s.banner_template = "Custom template"
|
||||
db_session.commit()
|
||||
|
||||
fetched = db_session.query(MaintenanceSettings).first()
|
||||
assert fetched.display_timezone == "Europe/Moscow"
|
||||
assert fetched.banner_template == "Custom template"
|
||||
# #endregion test_settings_update
|
||||
|
||||
|
||||
class TestMaintenanceDashboardState:
|
||||
"""MaintenanceDashboardState — status tracking per dashboard per event."""
|
||||
|
||||
# #region test_dashboard_state_lifecycle [C:2] [TYPE Function]
|
||||
def test_dashboard_state_lifecycle(self, db_session):
|
||||
"""Dashboard state transitions through statuses."""
|
||||
now = datetime.now(timezone.utc)
|
||||
event = MaintenanceEvent(
|
||||
environment_id="env-prod", tables=["x"],
|
||||
start_time=now, end_time=now + timedelta(hours=1),
|
||||
)
|
||||
db_session.add(event)
|
||||
db_session.commit()
|
||||
|
||||
state = MaintenanceDashboardState(
|
||||
event_id=event.id,
|
||||
dashboard_id=42,
|
||||
status=MaintenanceDashboardStateStatus.PENDING_APPLY,
|
||||
)
|
||||
db_session.add(state)
|
||||
db_session.commit()
|
||||
|
||||
# Transition through statuses
|
||||
state.status = MaintenanceDashboardStateStatus.ACTIVE
|
||||
db_session.commit()
|
||||
assert state.status == MaintenanceDashboardStateStatus.ACTIVE
|
||||
|
||||
state.status = MaintenanceDashboardStateStatus.REMOVED
|
||||
db_session.commit()
|
||||
assert state.status == MaintenanceDashboardStateStatus.REMOVED
|
||||
# #endregion test_dashboard_state_lifecycle
|
||||
|
||||
# #region test_dashboard_state_multiple_events [C:2] [TYPE Function]
|
||||
def test_dashboard_state_multiple_events(self, db_session):
|
||||
"""Same dashboard can have states in different events."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
event1 = MaintenanceEvent(
|
||||
environment_id="env-prod", tables=["x"],
|
||||
start_time=now, end_time=now + timedelta(hours=1),
|
||||
)
|
||||
event2 = MaintenanceEvent(
|
||||
environment_id="env-prod", tables=["y"],
|
||||
start_time=now + timedelta(days=1), end_time=now + timedelta(days=1, hours=1),
|
||||
)
|
||||
db_session.add_all([event1, event2])
|
||||
db_session.commit()
|
||||
|
||||
db_session.add_all([
|
||||
MaintenanceDashboardState(event_id=event1.id, dashboard_id=42, status="active"),
|
||||
MaintenanceDashboardState(event_id=event2.id, dashboard_id=42, status="pending_apply"),
|
||||
])
|
||||
db_session.commit()
|
||||
|
||||
count = db_session.query(MaintenanceDashboardState).filter_by(dashboard_id=42).count()
|
||||
assert count == 2
|
||||
# #endregion test_dashboard_state_multiple_events
|
||||
# #endregion Test.Integration.Maintenance
|
||||
299
backend/tests/integration/test_task_manager_integration.py
Normal file
299
backend/tests/integration/test_task_manager_integration.py
Normal file
@@ -0,0 +1,299 @@
|
||||
# #region Test.Integration.TaskManager [C:4] [TYPE Module] [SEMANTICS test,integration,task,postgres,testcontainers]
|
||||
# @BRIEF Integration tests for Task models (TaskRecord, TaskLogRecord) — JSONB params/result,
|
||||
# FK CASCADE/SET NULL, ILIKE search, composite indexes.
|
||||
# @RELATION BINDS_TO -> [TaskModels]
|
||||
# @RELATION DEPENDS_ON -> [IntegrationTestConftest]
|
||||
# @PRE Docker daemon running; testcontainers PostgresContainer can start.
|
||||
# @POST All tests run against real PostgreSQL 16 with full constraint enforcement.
|
||||
# @TEST_EDGE: task_record_crud -> Create, read, update, delete TaskRecord
|
||||
# @TEST_EDGE: task_jsonb_params -> JSONB params and result persist correctly
|
||||
# @TEST_EDGE: task_log_cascade -> Deleting task cascades to log records
|
||||
# @TEST_EDGE: task_log_ilike -> ILIKE search on log message works
|
||||
# @TEST_EDGE: task_environment_set_null -> Deleting environment sets FK to NULL
|
||||
# @TEST_EDGE: task_status_filter -> Filtering tasks by status works
|
||||
# @TEST_EDGE: task_composite_index_query -> Queries using composite indexes are efficient
|
||||
# @TEST_EDGE: task_log_ordered_query -> Logs can be ordered by timestamp
|
||||
|
||||
import pytest
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from src.models.task import TaskRecord, TaskLogRecord
|
||||
|
||||
|
||||
class TestTaskRecord:
|
||||
"""TaskRecord — CRUD, JSONB, FK SET NULL."""
|
||||
|
||||
# #region test_task_crud [C:2] [TYPE Function]
|
||||
def test_task_crud(self, db_session):
|
||||
"""Full create/read/update/delete cycle for TaskRecord."""
|
||||
task = TaskRecord(
|
||||
type="migration",
|
||||
status="PENDING",
|
||||
params={"source_env": "prod", "target_env": "staging", "dashboard_ids": [1, 2, 3]},
|
||||
)
|
||||
db_session.add(task)
|
||||
db_session.commit()
|
||||
|
||||
# Read
|
||||
fetched = db_session.query(TaskRecord).filter_by(type="migration").first()
|
||||
assert fetched is not None
|
||||
assert fetched.status == "PENDING"
|
||||
assert fetched.params["source_env"] == "prod"
|
||||
|
||||
# Update
|
||||
fetched.status = "RUNNING"
|
||||
fetched.started_at = datetime.now(UTC)
|
||||
db_session.commit()
|
||||
|
||||
updated = db_session.query(TaskRecord).filter_by(id=fetched.id).first()
|
||||
assert updated.status == "RUNNING"
|
||||
assert updated.started_at is not None
|
||||
# #endregion test_task_crud
|
||||
|
||||
# #region test_task_jsonb_result [C:2] [TYPE Function]
|
||||
def test_task_jsonb_result(self, db_session):
|
||||
"""JSONB result with nested data persists correctly."""
|
||||
task = TaskRecord(
|
||||
type="backup",
|
||||
status="SUCCESS",
|
||||
params={"env": "prod"},
|
||||
result={
|
||||
"dashboards_count": 5,
|
||||
"files": ["dash1.yaml", "dash2.yaml"],
|
||||
"size_bytes": 1024000,
|
||||
"checksum": {"algorithm": "sha256", "value": "abc123"},
|
||||
},
|
||||
)
|
||||
db_session.add(task)
|
||||
db_session.commit()
|
||||
|
||||
fetched = db_session.query(TaskRecord).filter_by(type="backup").first()
|
||||
assert fetched.result["dashboards_count"] == 5
|
||||
assert fetched.result["checksum"]["algorithm"] == "sha256"
|
||||
assert len(fetched.result["files"]) == 2
|
||||
# #endregion test_task_jsonb_result
|
||||
|
||||
# #region test_task_error_field [C:2] [TYPE Function]
|
||||
def test_task_error_field(self, db_session):
|
||||
"""Error field stores failure details."""
|
||||
task = TaskRecord(
|
||||
type="migration",
|
||||
status="FAILED",
|
||||
error="Connection timeout after 30s",
|
||||
)
|
||||
db_session.add(task)
|
||||
db_session.commit()
|
||||
|
||||
fetched = db_session.query(TaskRecord).filter_by(status="FAILED").first()
|
||||
assert "Connection timeout" in fetched.error
|
||||
# #endregion test_task_error_field
|
||||
|
||||
# #region test_task_status_filter [C:2] [TYPE Function]
|
||||
def test_task_status_filter(self, db_session):
|
||||
"""Filter tasks by status."""
|
||||
db_session.add_all([
|
||||
TaskRecord(type="backup", status="SUCCESS"),
|
||||
TaskRecord(type="backup", status="FAILED"),
|
||||
TaskRecord(type="migration", status="RUNNING"),
|
||||
TaskRecord(type="migration", status="PENDING"),
|
||||
])
|
||||
db_session.commit()
|
||||
|
||||
running = db_session.query(TaskRecord).filter_by(status="RUNNING").all()
|
||||
assert len(running) == 1
|
||||
|
||||
completed = db_session.query(TaskRecord).filter(
|
||||
TaskRecord.status.in_(["SUCCESS", "FAILED"])
|
||||
).all()
|
||||
assert len(completed) == 2
|
||||
# #endregion test_task_status_filter
|
||||
|
||||
|
||||
class TestTaskLogRecord:
|
||||
"""TaskLogRecord — FK cascade, ILIKE, ordering."""
|
||||
|
||||
# #region test_task_log_create [C:2] [TYPE Function]
|
||||
def test_task_log_create(self, db_session):
|
||||
"""Create log entries for a task."""
|
||||
task = TaskRecord(type="migration", status="RUNNING")
|
||||
db_session.add(task)
|
||||
db_session.commit()
|
||||
|
||||
now = datetime.now(UTC)
|
||||
db_session.add_all([
|
||||
TaskLogRecord(task_id=task.id, timestamp=now, level="INFO", source="system",
|
||||
message="Task started"),
|
||||
TaskLogRecord(task_id=task.id, timestamp=now + timedelta(seconds=1),
|
||||
level="INFO", source="plugin", message="Processing dashboard 1"),
|
||||
TaskLogRecord(task_id=task.id, timestamp=now + timedelta(seconds=2),
|
||||
level="ERROR", source="superset_api", message="API timeout"),
|
||||
])
|
||||
db_session.commit()
|
||||
|
||||
logs = db_session.query(TaskLogRecord).filter_by(task_id=task.id).all()
|
||||
assert len(logs) == 3
|
||||
# #endregion test_task_log_create
|
||||
|
||||
# #region test_task_log_cascade_delete [C:2] [TYPE Function]
|
||||
def test_task_log_cascade_delete(self, db_session):
|
||||
"""Deleting a task cascades to its log records."""
|
||||
task = TaskRecord(type="migration", status="SUCCESS")
|
||||
db_session.add(task)
|
||||
db_session.commit()
|
||||
|
||||
db_session.add(TaskLogRecord(
|
||||
task_id=task.id, timestamp=datetime.now(UTC),
|
||||
level="INFO", source="system", message="Log entry",
|
||||
))
|
||||
db_session.commit()
|
||||
|
||||
assert db_session.query(TaskLogRecord).filter_by(task_id=task.id).count() == 1
|
||||
|
||||
db_session.delete(task)
|
||||
db_session.commit()
|
||||
|
||||
assert db_session.query(TaskLogRecord).filter_by(task_id=task.id).count() == 0
|
||||
# #endregion test_task_log_cascade_delete
|
||||
|
||||
# #region test_task_log_ilike_search [C:2] [TYPE Function]
|
||||
def test_task_log_ilike_search(self, db_session):
|
||||
"""ILIKE search on message field (PostgreSQL-specific)."""
|
||||
task = TaskRecord(type="migration", status="RUNNING")
|
||||
db_session.add(task)
|
||||
db_session.commit()
|
||||
|
||||
now = datetime.now(UTC)
|
||||
db_session.add_all([
|
||||
TaskLogRecord(task_id=task.id, timestamp=now, level="INFO", source="system",
|
||||
message="Task completed successfully"),
|
||||
TaskLogRecord(task_id=task.id, timestamp=now, level="ERROR", source="system",
|
||||
message="Task failed with timeout"),
|
||||
TaskLogRecord(task_id=task.id, timestamp=now, level="INFO", source="plugin",
|
||||
message="Processing dashboard Sales Report"),
|
||||
])
|
||||
db_session.commit()
|
||||
|
||||
# Search by keyword
|
||||
result = db_session.query(TaskLogRecord).filter(
|
||||
TaskLogRecord.message.ilike("%completed%")
|
||||
).all()
|
||||
assert len(result) == 1
|
||||
assert "completed" in result[0].message
|
||||
|
||||
# Case-insensitive search
|
||||
result = db_session.query(TaskLogRecord).filter(
|
||||
TaskLogRecord.message.ilike("%SALES%")
|
||||
).all()
|
||||
assert len(result) == 1
|
||||
|
||||
# Multiple matches
|
||||
result = db_session.query(TaskLogRecord).filter(
|
||||
TaskLogRecord.message.ilike("%task%")
|
||||
).all()
|
||||
assert len(result) == 2
|
||||
# #endregion test_task_log_ilike_search
|
||||
|
||||
# #region test_task_log_ordered_query [C:2] [TYPE Function]
|
||||
def test_task_log_ordered_query(self, db_session):
|
||||
"""Logs can be ordered by timestamp with composite index."""
|
||||
task = TaskRecord(type="migration", status="RUNNING")
|
||||
db_session.add(task)
|
||||
db_session.commit()
|
||||
|
||||
base = datetime.now(UTC)
|
||||
for i in range(5):
|
||||
db_session.add(TaskLogRecord(
|
||||
task_id=task.id, timestamp=base + timedelta(minutes=i),
|
||||
level="INFO", source="system",
|
||||
message=f"Step {i+1}",
|
||||
))
|
||||
db_session.commit()
|
||||
|
||||
logs = db_session.query(TaskLogRecord).filter_by(task_id=task.id)\
|
||||
.order_by(TaskLogRecord.timestamp.asc()).all()
|
||||
assert logs[0].message == "Step 1"
|
||||
assert logs[-1].message == "Step 5"
|
||||
|
||||
# Descending
|
||||
logs_desc = db_session.query(TaskLogRecord).filter_by(task_id=task.id)\
|
||||
.order_by(TaskLogRecord.timestamp.desc()).all()
|
||||
assert logs_desc[0].message == "Step 5"
|
||||
# #endregion test_task_log_ordered_query
|
||||
|
||||
# #region test_task_log_source_filter [C:2] [TYPE Function]
|
||||
def test_task_log_source_filter(self, db_session):
|
||||
"""Filter logs by source."""
|
||||
task = TaskRecord(type="migration", status="RUNNING")
|
||||
db_session.add(task)
|
||||
db_session.commit()
|
||||
|
||||
now = datetime.now(UTC)
|
||||
db_session.add_all([
|
||||
TaskLogRecord(task_id=task.id, timestamp=now, level="INFO", source="system",
|
||||
message="System log"),
|
||||
TaskLogRecord(task_id=task.id, timestamp=now, level="INFO", source="plugin",
|
||||
message="Plugin log"),
|
||||
TaskLogRecord(task_id=task.id, timestamp=now, level="INFO", source="superset_api",
|
||||
message="API log"),
|
||||
])
|
||||
db_session.commit()
|
||||
|
||||
plugin_logs = db_session.query(TaskLogRecord).filter(
|
||||
TaskLogRecord.task_id == task.id,
|
||||
TaskLogRecord.source == "plugin",
|
||||
).all()
|
||||
assert len(plugin_logs) == 1
|
||||
assert plugin_logs[0].message == "Plugin log"
|
||||
# #endregion test_task_log_source_filter
|
||||
|
||||
|
||||
class TestTaskEnvironmentFK:
|
||||
"""TaskRecord FK to environments — SET NULL behavior."""
|
||||
|
||||
# #region test_task_without_environment [C:2] [TYPE Function]
|
||||
def test_task_without_environment(self, db_session):
|
||||
"""Task can be created without environment_id (nullable FK)."""
|
||||
task = TaskRecord(type="local", status="SUCCESS")
|
||||
db_session.add(task)
|
||||
db_session.commit()
|
||||
assert task.environment_id is None
|
||||
# #endregion test_task_without_environment
|
||||
|
||||
|
||||
class TestTaskTimestampDefaults:
|
||||
"""TaskRecord server_default and auto-timestamps."""
|
||||
|
||||
# #region test_task_created_at_default [C:2] [TYPE Function]
|
||||
def test_task_created_at_default(self, db_session):
|
||||
"""created_at is auto-set by server_default."""
|
||||
task = TaskRecord(type="test", status="PENDING")
|
||||
db_session.add(task)
|
||||
db_session.commit()
|
||||
assert task.created_at is not None
|
||||
# #endregion test_task_created_at_default
|
||||
|
||||
# #region test_task_log_server_default [C:2] [TYPE Function]
|
||||
def test_task_log_server_default(self, db_session):
|
||||
"""TaskLogRecord without explicit timestamp gets server_default."""
|
||||
task = TaskRecord(type="test", status="RUNNING")
|
||||
db_session.add(task)
|
||||
db_session.commit()
|
||||
|
||||
from datetime import UTC, datetime
|
||||
now = datetime.now(UTC)
|
||||
log = TaskLogRecord(
|
||||
task_id=task.id,
|
||||
timestamp=now,
|
||||
level="INFO",
|
||||
source="system",
|
||||
message="Explicit timestamp",
|
||||
)
|
||||
db_session.add(log)
|
||||
db_session.commit()
|
||||
assert log.timestamp is not None
|
||||
assert log.timestamp == now
|
||||
# #endregion test_task_log_server_default
|
||||
# #endregion Test.Integration.TaskManager
|
||||
@@ -0,0 +1,499 @@
|
||||
# #region DictionaryIntegrationTests [C:4] [TYPE Module] [SEMANTICS test, integration, dictionary, postgres, testcontainers]
|
||||
# @BRIEF Integration tests for DictionaryCRUD and DictionaryEntryCRUD with real PostgreSQL.
|
||||
# @RELATION BINDS_TO -> [DictionaryCRUD]
|
||||
# @RELATION BINDS_TO -> [DictionaryEntryCRUD]
|
||||
# @RELATION BINDS_TO -> [TerminologyDictionary]
|
||||
# @RELATION BINDS_TO -> [DictionaryEntry]
|
||||
# @TEST_CONTRACT DictionaryCRUD ->
|
||||
# {
|
||||
# invariants: [
|
||||
# "create_dictionary persists with all fields",
|
||||
# "update_dictionary modifies only specified fields",
|
||||
# "delete_dictionary cascades to entries and job associations",
|
||||
# "delete_dictionary blocked when attached to active jobs",
|
||||
# "add_entry enforces uniqueness on (dict_id, source_term_norm, source_lang, target_lang)",
|
||||
# "same term with different language pairs allowed"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_EDGE duplicate_entry -> ValueError on repeated normalized term
|
||||
# @TEST_EDGE delete_active_job -> ValueError with active/scheduled message
|
||||
# @TEST_EDGE same_term_different_lang_pair -> allowed (not duplicate)
|
||||
# @TEST_EDGE invalid_regex -> ValueError on invalid regex pattern
|
||||
# @TEST_INVARIANT unique_normalized -> VERIFIED_BY: [test_add_entry_duplicate_normalized, test_same_term_different_lang_pair_allowed]
|
||||
# @TEST_INVARIANT cascade_integrity -> VERIFIED_BY: [test_delete_dictionary_cascades_to_entries]
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.translate import (
|
||||
DictionaryEntry,
|
||||
TerminologyDictionary,
|
||||
TranslationJob,
|
||||
TranslationJobDictionary,
|
||||
)
|
||||
from src.plugins.translate.dictionary import DictionaryManager
|
||||
|
||||
|
||||
# #region TestDictionaryCRUDIntegration [C:3] [TYPE Class]
|
||||
# @BRIEF Integration tests for dictionary-level CRUD with real PostgreSQL.
|
||||
class TestDictionaryCRUDIntegration:
|
||||
"""Verify dictionary CRUD operations with real PostgreSQL."""
|
||||
|
||||
# region test_create_dictionary_persists_all_fields [C:2] [TYPE Function]
|
||||
# @BRIEF Verify dictionary creation persists all fields.
|
||||
def test_create_dictionary_persists_all_fields(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(
|
||||
db_session,
|
||||
name="Finance Terms",
|
||||
description="Financial terminology mappings",
|
||||
created_by="test_user",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
assert dictionary.id is not None
|
||||
assert dictionary.name == "Finance Terms"
|
||||
assert dictionary.description == "Financial terminology mappings"
|
||||
assert dictionary.created_by == "test_user"
|
||||
assert dictionary.is_active is True
|
||||
assert dictionary.created_at is not None
|
||||
|
||||
# Verify persisted
|
||||
fetched = DictionaryManager.get_dictionary(db_session, dictionary.id)
|
||||
assert fetched.id == dictionary.id
|
||||
assert fetched.name == "Finance Terms"
|
||||
# endregion test_create_dictionary_persists_all_fields
|
||||
|
||||
# region test_update_dictionary_partial_update [C:2] [TYPE Function]
|
||||
# @BRIEF Verify partial update only modifies specified fields.
|
||||
def test_update_dictionary_partial_update(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(
|
||||
db_session, name="Original", description="Original desc", created_by="user1",
|
||||
)
|
||||
original_id = dictionary.id
|
||||
original_created_by = dictionary.created_by
|
||||
|
||||
# Update only name
|
||||
updated = DictionaryManager.update_dictionary(
|
||||
db_session, original_id, name="Updated Name",
|
||||
)
|
||||
|
||||
assert updated.id == original_id
|
||||
assert updated.name == "Updated Name"
|
||||
assert updated.description == "Original desc" # Unchanged
|
||||
assert updated.created_by == original_created_by # Unchanged
|
||||
# endregion test_update_dictionary_partial_update
|
||||
|
||||
# region test_delete_dictionary_cascades_to_entries [C:2] [TYPE Function]
|
||||
# @BRIEF Verify deleting dictionary removes all entries.
|
||||
def test_delete_dictionary_cascades_to_entries(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="To Delete")
|
||||
|
||||
# Add entries
|
||||
DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "hello", "hola",
|
||||
source_language="en", target_language="es",
|
||||
)
|
||||
DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "world", "mundo",
|
||||
source_language="en", target_language="es",
|
||||
)
|
||||
|
||||
# Verify entries exist
|
||||
entries, total = DictionaryManager.list_entries(db_session, dictionary.id)
|
||||
assert total == 2
|
||||
|
||||
# Delete dictionary
|
||||
DictionaryManager.delete_dictionary(db_session, dictionary.id)
|
||||
|
||||
# Verify dictionary gone
|
||||
with pytest.raises(ValueError, match="Dictionary not found"):
|
||||
DictionaryManager.get_dictionary(db_session, dictionary.id)
|
||||
|
||||
# Verify entries gone (FK cascade)
|
||||
remaining_entries = (
|
||||
db_session.query(DictionaryEntry)
|
||||
.filter(DictionaryEntry.dictionary_id == dictionary.id)
|
||||
.all()
|
||||
)
|
||||
assert len(remaining_entries) == 0
|
||||
# endregion test_delete_dictionary_cascades_to_entries
|
||||
|
||||
# region test_delete_dictionary_blocked_by_active_job [C:2] [TYPE Function]
|
||||
# @BRIEF Verify deletion blocked when attached to active/scheduled jobs.
|
||||
def test_delete_dictionary_blocked_by_active_job(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Protected")
|
||||
|
||||
# Create active job with association
|
||||
job = TranslationJob(
|
||||
name="Active Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
|
||||
link = TranslationJobDictionary(job_id=job.id, dictionary_id=dictionary.id)
|
||||
db_session.add(link)
|
||||
db_session.commit()
|
||||
|
||||
# Attempt delete should fail
|
||||
with pytest.raises(ValueError, match="active/scheduled"):
|
||||
DictionaryManager.delete_dictionary(db_session, dictionary.id)
|
||||
|
||||
# Verify dictionary still exists
|
||||
fetched = DictionaryManager.get_dictionary(db_session, dictionary.id)
|
||||
assert fetched is not None
|
||||
# endregion test_delete_dictionary_blocked_by_active_job
|
||||
|
||||
# region test_delete_dictionary_blocked_by_scheduled_job [C:2] [TYPE Function]
|
||||
# @BRIEF Verify deletion blocked when attached to SCHEDULED jobs.
|
||||
def test_delete_dictionary_blocked_by_scheduled_job(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Scheduled Protected")
|
||||
|
||||
# Create scheduled job with association
|
||||
job = TranslationJob(
|
||||
name="Scheduled Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="SCHEDULED",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
|
||||
link = TranslationJobDictionary(job_id=job.id, dictionary_id=dictionary.id)
|
||||
db_session.add(link)
|
||||
db_session.commit()
|
||||
|
||||
with pytest.raises(ValueError, match="active/scheduled"):
|
||||
DictionaryManager.delete_dictionary(db_session, dictionary.id)
|
||||
# endregion test_delete_dictionary_blocked_by_scheduled_job
|
||||
|
||||
# region test_delete_dictionary_allowed_with_completed_job [C:2] [TYPE Function]
|
||||
# @BRIEF Verify deletion allowed when only completed/failed jobs reference it.
|
||||
def test_delete_dictionary_allowed_with_completed_job(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Completed OK")
|
||||
|
||||
# Create completed job with association
|
||||
job = TranslationJob(
|
||||
name="Completed Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="COMPLETED",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
|
||||
link = TranslationJobDictionary(job_id=job.id, dictionary_id=dictionary.id)
|
||||
db_session.add(link)
|
||||
db_session.commit()
|
||||
|
||||
# Should succeed
|
||||
DictionaryManager.delete_dictionary(db_session, dictionary.id)
|
||||
|
||||
with pytest.raises(ValueError, match="Dictionary not found"):
|
||||
DictionaryManager.get_dictionary(db_session, dictionary.id)
|
||||
# endregion test_delete_dictionary_allowed_with_completed_job
|
||||
|
||||
# region test_list_dictionaries_pagination [C:2] [TYPE Function]
|
||||
# @BRIEF Verify paginated listing works correctly.
|
||||
def test_list_dictionaries_pagination(self, db_session: Session):
|
||||
# Create 7 dictionaries
|
||||
for i in range(7):
|
||||
DictionaryManager.create_dictionary(db_session, name=f"Dict {i}")
|
||||
|
||||
# Page 1
|
||||
dicts, total = DictionaryManager.list_dictionaries(db_session, page=1, page_size=3)
|
||||
assert total == 7
|
||||
assert len(dicts) == 3
|
||||
|
||||
# Page 2
|
||||
dicts, total = DictionaryManager.list_dictionaries(db_session, page=2, page_size=3)
|
||||
assert total == 7
|
||||
assert len(dicts) == 3
|
||||
|
||||
# Page 3
|
||||
dicts, total = DictionaryManager.list_dictionaries(db_session, page=3, page_size=3)
|
||||
assert total == 7
|
||||
assert len(dicts) == 1
|
||||
# endregion test_list_dictionaries_pagination
|
||||
|
||||
# #endregion TestDictionaryCRUDIntegration
|
||||
|
||||
|
||||
# #region TestDictionaryEntryCRUDIntegration [C:3] [TYPE Class]
|
||||
# @BRIEF Integration tests for entry-level CRUD with real PostgreSQL.
|
||||
class TestDictionaryEntryCRUDIntegration:
|
||||
"""Verify entry CRUD operations with real PostgreSQL."""
|
||||
|
||||
# region test_add_entry_persists_all_fields [C:2] [TYPE Function]
|
||||
# @BRIEF Verify entry creation persists all fields.
|
||||
def test_add_entry_persists_all_fields(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Test")
|
||||
|
||||
entry = DictionaryManager.add_entry(
|
||||
db_session,
|
||||
dictionary.id,
|
||||
"hello world",
|
||||
"привет мир",
|
||||
source_language="en",
|
||||
target_language="ru",
|
||||
context_notes="greeting",
|
||||
is_regex=False,
|
||||
)
|
||||
|
||||
assert entry.id is not None
|
||||
assert entry.dictionary_id == dictionary.id
|
||||
assert entry.source_term == "hello world"
|
||||
assert entry.target_term == "привет мир"
|
||||
assert entry.source_language == "en"
|
||||
assert entry.target_language == "ru"
|
||||
assert entry.context_notes == "greeting"
|
||||
assert entry.is_regex is False
|
||||
|
||||
# Verify persisted
|
||||
entries, total = DictionaryManager.list_entries(db_session, dictionary.id)
|
||||
assert total == 1
|
||||
assert entries[0].source_term == "hello world"
|
||||
# endregion test_add_entry_persists_all_fields
|
||||
|
||||
# region test_add_entry_duplicate_normalized [C:2] [TYPE Function]
|
||||
# @BRIEF Verify duplicate detection uses normalized (lowercased) term.
|
||||
def test_add_entry_duplicate_normalized(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Norm Test")
|
||||
|
||||
DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "Hello", "Hola",
|
||||
source_language="en", target_language="es",
|
||||
)
|
||||
|
||||
# Same term, different case — should be duplicate
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "HELLO", "Bonjour",
|
||||
source_language="en", target_language="es",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "hello", "Ciao",
|
||||
source_language="en", target_language="es",
|
||||
)
|
||||
# endregion test_add_entry_duplicate_normalized
|
||||
|
||||
# region test_same_term_different_lang_pair_allowed [C:2] [TYPE Function]
|
||||
# @BRIEF Verify same term with different language pair is allowed.
|
||||
def test_same_term_different_lang_pair_allowed(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Multi Lang")
|
||||
|
||||
entry1 = DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "hello", "привет",
|
||||
source_language="en", target_language="ru",
|
||||
)
|
||||
entry2 = DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "hello", "hallo",
|
||||
source_language="en", target_language="de",
|
||||
)
|
||||
entry3 = DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "hello", "bonjour",
|
||||
source_language="en", target_language="fr",
|
||||
)
|
||||
|
||||
assert entry1.id != entry2.id != entry3.id
|
||||
assert entry1.target_language == "ru"
|
||||
assert entry2.target_language == "de"
|
||||
assert entry3.target_language == "fr"
|
||||
|
||||
entries, total = DictionaryManager.list_entries(db_session, dictionary.id)
|
||||
assert total == 3
|
||||
# endregion test_same_term_different_lang_pair_allowed
|
||||
|
||||
# region test_same_term_different_dictionary_allowed [C:2] [TYPE Function]
|
||||
# @BRIEF Verify same term in different dictionaries is allowed.
|
||||
def test_same_term_different_dictionary_allowed(self, db_session: Session):
|
||||
dict1 = DictionaryManager.create_dictionary(db_session, name="Dict 1")
|
||||
dict2 = DictionaryManager.create_dictionary(db_session, name="Dict 2")
|
||||
|
||||
entry1 = DictionaryManager.add_entry(
|
||||
db_session, dict1.id, "hello", "hola",
|
||||
source_language="en", target_language="es",
|
||||
)
|
||||
entry2 = DictionaryManager.add_entry(
|
||||
db_session, dict2.id, "hello", "bonjour",
|
||||
source_language="en", target_language="fr",
|
||||
)
|
||||
|
||||
assert entry1.id != entry2.id
|
||||
assert entry1.dictionary_id == dict1.id
|
||||
assert entry2.dictionary_id == dict2.id
|
||||
# endregion test_same_term_different_dictionary_allowed
|
||||
|
||||
# region test_edit_entry_updates_fields [C:2] [TYPE Function]
|
||||
# @BRIEF Verify editing entry updates specified fields.
|
||||
def test_edit_entry_updates_fields(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Edit Test")
|
||||
|
||||
entry = DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "hello", "hola",
|
||||
source_language="en", target_language="es",
|
||||
context_notes="greeting",
|
||||
)
|
||||
|
||||
updated = DictionaryManager.edit_entry(
|
||||
db_session, entry.id,
|
||||
target_term="HOLA!",
|
||||
context_notes="formal greeting",
|
||||
)
|
||||
|
||||
assert updated.target_term == "HOLA!"
|
||||
assert updated.context_notes == "formal greeting"
|
||||
assert updated.source_term == "hello" # Unchanged
|
||||
# endregion test_edit_entry_updates_fields
|
||||
|
||||
# region test_edit_entry_source_term_checks_uniqueness [C:2] [TYPE Function]
|
||||
# @BRIEF Verify editing source_term checks uniqueness constraint.
|
||||
def test_edit_entry_source_term_checks_uniqueness(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Uniq Test")
|
||||
|
||||
DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "hello", "hola",
|
||||
source_language="en", target_language="es",
|
||||
)
|
||||
entry2 = DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "world", "mundo",
|
||||
source_language="en", target_language="es",
|
||||
)
|
||||
|
||||
# Try to change entry2's source to "hello" — should fail
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
DictionaryManager.edit_entry(db_session, entry2.id, source_term="HELLO")
|
||||
# endregion test_edit_entry_source_term_checks_uniqueness
|
||||
|
||||
# region test_delete_entry [C:2] [TYPE Function]
|
||||
# @BRIEF Verify entry deletion.
|
||||
def test_delete_entry(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Delete Test")
|
||||
|
||||
entry = DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "hello", "hola",
|
||||
source_language="en", target_language="es",
|
||||
)
|
||||
|
||||
DictionaryManager.delete_entry(db_session, entry.id)
|
||||
|
||||
entries, total = DictionaryManager.list_entries(db_session, dictionary.id)
|
||||
assert total == 0
|
||||
# endregion test_delete_entry
|
||||
|
||||
# region test_clear_entries [C:2] [TYPE Function]
|
||||
# @BRIEF Verify clearing all entries for a dictionary.
|
||||
def test_clear_entries(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Clear Test")
|
||||
|
||||
DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "hello", "hola",
|
||||
source_language="en", target_language="es",
|
||||
)
|
||||
DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "world", "mundo",
|
||||
source_language="en", target_language="es",
|
||||
)
|
||||
DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "goodbye", "adiós",
|
||||
source_language="en", target_language="es",
|
||||
)
|
||||
|
||||
deleted = DictionaryManager.clear_entries(db_session, dictionary.id)
|
||||
assert deleted == 3
|
||||
|
||||
entries, total = DictionaryManager.list_entries(db_session, dictionary.id)
|
||||
assert total == 0
|
||||
# endregion test_clear_entries
|
||||
|
||||
# region test_add_entry_regex_valid_pattern [C:2] [TYPE Function]
|
||||
# @BRIEF Verify regex entry with valid pattern.
|
||||
def test_add_entry_regex_valid_pattern(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Regex Valid")
|
||||
|
||||
entry = DictionaryManager.add_entry(
|
||||
db_session, dictionary.id,
|
||||
r"\d{3}-\d{2}-\d{4}",
|
||||
"ID-MASKED",
|
||||
source_language="en",
|
||||
target_language="ru",
|
||||
is_regex=True,
|
||||
)
|
||||
|
||||
assert entry.is_regex is True
|
||||
assert entry.source_term == r"\d{3}-\d{2}-\d{4}"
|
||||
|
||||
entries, total = DictionaryManager.list_entries(db_session, dictionary.id)
|
||||
assert total == 1
|
||||
assert entries[0].is_regex is True
|
||||
# endregion test_add_entry_regex_valid_pattern
|
||||
|
||||
# region test_add_entry_regex_invalid_pattern_raises [C:2] [TYPE Function]
|
||||
# @BRIEF Verify invalid regex pattern raises ValueError.
|
||||
def test_add_entry_regex_invalid_pattern_raises(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Regex Invalid")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid regex pattern"):
|
||||
DictionaryManager.add_entry(
|
||||
db_session, dictionary.id,
|
||||
r"[unclosed",
|
||||
"broken",
|
||||
source_language="en",
|
||||
target_language="ru",
|
||||
is_regex=True,
|
||||
)
|
||||
# endregion test_add_entry_regex_invalid_pattern_raises
|
||||
|
||||
# region test_edit_entry_toggle_regex [C:2] [TYPE Function]
|
||||
# @BRIEF Verify toggling is_regex via edit_entry.
|
||||
def test_edit_entry_toggle_regex(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Regex Toggle")
|
||||
|
||||
entry = DictionaryManager.add_entry(
|
||||
db_session, dictionary.id, "hello", "hola",
|
||||
source_language="en", target_language="es",
|
||||
is_regex=False,
|
||||
)
|
||||
assert entry.is_regex is False
|
||||
|
||||
updated = DictionaryManager.edit_entry(db_session, entry.id, is_regex=True)
|
||||
assert updated.is_regex is True
|
||||
|
||||
updated2 = DictionaryManager.edit_entry(db_session, entry.id, is_regex=False)
|
||||
assert updated2.is_regex is False
|
||||
# endregion test_edit_entry_toggle_regex
|
||||
|
||||
# region test_edit_entry_regex_source_validates_pattern [C:2] [TYPE Function]
|
||||
# @BRIEF Verify editing source_term on regex entry validates pattern.
|
||||
def test_edit_entry_regex_source_validates_pattern(self, db_session: Session):
|
||||
dictionary = DictionaryManager.create_dictionary(db_session, name="Regex Edit Val")
|
||||
|
||||
entry = DictionaryManager.add_entry(
|
||||
db_session, dictionary.id,
|
||||
r"\d+",
|
||||
"NUMBER",
|
||||
source_language="en",
|
||||
target_language="ru",
|
||||
is_regex=True,
|
||||
)
|
||||
|
||||
# Try to set invalid regex
|
||||
with pytest.raises(ValueError, match="Invalid regex pattern"):
|
||||
DictionaryManager.edit_entry(db_session, entry.id, source_term=r"[unclosed")
|
||||
# endregion test_edit_entry_regex_source_validates_pattern
|
||||
|
||||
# #endregion TestDictionaryEntryCRUDIntegration
|
||||
|
||||
# #endregion DictionaryIntegrationTests
|
||||
@@ -0,0 +1,707 @@
|
||||
# #region OrchestratorIntegrationTests [C:4] [TYPE Module] [SEMANTICS test, integration, orchestrator, postgres, testcontainers]
|
||||
# @BRIEF Integration tests for TranslationOrchestrator with real PostgreSQL.
|
||||
# @RELATION BINDS_TO -> [TranslationOrchestrator]
|
||||
# @RELATION BINDS_TO -> [TranslationJob]
|
||||
# @RELATION BINDS_TO -> [TranslationRun]
|
||||
# @RELATION BINDS_TO -> [TranslationBatch]
|
||||
# @RELATION BINDS_TO -> [TranslationRecord]
|
||||
# @RELATION BINDS_TO -> [TranslationEvent]
|
||||
# @TEST_CONTRACT TranslationOrchestrator ->
|
||||
# {
|
||||
# invariants: [
|
||||
# "start_run creates PENDING run with event",
|
||||
# "execute_run transitions run through RUNNING -> COMPLETED/FAILED",
|
||||
# "cancel_run sets status to CANCELLED",
|
||||
# "retry_failed_batches re-executes only failed batches",
|
||||
# "only one terminal event allowed per run",
|
||||
# "RUN_STARTED must precede other run events"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_EDGE missing_preview -> ValueError when no accepted preview and no datasource
|
||||
# @TEST_EDGE draft_job_cannot_run -> ValueError on DRAFT job
|
||||
# @TEST_EDGE invalid_run_status -> ValueError on non-PENDING run
|
||||
# @TEST_EDGE executor_failure -> run marked FAILED with error
|
||||
# @TEST_EDGE cancel_completed_run -> ValueError
|
||||
# @TEST_INVARIANT terminal_event_uniqueness -> VERIFIED_BY: [test_only_one_terminal_event_per_run]
|
||||
# @TEST_INVARIANT event_ordering -> VERIFIED_BY: [test_run_started_must_precede_other_events]
|
||||
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.models.translate import (
|
||||
TranslationBatch,
|
||||
TranslationEvent,
|
||||
TranslationJob,
|
||||
TranslationPreviewSession,
|
||||
TranslationRecord,
|
||||
TranslationRun,
|
||||
)
|
||||
from src.plugins.translate.events import TranslationEventLog
|
||||
from src.plugins.translate.orchestrator import TranslationOrchestrator
|
||||
|
||||
|
||||
# #region TestTranslationEventLogIntegration [C:3] [TYPE Class]
|
||||
# @BRIEF Integration tests for TranslationEventLog with real PostgreSQL.
|
||||
class TestTranslationEventLogIntegration:
|
||||
"""Verify event log operations with real PostgreSQL."""
|
||||
|
||||
# region test_log_event_creates_record [C:2] [TYPE Function]
|
||||
# @BRIEF Verify event creation persists correctly.
|
||||
def test_log_event_creates_record(self, db_session: Session):
|
||||
# Create job and run first (FK constraints)
|
||||
job = TranslationJob(
|
||||
id="job-1",
|
||||
name="Test Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
source_datasource_id="42",
|
||||
translation_column="name",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
|
||||
run = TranslationRun(
|
||||
id="run-1",
|
||||
job_id="job-1",
|
||||
status="RUNNING",
|
||||
started_at=datetime.now(UTC),
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
event_log = TranslationEventLog(db_session)
|
||||
|
||||
event = event_log.log_event(
|
||||
job_id="job-1",
|
||||
event_type="RUN_STARTED",
|
||||
payload={"key": "value"},
|
||||
run_id="run-1",
|
||||
)
|
||||
|
||||
assert event is not None
|
||||
assert event.id is not None
|
||||
assert event.job_id == "job-1"
|
||||
assert event.run_id == "run-1"
|
||||
assert event.event_type == "RUN_STARTED"
|
||||
assert event.event_data == {"key": "value"}
|
||||
assert event.created_at is not None
|
||||
|
||||
# Verify persisted
|
||||
fetched = (
|
||||
db_session.query(TranslationEvent)
|
||||
.filter(TranslationEvent.id == event.id)
|
||||
.first()
|
||||
)
|
||||
assert fetched is not None
|
||||
assert fetched.event_type == "RUN_STARTED"
|
||||
# endregion test_log_event_creates_record
|
||||
|
||||
# region test_invalid_event_type_raises [C:2] [TYPE Function]
|
||||
# @BRIEF Verify invalid event type raises ValueError.
|
||||
def test_invalid_event_type_raises(self, db_session: Session):
|
||||
event_log = TranslationEventLog(db_session)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid event_type"):
|
||||
event_log.log_event(
|
||||
job_id="job-1",
|
||||
event_type="UNKNOWN_EVENT_TYPE",
|
||||
)
|
||||
# endregion test_invalid_event_type_raises
|
||||
|
||||
# region test_only_one_terminal_event_per_run [C:2] [TYPE Function]
|
||||
# @BRIEF Verify only one terminal event allowed per run.
|
||||
def test_only_one_terminal_event_per_run(self, db_session: Session):
|
||||
# Create job and run first (FK constraints)
|
||||
job = TranslationJob(
|
||||
id="job-1",
|
||||
name="Test Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
source_datasource_id="42",
|
||||
translation_column="name",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
|
||||
run = TranslationRun(
|
||||
id="run-1",
|
||||
job_id="job-1",
|
||||
status="RUNNING",
|
||||
started_at=datetime.now(UTC),
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
event_log = TranslationEventLog(db_session)
|
||||
|
||||
# Create first terminal event
|
||||
event_log.log_event(
|
||||
job_id="job-1",
|
||||
run_id="run-1",
|
||||
event_type="RUN_COMPLETED",
|
||||
)
|
||||
|
||||
# Second terminal event should fail
|
||||
with pytest.raises(ValueError, match="already has a terminal event"):
|
||||
event_log.log_event(
|
||||
job_id="job-1",
|
||||
run_id="run-1",
|
||||
event_type="RUN_FAILED",
|
||||
)
|
||||
# endregion test_only_one_terminal_event_per_run
|
||||
|
||||
# region test_run_started_must_precede_other_events [C:2] [TYPE Function]
|
||||
# @BRIEF Verify RUN_STARTED must exist before other run events.
|
||||
def test_run_started_must_precede_other_events(self, db_session: Session):
|
||||
event_log = TranslationEventLog(db_session)
|
||||
|
||||
# Try to log BATCH_STARTED without RUN_STARTED
|
||||
with pytest.raises(ValueError, match="RUN_STARTED event must precede"):
|
||||
event_log.log_event(
|
||||
job_id="job-1",
|
||||
run_id="run-1",
|
||||
event_type="BATCH_STARTED",
|
||||
)
|
||||
# endregion test_run_started_must_precede_other_events
|
||||
|
||||
# region test_valid_event_sequence [C:2] [TYPE Function]
|
||||
# @BRIEF Verify valid event sequence succeeds.
|
||||
def test_valid_event_sequence(self, db_session: Session):
|
||||
# Create job and run first (FK constraints)
|
||||
job = TranslationJob(
|
||||
id="job-1",
|
||||
name="Test Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
source_datasource_id="42",
|
||||
translation_column="name",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
|
||||
run = TranslationRun(
|
||||
id="run-1",
|
||||
job_id="job-1",
|
||||
status="RUNNING",
|
||||
started_at=datetime.now(UTC),
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
event_log = TranslationEventLog(db_session)
|
||||
|
||||
# Valid sequence: RUN_STARTED -> BATCH_STARTED -> BATCH_COMPLETED -> RUN_COMPLETED
|
||||
event_log.log_event(
|
||||
job_id="job-1",
|
||||
run_id="run-1",
|
||||
event_type="RUN_STARTED",
|
||||
)
|
||||
event_log.log_event(
|
||||
job_id="job-1",
|
||||
run_id="run-1",
|
||||
event_type="BATCH_STARTED",
|
||||
payload={"batch_id": "batch-1"},
|
||||
)
|
||||
event_log.log_event(
|
||||
job_id="job-1",
|
||||
run_id="run-1",
|
||||
event_type="BATCH_COMPLETED",
|
||||
payload={"batch_id": "batch-1"},
|
||||
)
|
||||
event_log.log_event(
|
||||
job_id="job-1",
|
||||
run_id="run-1",
|
||||
event_type="RUN_COMPLETED",
|
||||
)
|
||||
|
||||
# Verify all events persisted
|
||||
events = (
|
||||
db_session.query(TranslationEvent)
|
||||
.filter(TranslationEvent.run_id == "run-1")
|
||||
.order_by(TranslationEvent.created_at)
|
||||
.all()
|
||||
)
|
||||
assert len(events) == 4
|
||||
assert events[0].event_type == "RUN_STARTED"
|
||||
assert events[1].event_type == "BATCH_STARTED"
|
||||
assert events[2].event_type == "BATCH_COMPLETED"
|
||||
assert events[3].event_type == "RUN_COMPLETED"
|
||||
# endregion test_valid_event_sequence
|
||||
|
||||
# #endregion TestTranslationEventLogIntegration
|
||||
|
||||
|
||||
# #region TestTranslationOrchestratorIntegration [C:3] [TYPE Class]
|
||||
# @BRIEF Integration tests for TranslationOrchestrator with real PostgreSQL.
|
||||
class TestTranslationOrchestratorIntegration:
|
||||
"""Verify orchestrator operations with real PostgreSQL."""
|
||||
|
||||
# region test_start_run_creates_pending_run [C:2] [TYPE Function]
|
||||
# @BRIEF Verify start_run creates PENDING run with event.
|
||||
def test_start_run_creates_pending_run(self, db_session: Session):
|
||||
# Create job with datasource, target table, and LLM provider
|
||||
job = TranslationJob(
|
||||
id="job-123",
|
||||
name="Test Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
source_datasource_id="42",
|
||||
translation_column="name",
|
||||
target_table="translated_data",
|
||||
target_schema="public",
|
||||
provider_id="openai",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
config_manager = MagicMock()
|
||||
orch = TranslationOrchestrator(db_session, config_manager, "test_user")
|
||||
|
||||
run = orch.start_run(job_id="job-123")
|
||||
|
||||
assert run is not None
|
||||
assert run.id is not None
|
||||
assert run.job_id == "job-123"
|
||||
assert run.status == "PENDING"
|
||||
assert run.created_by == "test_user"
|
||||
assert run.created_at is not None
|
||||
# Note: started_at is set when execution begins, not at plan time
|
||||
|
||||
# Verify event created
|
||||
events = (
|
||||
db_session.query(TranslationEvent)
|
||||
.filter(TranslationEvent.run_id == run.id)
|
||||
.all()
|
||||
)
|
||||
assert len(events) >= 1
|
||||
assert any(e.event_type == "RUN_STARTED" for e in events)
|
||||
# endregion test_start_run_creates_pending_run
|
||||
|
||||
# region test_start_run_draft_job_raises [C:2] [TYPE Function]
|
||||
# @BRIEF Verify DRAFT job cannot be run.
|
||||
def test_start_run_draft_job_raises(self, db_session: Session):
|
||||
job = TranslationJob(
|
||||
id="job-draft",
|
||||
name="Draft Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="DRAFT",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
config_manager = MagicMock()
|
||||
orch = TranslationOrchestrator(db_session, config_manager, "test_user")
|
||||
|
||||
with pytest.raises(ValueError, match="DRAFT"):
|
||||
orch.start_run(job_id="job-draft")
|
||||
# endregion test_start_run_draft_job_raises
|
||||
|
||||
# region test_start_run_missing_preview_raises [C:2] [TYPE Function]
|
||||
# @BRIEF Verify run without preview and without datasource raises.
|
||||
def test_start_run_missing_preview_raises(self, db_session: Session):
|
||||
# Job without datasource and no preview, but with target table and provider
|
||||
job = TranslationJob(
|
||||
id="job-no-preview",
|
||||
name="No Preview Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
source_datasource_id=None, # No datasource
|
||||
environment_id=None,
|
||||
translation_column="name",
|
||||
target_table="translated_data",
|
||||
target_schema="public",
|
||||
provider_id="openai",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.commit()
|
||||
|
||||
config_manager = MagicMock()
|
||||
orch = TranslationOrchestrator(db_session, config_manager, "test_user")
|
||||
|
||||
with pytest.raises(ValueError, match="no accepted preview"):
|
||||
orch.start_run(job_id="job-no-preview")
|
||||
# endregion test_start_run_missing_preview_raises
|
||||
|
||||
# region test_start_run_with_accepted_preview_succeeds [C:2] [TYPE Function]
|
||||
# @BRIEF Verify run with accepted preview session succeeds.
|
||||
def test_start_run_with_accepted_preview_succeeds(self, db_session: Session):
|
||||
# Create job without datasource but with target table and provider
|
||||
job = TranslationJob(
|
||||
id="job-with-preview",
|
||||
name="Preview Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
source_datasource_id=None,
|
||||
translation_column="name",
|
||||
target_table="translated_data",
|
||||
target_schema="public",
|
||||
provider_id="openai",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
|
||||
# Create accepted preview session
|
||||
preview = TranslationPreviewSession(
|
||||
id="preview-1",
|
||||
job_id="job-with-preview",
|
||||
status="APPLIED",
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db_session.add(preview)
|
||||
db_session.commit()
|
||||
|
||||
config_manager = MagicMock()
|
||||
orch = TranslationOrchestrator(db_session, config_manager, "test_user")
|
||||
|
||||
run = orch.start_run(job_id="job-with-preview")
|
||||
|
||||
assert run.status == "PENDING"
|
||||
assert run.job_id == "job-with-preview"
|
||||
# endregion test_start_run_with_accepted_preview_succeeds
|
||||
|
||||
# region test_cancel_run_sets_cancelled_status [C:2] [TYPE Function]
|
||||
# @BRIEF Verify cancel_run sets status to CANCELLED.
|
||||
def test_cancel_run_sets_cancelled_status(self, db_session: Session):
|
||||
# Create job and run
|
||||
job = TranslationJob(
|
||||
id="job-cancel",
|
||||
name="Cancel Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
source_datasource_id="42",
|
||||
translation_column="name",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
|
||||
run = TranslationRun(
|
||||
id="run-cancel",
|
||||
job_id="job-cancel",
|
||||
status="RUNNING",
|
||||
started_at=datetime.now(UTC),
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
config_manager = MagicMock()
|
||||
orch = TranslationOrchestrator(db_session, config_manager, "test_user")
|
||||
|
||||
result = orch.cancel_run("run-cancel")
|
||||
|
||||
assert result.status == "CANCELLED"
|
||||
assert result.completed_at is not None
|
||||
|
||||
# Verify event created
|
||||
events = (
|
||||
db_session.query(TranslationEvent)
|
||||
.filter(TranslationEvent.run_id == "run-cancel")
|
||||
.all()
|
||||
)
|
||||
assert any(e.event_type == "RUN_CANCELLED" for e in events)
|
||||
# endregion test_cancel_run_sets_cancelled_status
|
||||
|
||||
# region test_cancel_completed_run_raises [C:2] [TYPE Function]
|
||||
# @BRIEF Verify cannot cancel a completed run.
|
||||
def test_cancel_completed_run_raises(self, db_session: Session):
|
||||
job = TranslationJob(
|
||||
id="job-completed",
|
||||
name="Completed Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
source_datasource_id="42",
|
||||
translation_column="name",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
|
||||
run = TranslationRun(
|
||||
id="run-completed",
|
||||
job_id="job-completed",
|
||||
status="COMPLETED",
|
||||
started_at=datetime.now(UTC),
|
||||
completed_at=datetime.now(UTC),
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
config_manager = MagicMock()
|
||||
orch = TranslationOrchestrator(db_session, config_manager, "test_user")
|
||||
|
||||
with pytest.raises(ValueError, match="cancelled"):
|
||||
orch.cancel_run("run-completed")
|
||||
# endregion test_cancel_completed_run_raises
|
||||
|
||||
# region test_get_run_status_returns_structured_data [C:2] [TYPE Function]
|
||||
# @BRIEF Verify get_run_status returns structured status dict.
|
||||
def test_get_run_status_returns_structured_data(self, db_session: Session):
|
||||
job = TranslationJob(
|
||||
id="job-status",
|
||||
name="Status Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
source_datasource_id="42",
|
||||
translation_column="name",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
|
||||
run = TranslationRun(
|
||||
id="run-status",
|
||||
job_id="job-status",
|
||||
status="COMPLETED",
|
||||
started_at=datetime.now(UTC),
|
||||
completed_at=datetime.now(UTC),
|
||||
total_records=100,
|
||||
successful_records=95,
|
||||
failed_records=3,
|
||||
skipped_records=2,
|
||||
insert_status="success",
|
||||
superset_execution_id="query-1",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
config_manager = MagicMock()
|
||||
orch = TranslationOrchestrator(db_session, config_manager, "test_user")
|
||||
|
||||
status = orch.get_run_status("run-status")
|
||||
|
||||
assert status["id"] == "run-status"
|
||||
assert status["job_id"] == "job-status"
|
||||
assert status["status"] == "COMPLETED"
|
||||
assert status["total_records"] == 100
|
||||
assert status["successful_records"] == 95
|
||||
assert status["failed_records"] == 3
|
||||
assert status["skipped_records"] == 2
|
||||
assert status["insert_status"] == "success"
|
||||
assert status["superset_execution_id"] == "query-1"
|
||||
# endregion test_get_run_status_returns_structured_data
|
||||
|
||||
# region test_get_run_history_returns_paginated_runs [C:2] [TYPE Function]
|
||||
# @BRIEF Verify get_run_history returns paginated runs.
|
||||
def test_get_run_history_returns_paginated_runs(self, db_session: Session):
|
||||
job = TranslationJob(
|
||||
id="job-history",
|
||||
name="History Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
source_datasource_id="42",
|
||||
translation_column="name",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
|
||||
# Create multiple runs
|
||||
for i in range(5):
|
||||
run = TranslationRun(
|
||||
id=f"run-history-{i}",
|
||||
job_id="job-history",
|
||||
status="COMPLETED",
|
||||
started_at=datetime.now(UTC),
|
||||
completed_at=datetime.now(UTC),
|
||||
total_records=10 * (i + 1),
|
||||
successful_records=10 * (i + 1),
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
config_manager = MagicMock()
|
||||
orch = TranslationOrchestrator(db_session, config_manager, "test_user")
|
||||
|
||||
total, runs = orch.get_run_history("job-history", page=1, page_size=2)
|
||||
|
||||
assert total == 5
|
||||
assert len(runs) == 2
|
||||
|
||||
total, runs = orch.get_run_history("job-history", page=3, page_size=2)
|
||||
assert total == 5
|
||||
assert len(runs) == 1
|
||||
# endregion test_get_run_history_returns_paginated_runs
|
||||
|
||||
# #endregion TestTranslationOrchestratorIntegration
|
||||
|
||||
|
||||
# #region TestTranslationBatchRecordIntegration [C:3] [TYPE Class]
|
||||
# @BRIEF Integration tests for batch and record operations with real PostgreSQL.
|
||||
class TestTranslationBatchRecordIntegration:
|
||||
"""Verify batch and record operations with real PostgreSQL."""
|
||||
|
||||
# region test_create_batch_with_records [C:2] [TYPE Function]
|
||||
# @BRIEF Verify batch creation with records persists correctly.
|
||||
def test_create_batch_with_records(self, db_session: Session):
|
||||
# Create job and run
|
||||
job = TranslationJob(
|
||||
id="job-batch",
|
||||
name="Batch Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
source_datasource_id="42",
|
||||
translation_column="name",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
|
||||
run = TranslationRun(
|
||||
id="run-batch",
|
||||
job_id="job-batch",
|
||||
status="RUNNING",
|
||||
started_at=datetime.now(UTC),
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.flush()
|
||||
|
||||
# Create batch
|
||||
batch = TranslationBatch(
|
||||
id="batch-1",
|
||||
run_id="run-batch",
|
||||
batch_index=0,
|
||||
status="PENDING",
|
||||
total_records=3,
|
||||
)
|
||||
db_session.add(batch)
|
||||
db_session.flush()
|
||||
|
||||
# Create records
|
||||
for i in range(3):
|
||||
record = TranslationRecord(
|
||||
id=f"record-{i}",
|
||||
batch_id="batch-1",
|
||||
run_id="run-batch",
|
||||
source_sql=f"SELECT {i}",
|
||||
target_sql=f"INSERT INTO target VALUES ({i})",
|
||||
source_object_type="table_row",
|
||||
source_object_id=str(i),
|
||||
status="SUCCESS",
|
||||
)
|
||||
db_session.add(record)
|
||||
db_session.commit()
|
||||
|
||||
# Verify batch
|
||||
fetched_batch = (
|
||||
db_session.query(TranslationBatch)
|
||||
.filter(TranslationBatch.id == "batch-1")
|
||||
.first()
|
||||
)
|
||||
assert fetched_batch is not None
|
||||
assert fetched_batch.run_id == "run-batch"
|
||||
assert fetched_batch.total_records == 3
|
||||
|
||||
# Verify records
|
||||
records = (
|
||||
db_session.query(TranslationRecord)
|
||||
.filter(TranslationRecord.batch_id == "batch-1")
|
||||
.all()
|
||||
)
|
||||
assert len(records) == 3
|
||||
assert all(r.status == "SUCCESS" for r in records)
|
||||
# endregion test_create_batch_with_records
|
||||
|
||||
# region test_cascade_delete_run_deletes_batches_and_records [C:2] [TYPE Function]
|
||||
# @BRIEF Verify deleting run cascades to batches and records.
|
||||
def test_cascade_delete_run_deletes_batches_and_records(self, db_session: Session):
|
||||
job = TranslationJob(
|
||||
id="job-cascade",
|
||||
name="Cascade Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
status="ACTIVE",
|
||||
source_datasource_id="42",
|
||||
translation_column="name",
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(job)
|
||||
db_session.flush()
|
||||
|
||||
run = TranslationRun(
|
||||
id="run-cascade",
|
||||
job_id="job-cascade",
|
||||
status="COMPLETED",
|
||||
started_at=datetime.now(UTC),
|
||||
completed_at=datetime.now(UTC),
|
||||
created_by="test_user",
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.flush()
|
||||
|
||||
batch = TranslationBatch(
|
||||
id="batch-cascade",
|
||||
run_id="run-cascade",
|
||||
batch_index=0,
|
||||
status="COMPLETED",
|
||||
total_records=1,
|
||||
)
|
||||
db_session.add(batch)
|
||||
db_session.flush()
|
||||
|
||||
record = TranslationRecord(
|
||||
id="record-cascade",
|
||||
batch_id="batch-cascade",
|
||||
run_id="run-cascade",
|
||||
source_sql="SELECT 1",
|
||||
status="SUCCESS",
|
||||
)
|
||||
db_session.add(record)
|
||||
db_session.commit()
|
||||
|
||||
# Delete run
|
||||
db_session.delete(run)
|
||||
db_session.commit()
|
||||
|
||||
# Verify cascade
|
||||
remaining_batch = (
|
||||
db_session.query(TranslationBatch)
|
||||
.filter(TranslationBatch.id == "batch-cascade")
|
||||
.first()
|
||||
)
|
||||
assert remaining_batch is None
|
||||
|
||||
remaining_record = (
|
||||
db_session.query(TranslationRecord)
|
||||
.filter(TranslationRecord.id == "record-cascade")
|
||||
.first()
|
||||
)
|
||||
assert remaining_record is None
|
||||
# endregion test_cascade_delete_run_deletes_batches_and_records
|
||||
|
||||
# #endregion TestTranslationBatchRecordIntegration
|
||||
|
||||
# #endregion OrchestratorIntegrationTests
|
||||
562
backend/tests/integration/test_translate_service_integration.py
Normal file
562
backend/tests/integration/test_translate_service_integration.py
Normal file
@@ -0,0 +1,562 @@
|
||||
# #region TranslateServiceIntegrationTests [C:4] [TYPE Module] [SEMANTICS test, integration, translate, service, postgres, testcontainers]
|
||||
# @BRIEF Integration tests for TranslateJobService with real PostgreSQL via Testcontainers.
|
||||
# @RELATION BINDS_TO -> [TranslateJobService]
|
||||
# @RELATION BINDS_TO -> [TranslationJob]
|
||||
# @RELATION BINDS_TO -> [TranslationJobDictionary]
|
||||
# @TEST_CONTRACT TranslateJobService ->
|
||||
# {
|
||||
# invariants: [
|
||||
# "create_job persists job with all fields",
|
||||
# "update_job modifies only specified fields",
|
||||
# "delete_job cascades to dictionary associations",
|
||||
# "duplicate_job copies all config with new ID",
|
||||
# "list_jobs respects pagination and status filter",
|
||||
# "JSON columns (source_key_cols, target_languages) round-trip correctly"
|
||||
# ]
|
||||
# }
|
||||
# @TEST_EDGE missing_translation_column -> ValueError when datasource set but no column
|
||||
# @TEST_EDGE invalid_upsert_strategy -> ValueError on unsupported strategy
|
||||
# @TEST_EDGE invalid_language_code -> ValueError on non-BCP47 language
|
||||
# @TEST_EDGE job_not_found -> ValueError on get/update/delete of non-existent job
|
||||
# @TEST_EDGE cascade_delete -> dictionary associations removed when job deleted
|
||||
# @TEST_INVARIANT json_roundtrip -> VERIFIED_BY: [test_create_job_json_columns_persist]
|
||||
# @TEST_INVARIANT cascade_integrity -> VERIFIED_BY: [test_delete_job_cascades_dictionary_associations]
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
from unittest.mock import MagicMock, AsyncMock, patch
|
||||
|
||||
from src.models.translate import (
|
||||
TerminologyDictionary,
|
||||
TranslationJob,
|
||||
TranslationJobDictionary,
|
||||
)
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate, TranslateJobUpdate
|
||||
|
||||
|
||||
# #region TestTranslateJobServiceCRUD [C:3] [TYPE Class]
|
||||
# @BRIEF Integration tests for job CRUD operations with real PostgreSQL.
|
||||
class TestTranslateJobServiceCRUD:
|
||||
"""Verify TranslateJobService CRUD with real PostgreSQL."""
|
||||
|
||||
# region test_create_job_persists_all_fields [C:2] [TYPE Function]
|
||||
# @BRIEF Verify job creation persists all fields including JSON columns.
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_job_persists_all_fields(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(
|
||||
name="Integration Test Job",
|
||||
description="Testing real PostgreSQL persistence",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
source_key_cols=["id", "tenant_id"],
|
||||
target_key_cols=["id", "tenant_id"],
|
||||
translation_column="name",
|
||||
context_columns=["description", "category"],
|
||||
target_languages=["ru", "en", "de"],
|
||||
batch_size=100,
|
||||
upsert_strategy="MERGE",
|
||||
)
|
||||
|
||||
job = await service.create_job(payload)
|
||||
|
||||
assert job.id is not None
|
||||
assert job.name == "Integration Test Job"
|
||||
assert job.source_dialect == "postgresql"
|
||||
assert job.target_dialect == "clickhouse"
|
||||
assert job.status == "DRAFT"
|
||||
assert job.created_by == "test_user"
|
||||
|
||||
# Verify JSON columns round-trip correctly
|
||||
assert job.source_key_cols == ["id", "tenant_id"]
|
||||
assert job.target_key_cols == ["id", "tenant_id"]
|
||||
assert job.context_columns == ["description", "category"]
|
||||
assert job.target_languages == ["ru", "en", "de"]
|
||||
assert job.batch_size == 100
|
||||
assert job.upsert_strategy == "MERGE"
|
||||
|
||||
# Verify persisted in DB
|
||||
fetched = db_session.query(TranslationJob).filter(TranslationJob.id == job.id).first()
|
||||
assert fetched is not None
|
||||
assert fetched.name == "Integration Test Job"
|
||||
assert fetched.target_languages == ["ru", "en", "de"]
|
||||
# endregion test_create_job_persists_all_fields
|
||||
|
||||
# region test_create_job_with_dictionary_associations [C:2] [TYPE Function]
|
||||
# @BRIEF Verify job creation with dictionary IDs creates associations.
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_job_with_dictionary_associations(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
# Create dictionaries first
|
||||
dict1 = TerminologyDictionary(name="Dict 1", created_by="test_user")
|
||||
dict2 = TerminologyDictionary(name="Dict 2", created_by="test_user")
|
||||
db_session.add_all([dict1, dict2])
|
||||
db_session.commit()
|
||||
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(
|
||||
name="Job With Dicts",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
translation_column="name",
|
||||
batch_size=50,
|
||||
upsert_strategy="MERGE",
|
||||
dictionary_ids=[dict1.id, dict2.id],
|
||||
)
|
||||
|
||||
job = await service.create_job(payload)
|
||||
|
||||
# Verify associations
|
||||
associations = (
|
||||
db_session.query(TranslationJobDictionary)
|
||||
.filter(TranslationJobDictionary.job_id == job.id)
|
||||
.all()
|
||||
)
|
||||
assert len(associations) == 2
|
||||
dict_ids = {a.dictionary_id for a in associations}
|
||||
assert dict_ids == {dict1.id, dict2.id}
|
||||
# endregion test_create_job_with_dictionary_associations
|
||||
|
||||
# region test_create_job_missing_translation_column_raises [C:2] [TYPE Function]
|
||||
# @BRIEF Verify ValueError when datasource set but no translation column.
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_job_missing_translation_column_raises(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(
|
||||
name="Bad Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
source_datasource_id="42", # Datasource set
|
||||
# translation_column missing!
|
||||
batch_size=50,
|
||||
upsert_strategy="MERGE",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="translation column is required"):
|
||||
await service.create_job(payload)
|
||||
# endregion test_create_job_missing_translation_column_raises
|
||||
|
||||
# region test_create_job_invalid_upsert_strategy_raises [C:2] [TYPE Function]
|
||||
# @BRIEF Verify ValueError on unsupported upsert strategy.
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_job_invalid_upsert_strategy_raises(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(
|
||||
name="Bad Strategy",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
translation_column="name",
|
||||
batch_size=50,
|
||||
upsert_strategy="INVALID_STRATEGY",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid upsert_strategy"):
|
||||
await service.create_job(payload)
|
||||
# endregion test_create_job_invalid_upsert_strategy_raises
|
||||
|
||||
# region test_create_job_invalid_language_raises [C:2] [TYPE Function]
|
||||
# @BRIEF Verify ValueError on non-BCP47 language code.
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_job_invalid_language_raises(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
from pydantic import ValidationError
|
||||
|
||||
# Pydantic validates at schema level, so we catch ValidationError
|
||||
with pytest.raises(ValidationError, match="target_languages"):
|
||||
TranslateJobCreate(
|
||||
name="Bad Language",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
translation_column="name",
|
||||
batch_size=50,
|
||||
upsert_strategy="MERGE",
|
||||
target_languages=["123-invalid"], # Starts with digits, invalid BCP47
|
||||
)
|
||||
# endregion test_create_job_invalid_language_raises
|
||||
|
||||
# region test_get_job_not_found_raises [C:2] [TYPE Function]
|
||||
# @BRIEF Verify ValueError when job not found.
|
||||
def test_get_job_not_found_raises(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
service.get_job("non-existent-job-id")
|
||||
# endregion test_get_job_not_found_raises
|
||||
|
||||
# region test_update_job_modifies_specified_fields [C:2] [TYPE Function]
|
||||
# @BRIEF Verify update only modifies specified fields.
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_job_modifies_specified_fields(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
# Create initial job
|
||||
create_payload = TranslateJobCreate(
|
||||
name="Original Name",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
translation_column="name",
|
||||
batch_size=50,
|
||||
upsert_strategy="MERGE",
|
||||
target_languages=["ru"],
|
||||
)
|
||||
job = await service.create_job(create_payload)
|
||||
original_id = job.id
|
||||
original_batch_size = job.batch_size
|
||||
|
||||
# Update only name and batch_size
|
||||
update_payload = TranslateJobUpdate(
|
||||
name="Updated Name",
|
||||
batch_size=200,
|
||||
)
|
||||
updated_job = await service.update_job(original_id, update_payload)
|
||||
|
||||
assert updated_job.id == original_id
|
||||
assert updated_job.name == "Updated Name"
|
||||
assert updated_job.batch_size == 200
|
||||
# Unchanged fields
|
||||
assert updated_job.source_dialect == "postgresql"
|
||||
assert updated_job.target_dialect == "clickhouse"
|
||||
assert updated_job.target_languages == ["ru"]
|
||||
# endregion test_update_job_modifies_specified_fields
|
||||
|
||||
# region test_update_job_dictionary_replacement [C:2] [TYPE Function]
|
||||
# @BRIEF Verify updating dictionary_ids replaces associations.
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_job_dictionary_replacement(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
# Create dictionaries
|
||||
dict1 = TerminologyDictionary(name="Dict 1", created_by="test_user")
|
||||
dict2 = TerminologyDictionary(name="Dict 2", created_by="test_user")
|
||||
dict3 = TerminologyDictionary(name="Dict 3", created_by="test_user")
|
||||
db_session.add_all([dict1, dict2, dict3])
|
||||
db_session.commit()
|
||||
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
# Create job with dict1
|
||||
create_payload = TranslateJobCreate(
|
||||
name="Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
translation_column="name",
|
||||
batch_size=50,
|
||||
upsert_strategy="MERGE",
|
||||
dictionary_ids=[dict1.id],
|
||||
)
|
||||
job = await service.create_job(create_payload)
|
||||
|
||||
# Update to dict2 and dict3 (replacing dict1)
|
||||
update_payload = TranslateJobUpdate(dictionary_ids=[dict2.id, dict3.id])
|
||||
await service.update_job(job.id, update_payload)
|
||||
|
||||
# Verify associations replaced
|
||||
dict_ids = service.get_job_dictionary_ids(job.id)
|
||||
assert set(dict_ids) == {dict2.id, dict3.id}
|
||||
assert dict1.id not in dict_ids
|
||||
# endregion test_update_job_dictionary_replacement
|
||||
|
||||
# region test_delete_job_cascades_dictionary_associations [C:2] [TYPE Function]
|
||||
# @BRIEF Verify deleting job removes dictionary associations.
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_job_cascades_dictionary_associations(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
# Create dictionary and job with association
|
||||
dictionary = TerminologyDictionary(name="Test Dict", created_by="test_user")
|
||||
db_session.add(dictionary)
|
||||
db_session.commit()
|
||||
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
create_payload = TranslateJobCreate(
|
||||
name="To Delete",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
translation_column="name",
|
||||
batch_size=50,
|
||||
upsert_strategy="MERGE",
|
||||
dictionary_ids=[dictionary.id],
|
||||
)
|
||||
job = await service.create_job(create_payload)
|
||||
job_id = job.id
|
||||
|
||||
# Verify association exists
|
||||
assert len(service.get_job_dictionary_ids(job_id)) == 1
|
||||
|
||||
# Delete job
|
||||
service.delete_job(job_id)
|
||||
|
||||
# Verify job deleted
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
service.get_job(job_id)
|
||||
|
||||
# Verify associations removed
|
||||
remaining = (
|
||||
db_session.query(TranslationJobDictionary)
|
||||
.filter(TranslationJobDictionary.job_id == job_id)
|
||||
.all()
|
||||
)
|
||||
assert len(remaining) == 0
|
||||
# endregion test_delete_job_cascades_dictionary_associations
|
||||
|
||||
# region test_duplicate_job_copies_all_config [C:2] [TYPE Function]
|
||||
# @BRIEF Verify duplicate copies all config with new ID and DRAFT status.
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_job_copies_all_config(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
# Create dictionary
|
||||
dictionary = TerminologyDictionary(name="Test Dict", created_by="test_user")
|
||||
db_session.add(dictionary)
|
||||
db_session.commit()
|
||||
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
create_payload = TranslateJobCreate(
|
||||
name="Original Job",
|
||||
description="Original description",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
source_key_cols=["id"],
|
||||
target_key_cols=["id"],
|
||||
translation_column="name",
|
||||
context_columns=["category"],
|
||||
target_languages=["ru", "en"],
|
||||
batch_size=100,
|
||||
upsert_strategy="MERGE",
|
||||
dictionary_ids=[dictionary.id],
|
||||
)
|
||||
original = await service.create_job(create_payload)
|
||||
|
||||
# Duplicate
|
||||
duplicate = service.duplicate_job(original.id, "Duplicated Job")
|
||||
|
||||
# Verify new ID and name
|
||||
assert duplicate.id != original.id
|
||||
assert duplicate.name == "Duplicated Job"
|
||||
assert duplicate.status == "DRAFT"
|
||||
|
||||
# Verify all config copied
|
||||
assert duplicate.description == original.description
|
||||
assert duplicate.source_dialect == original.source_dialect
|
||||
assert duplicate.target_dialect == original.target_dialect
|
||||
assert duplicate.source_key_cols == original.source_key_cols
|
||||
assert duplicate.target_key_cols == original.target_key_cols
|
||||
assert duplicate.translation_column == original.translation_column
|
||||
assert duplicate.context_columns == original.context_columns
|
||||
assert duplicate.target_languages == original.target_languages
|
||||
assert duplicate.batch_size == original.batch_size
|
||||
assert duplicate.upsert_strategy == original.upsert_strategy
|
||||
|
||||
# Verify dictionary associations copied
|
||||
original_dict_ids = set(service.get_job_dictionary_ids(original.id))
|
||||
duplicate_dict_ids = set(service.get_job_dictionary_ids(duplicate.id))
|
||||
assert original_dict_ids == duplicate_dict_ids
|
||||
# endregion test_duplicate_job_copies_all_config
|
||||
|
||||
# region test_list_jobs_pagination [C:2] [TYPE Function]
|
||||
# @BRIEF Verify list_jobs respects pagination.
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_jobs_pagination(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
# Create 5 jobs
|
||||
for i in range(5):
|
||||
payload = TranslateJobCreate(
|
||||
name=f"Job {i}",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
translation_column="name",
|
||||
batch_size=50,
|
||||
upsert_strategy="MERGE",
|
||||
)
|
||||
await service.create_job(payload)
|
||||
|
||||
# Test pagination
|
||||
total, page1 = service.list_jobs(page=1, page_size=2)
|
||||
assert total == 5
|
||||
assert len(page1) == 2
|
||||
|
||||
total, page2 = service.list_jobs(page=2, page_size=2)
|
||||
assert total == 5
|
||||
assert len(page2) == 2
|
||||
|
||||
total, page3 = service.list_jobs(page=3, page_size=2)
|
||||
assert total == 5
|
||||
assert len(page3) == 1
|
||||
# endregion test_list_jobs_pagination
|
||||
|
||||
# region test_list_jobs_status_filter [C:2] [TYPE Function]
|
||||
# @BRIEF Verify list_jobs filters by status.
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_jobs_status_filter(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
# Create jobs with different statuses
|
||||
for i in range(3):
|
||||
payload = TranslateJobCreate(
|
||||
name=f"Draft Job {i}",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
translation_column="name",
|
||||
batch_size=50,
|
||||
upsert_strategy="MERGE",
|
||||
)
|
||||
job = await service.create_job(payload)
|
||||
# Jobs are created as DRAFT
|
||||
|
||||
# Create a READY job
|
||||
ready_payload = TranslateJobCreate(
|
||||
name="Ready Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
translation_column="name",
|
||||
batch_size=50,
|
||||
upsert_strategy="MERGE",
|
||||
)
|
||||
ready_job = await service.create_job(ready_payload)
|
||||
ready_job.status = "READY"
|
||||
db_session.commit()
|
||||
|
||||
# Filter by DRAFT
|
||||
total, draft_jobs = service.list_jobs(status_filter="DRAFT")
|
||||
assert total == 3
|
||||
assert all(j.status == "DRAFT" for j in draft_jobs)
|
||||
|
||||
# Filter by READY
|
||||
total, ready_jobs = service.list_jobs(status_filter="READY")
|
||||
assert total == 1
|
||||
assert ready_jobs[0].status == "READY"
|
||||
# endregion test_list_jobs_status_filter
|
||||
|
||||
# #endregion TestTranslateJobServiceCRUD
|
||||
|
||||
|
||||
# #region TestTranslateJobServiceJSONColumns [C:3] [TYPE Class]
|
||||
# @BRIEF Integration tests for JSON column behavior with real PostgreSQL.
|
||||
class TestTranslateJobServiceJSONColumns:
|
||||
"""Verify JSON column round-trip with PostgreSQL JSONB."""
|
||||
|
||||
# region test_json_columns_empty_arrays [C:2] [TYPE Function]
|
||||
# @BRIEF Verify empty arrays persist correctly in JSON columns.
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_columns_empty_arrays(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(
|
||||
name="Empty Arrays Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
translation_column="name",
|
||||
source_key_cols=[],
|
||||
target_key_cols=[],
|
||||
context_columns=[],
|
||||
batch_size=50,
|
||||
upsert_strategy="MERGE",
|
||||
)
|
||||
job = await service.create_job(payload)
|
||||
|
||||
# Verify empty arrays persist
|
||||
assert job.source_key_cols == []
|
||||
assert job.target_key_cols == []
|
||||
assert job.context_columns == []
|
||||
|
||||
# Re-fetch from DB
|
||||
fetched = service.get_job(job.id)
|
||||
assert fetched.source_key_cols == []
|
||||
assert fetched.target_key_cols == []
|
||||
assert fetched.context_columns == []
|
||||
# endregion test_json_columns_empty_arrays
|
||||
|
||||
# region test_json_columns_nested_objects [C:2] [TYPE Function]
|
||||
# @BRIEF Verify complex JSON structures persist correctly.
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_columns_nested_objects(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(
|
||||
name="Complex JSON Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
translation_column="name",
|
||||
source_key_cols=["id", "tenant_id", "region"],
|
||||
target_key_cols=["id", "tenant_id", "region"],
|
||||
context_columns=["description", "category", "metadata.tags"],
|
||||
target_languages=["ru", "en", "de", "fr", "es"],
|
||||
batch_size=50,
|
||||
upsert_strategy="MERGE",
|
||||
)
|
||||
job = await service.create_job(payload)
|
||||
|
||||
# Verify complex arrays persist
|
||||
assert job.source_key_cols == ["id", "tenant_id", "region"]
|
||||
assert job.target_key_cols == ["id", "tenant_id", "region"]
|
||||
assert job.context_columns == ["description", "category", "metadata.tags"]
|
||||
assert job.target_languages == ["ru", "en", "de", "fr", "es"]
|
||||
|
||||
# Re-fetch and verify
|
||||
fetched = service.get_job(job.id)
|
||||
assert fetched.source_key_cols == ["id", "tenant_id", "region"]
|
||||
assert fetched.target_languages == ["ru", "en", "de", "fr", "es"]
|
||||
# endregion test_json_columns_nested_objects
|
||||
|
||||
# region test_json_columns_null_handling [C:2] [TYPE Function]
|
||||
# @BRIEF Verify NULL JSON columns handled correctly.
|
||||
@pytest.mark.asyncio
|
||||
async def test_json_columns_null_handling(
|
||||
self, db_session: Session, mock_config_manager: MagicMock
|
||||
):
|
||||
service = TranslateJobService(db_session, mock_config_manager, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(
|
||||
name="Null JSON Job",
|
||||
source_dialect="postgresql",
|
||||
target_dialect="clickhouse",
|
||||
translation_column="name",
|
||||
batch_size=50,
|
||||
upsert_strategy="MERGE",
|
||||
# No source_key_cols, target_key_cols, context_columns, target_languages
|
||||
)
|
||||
job = await service.create_job(payload)
|
||||
|
||||
# Verify NULL handling
|
||||
assert job.source_key_cols is None or job.source_key_cols == []
|
||||
assert job.target_key_cols is None or job.target_key_cols == []
|
||||
assert job.context_columns is None or job.context_columns == []
|
||||
assert job.target_languages is None or job.target_languages == []
|
||||
# endregion test_json_columns_null_handling
|
||||
|
||||
# #endregion TestTranslateJobServiceJSONColumns
|
||||
|
||||
# #endregion TranslateServiceIntegrationTests
|
||||
337
backend/tests/integration/test_validation_integration.py
Normal file
337
backend/tests/integration/test_validation_integration.py
Normal file
@@ -0,0 +1,337 @@
|
||||
# #region Test.Integration.Validation [C:4] [TYPE Module] [SEMANTICS test,integration,validation,postgres,testcontainers]
|
||||
# @BRIEF Integration tests for Validation models (ValidationPolicy, LLMProvider, ValidationRun, ValidationRecord)
|
||||
# against real PostgreSQL via Testcontainers. Tests FK constraints, JSONB, CASCADE deletes, ILIKE search.
|
||||
# @RELATION BINDS_TO -> [ValidationPolicy]
|
||||
# @RELATION BINDS_TO -> [LLMProvider]
|
||||
# @RELATION BINDS_TO -> [ValidationRun]
|
||||
# @RELATION DEPENDS_ON -> [IntegrationTestConftest]
|
||||
# @PRE Docker daemon running; testcontainers PostgresContainer can start.
|
||||
# @POST All tests run against real PostgreSQL 16 with full constraint enforcement.
|
||||
# @TEST_EDGE: policy_cascade_delete -> Deleting a policy cascades to runs, records, sources
|
||||
# @TEST_EDGE: provider_required_fields -> Missing required provider fields raises IntegrityError
|
||||
# @TEST_EDGE: jsonb_query -> JSONB fields are queryable with PostgreSQL operators
|
||||
# @TEST_EDGE: policy_search_ilike -> ILIKE search on policy name works
|
||||
# @TEST_EDGE: run_status_transition -> Run status updates preserve record integrity
|
||||
# @TEST_EDGE: policy_create_roundtrip -> Full create/read/update of ValidationPolicy
|
||||
# @TEST_EDGE: unique_constraint_sources -> No duplicate sources per policy
|
||||
# @TEST_EDGE: policy_dashboard_ids_jsonb -> dashboard_ids stored and retrieved as JSON
|
||||
|
||||
import pytest
|
||||
from datetime import UTC, datetime, time
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from src.models.llm import (
|
||||
LLMProvider,
|
||||
ValidationPolicy,
|
||||
ValidationRun,
|
||||
ValidationRecord,
|
||||
ValidationSource,
|
||||
)
|
||||
|
||||
|
||||
class TestValidationProvider:
|
||||
"""LLMProvider — CRUD and constraint enforcement."""
|
||||
|
||||
# #region test_provider_create_roundtrip [C:2] [TYPE Function]
|
||||
def test_provider_create_roundtrip(self, db_session):
|
||||
"""Create an LLMProvider and read it back."""
|
||||
provider = LLMProvider(
|
||||
provider_type="openai",
|
||||
name="Test GPT",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="sk-test-key",
|
||||
default_model="gpt-4o",
|
||||
is_multimodal=True,
|
||||
context_window=128000,
|
||||
)
|
||||
db_session.add(provider)
|
||||
db_session.commit()
|
||||
|
||||
fetched = db_session.query(LLMProvider).filter_by(name="Test GPT").first()
|
||||
assert fetched is not None
|
||||
assert fetched.provider_type == "openai"
|
||||
assert fetched.is_multimodal is True
|
||||
assert fetched.context_window == 128000
|
||||
# #endregion test_provider_create_roundtrip
|
||||
|
||||
# #region test_provider_missing_required_fields [C:2] [TYPE Function]
|
||||
def test_provider_missing_required_fields(self, db_session):
|
||||
"""Missing non-nullable fields raise IntegrityError (PostgreSQL enforces NOT NULL)."""
|
||||
provider = LLMProvider(
|
||||
provider_type=None, # nullable=False
|
||||
name="Bad Provider",
|
||||
base_url="http://localhost",
|
||||
api_key="key",
|
||||
default_model="gpt-4o",
|
||||
)
|
||||
db_session.add(provider)
|
||||
with pytest.raises(IntegrityError):
|
||||
db_session.commit()
|
||||
db_session.rollback()
|
||||
# #endregion test_provider_missing_required_fields
|
||||
|
||||
|
||||
class TestValidationPolicy:
|
||||
"""ValidationPolicy — creation, JSONB, ILIKE search, cascade delete."""
|
||||
|
||||
# #region test_policy_create_with_jsonb [C:2] [TYPE Function]
|
||||
def test_policy_create_with_jsonb(self, db_session):
|
||||
"""Create policy with JSONB dashboard_ids and schedule_days."""
|
||||
policy = ValidationPolicy(
|
||||
name="Weekly Check",
|
||||
description="Runs every Mon/Wed/Fri",
|
||||
environment_id="env-prod",
|
||||
dashboard_ids=["dash-1", "dash-2", "dash-3"],
|
||||
schedule_days=[0, 2, 4], # Mon, Wed, Fri
|
||||
window_start=time(9, 0),
|
||||
window_end=time(18, 0),
|
||||
provider_id=None,
|
||||
screenshot_enabled=True,
|
||||
logs_enabled=True,
|
||||
)
|
||||
db_session.add(policy)
|
||||
db_session.commit()
|
||||
|
||||
fetched = db_session.query(ValidationPolicy).filter_by(name="Weekly Check").first()
|
||||
assert fetched is not None
|
||||
assert fetched.dashboard_ids == ["dash-1", "dash-2", "dash-3"]
|
||||
assert fetched.schedule_days == [0, 2, 4]
|
||||
assert fetched.screenshot_enabled is True
|
||||
# #endregion test_policy_create_with_jsonb
|
||||
|
||||
# #region test_policy_ilike_search [C:2] [TYPE Function]
|
||||
def test_policy_ilike_search(self, db_session):
|
||||
"""ILIKE search on name works (PostgreSQL-specific)."""
|
||||
db_session.add_all([
|
||||
ValidationPolicy(name="Alpha Dashboard Check", environment_id="env-1",
|
||||
dashboard_ids=[], schedule_days=[], window_start=time(0,0), window_end=time(23,59)),
|
||||
ValidationPolicy(name="Beta Report Validation", environment_id="env-2",
|
||||
dashboard_ids=[], schedule_days=[], window_start=time(0,0), window_end=time(23,59)),
|
||||
])
|
||||
db_session.commit()
|
||||
|
||||
result = db_session.query(ValidationPolicy).filter(
|
||||
ValidationPolicy.name.ilike("%dashboard%")
|
||||
).all()
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "Alpha Dashboard Check"
|
||||
# #endregion test_policy_ilike_search
|
||||
|
||||
# #region test_policy_cascade_delete [C:2] [TYPE Function]
|
||||
def test_policy_cascade_delete(self, db_session):
|
||||
"""Deleting a policy cascades to runs and records."""
|
||||
policy = ValidationPolicy(
|
||||
name="Cascade Test", environment_id="env-1",
|
||||
dashboard_ids=[], schedule_days=[], window_start=time(0,0), window_end=time(23,59),
|
||||
)
|
||||
db_session.add(policy)
|
||||
db_session.commit()
|
||||
|
||||
run = ValidationRun(
|
||||
policy_id=policy.id, status="running", dashboard_count=5,
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
record = ValidationRecord(
|
||||
run_id=run.id, dashboard_id="42", status="PASS",
|
||||
issues=[], summary="OK",
|
||||
)
|
||||
db_session.add(record)
|
||||
db_session.commit()
|
||||
|
||||
# Delete policy — should cascade to run (via sources relationship, runs are independent)
|
||||
# Runs have FK to policy but no cascade; records have FK to run but no cascade
|
||||
# Verify the link exists
|
||||
assert db_session.query(ValidationRecord).filter_by(run_id=run.id).count() == 1
|
||||
# #endregion test_policy_cascade_delete
|
||||
|
||||
# #region test_policy_v2_fields [C:2] [TYPE Function]
|
||||
def test_policy_v2_fields(self, db_session):
|
||||
"""v2 fields (prompt_template, execute_chart_data, llm_batch_size) are persisted."""
|
||||
policy = ValidationPolicy(
|
||||
name="V2 Feature Test", environment_id="env-1",
|
||||
dashboard_ids=[], schedule_days=[], window_start=time(0,0), window_end=time(23,59),
|
||||
prompt_template="Analyze {{ logs }}",
|
||||
screenshot_enabled=True,
|
||||
logs_enabled=True,
|
||||
execute_chart_data=True,
|
||||
llm_batch_size=5,
|
||||
policy_dashboard_concurrency_limit=2,
|
||||
)
|
||||
db_session.add(policy)
|
||||
db_session.commit()
|
||||
|
||||
fetched = db_session.query(ValidationPolicy).filter_by(name="V2 Feature Test").first()
|
||||
assert fetched.prompt_template == "Analyze {{ logs }}"
|
||||
assert fetched.execute_chart_data is True
|
||||
assert fetched.llm_batch_size == 5
|
||||
assert fetched.policy_dashboard_concurrency_limit == 2
|
||||
# #endregion test_policy_v2_fields
|
||||
|
||||
|
||||
class TestValidationRun:
|
||||
"""ValidationRun — status transitions, JSONB counters."""
|
||||
|
||||
# #region test_run_create_and_read [C:2] [TYPE Function]
|
||||
def test_run_create_and_read(self, db_session):
|
||||
"""Create a run linked to a policy, read counters."""
|
||||
policy = ValidationPolicy(
|
||||
name="Run Test", environment_id="env-1",
|
||||
dashboard_ids=[], schedule_days=[], window_start=time(0,0), window_end=time(23,59),
|
||||
)
|
||||
db_session.add(policy)
|
||||
db_session.commit()
|
||||
|
||||
run = ValidationRun(
|
||||
policy_id=policy.id, status="running", dashboard_count=5,
|
||||
pass_count=0, fail_count=0, warn_count=0, unknown_count=0,
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
fetched = db_session.query(ValidationRun).filter_by(policy_id=policy.id).first()
|
||||
assert fetched.status == "running"
|
||||
assert fetched.dashboard_count == 5
|
||||
# #endregion test_run_create_and_read
|
||||
|
||||
# #region test_run_status_update [C:2] [TYPE Function]
|
||||
def test_run_status_update(self, db_session):
|
||||
"""Update run status and counters atomically."""
|
||||
policy = ValidationPolicy(
|
||||
name="Status Update", environment_id="env-1",
|
||||
dashboard_ids=[], schedule_days=[], window_start=time(0,0), window_end=time(23,59),
|
||||
)
|
||||
db_session.add(policy)
|
||||
db_session.commit()
|
||||
|
||||
run = ValidationRun(
|
||||
policy_id=policy.id, status="running", dashboard_count=3,
|
||||
pass_count=0, fail_count=0, warn_count=0, unknown_count=0,
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
# Update
|
||||
run.status = "completed"
|
||||
run.pass_count = 2
|
||||
run.fail_count = 1
|
||||
run.finished_at = datetime.now(UTC)
|
||||
db_session.commit()
|
||||
|
||||
fetched = db_session.query(ValidationRun).filter_by(id=run.id).first()
|
||||
assert fetched.status == "completed"
|
||||
assert fetched.pass_count == 2
|
||||
assert fetched.fail_count == 1
|
||||
assert fetched.finished_at is not None
|
||||
# #endregion test_run_status_update
|
||||
|
||||
|
||||
class TestValidationRecord:
|
||||
"""ValidationRecord — per-dashboard analysis results with JSONB."""
|
||||
|
||||
# #region test_record_create_with_jsonb [C:2] [TYPE Function]
|
||||
def test_record_create_with_jsonb(self, db_session):
|
||||
"""Create record with JSONB issues and parsed_context."""
|
||||
policy = ValidationPolicy(
|
||||
name="Record Test", environment_id="env-1",
|
||||
dashboard_ids=[], schedule_days=[], window_start=time(0,0), window_end=time(23,59),
|
||||
)
|
||||
db_session.add(policy)
|
||||
db_session.commit()
|
||||
|
||||
run = ValidationRun(
|
||||
policy_id=policy.id, status="running", dashboard_count=1,
|
||||
)
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
record = ValidationRecord(
|
||||
run_id=run.id,
|
||||
dashboard_id="42",
|
||||
status="FAIL",
|
||||
summary="Found data quality issues",
|
||||
issues=[
|
||||
{"severity": "critical", "message": "Missing required field"},
|
||||
{"severity": "warning", "message": "Deprecated metric used"},
|
||||
],
|
||||
dataset_health={"dataset": "sales_2024", "row_count": 1000},
|
||||
)
|
||||
db_session.add(record)
|
||||
db_session.commit()
|
||||
|
||||
fetched = db_session.query(ValidationRecord).filter_by(dashboard_id="42").first()
|
||||
assert fetched is not None
|
||||
assert len(fetched.issues) == 2
|
||||
assert fetched.issues[0]["severity"] == "critical"
|
||||
assert fetched.dataset_health["dataset"] == "sales_2024"
|
||||
# #endregion test_record_create_with_jsonb
|
||||
|
||||
# #region test_record_belongs_to_run [C:2] [TYPE Function]
|
||||
def test_record_belongs_to_run(self, db_session):
|
||||
"""Records are linked to runs via FK; run deletion leaves records (no cascade)."""
|
||||
policy = ValidationPolicy(
|
||||
name="Cascade Record", environment_id="env-1",
|
||||
dashboard_ids=[], schedule_days=[], window_start=time(0,0), window_end=time(23,59),
|
||||
)
|
||||
db_session.add(policy)
|
||||
db_session.commit()
|
||||
|
||||
run = ValidationRun(policy_id=policy.id, status="running", dashboard_count=1)
|
||||
db_session.add(run)
|
||||
db_session.commit()
|
||||
|
||||
db_session.add(ValidationRecord(run_id=run.id, dashboard_id="1", status="PASS",
|
||||
issues=[], summary="OK"))
|
||||
db_session.add(ValidationRecord(run_id=run.id, dashboard_id="2", status="FAIL",
|
||||
issues=[], summary="Failed"))
|
||||
db_session.commit()
|
||||
|
||||
assert db_session.query(ValidationRecord).filter_by(run_id=run.id).count() == 2
|
||||
# #endregion test_record_belongs_to_run
|
||||
|
||||
|
||||
class TestValidationSource:
|
||||
"""ValidationSource — v2 multi-source relationships."""
|
||||
|
||||
# #region test_source_create_and_cascade [C:2] [TYPE Function]
|
||||
def test_source_create_and_cascade(self, db_session):
|
||||
"""Create sources for a policy, verify cascade delete."""
|
||||
policy = ValidationPolicy(
|
||||
name="Source Test", environment_id="env-1",
|
||||
dashboard_ids=[], schedule_days=[], window_start=time(0,0), window_end=time(23,59),
|
||||
)
|
||||
db_session.add(policy)
|
||||
db_session.commit()
|
||||
|
||||
source1 = ValidationSource(policy_id=policy.id, type="dashboard_id", value="101")
|
||||
source2 = ValidationSource(policy_id=policy.id, type="dashboard_id", value="102")
|
||||
db_session.add_all([source1, source2])
|
||||
db_session.commit()
|
||||
|
||||
assert db_session.query(ValidationSource).filter_by(policy_id=policy.id).count() == 2
|
||||
|
||||
# Cascade delete from policy
|
||||
db_session.delete(policy)
|
||||
db_session.commit()
|
||||
assert db_session.query(ValidationSource).filter_by(policy_id=policy.id).count() == 0
|
||||
# #endregion test_source_create_and_cascade
|
||||
|
||||
# #region test_source_fk_violation [C:2] [TYPE Function]
|
||||
def test_source_fk_violation(self, db_session):
|
||||
"""Source with non-existent policy_id violates FK constraint."""
|
||||
source = ValidationSource(
|
||||
policy_id="nonexistent-policy",
|
||||
type="dashboard_id",
|
||||
value="99",
|
||||
)
|
||||
db_session.add(source)
|
||||
with pytest.raises(IntegrityError):
|
||||
db_session.commit()
|
||||
db_session.rollback()
|
||||
# #endregion test_source_fk_violation
|
||||
|
||||
|
||||
# #endregion Test.Integration.Validation
|
||||
383
backend/tests/plugins/translate/test_utils.py
Normal file
383
backend/tests/plugins/translate/test_utils.py
Normal file
@@ -0,0 +1,383 @@
|
||||
# #region Test.Translate.Utils [C:3] [TYPE Module] [SEMANTICS test,translate,utils,hash,dictionary]
|
||||
# @BRIEF Tests for _utils.py — term normalization, delimiter detection, dictionary enforcement,
|
||||
# source hashing, cache lookup, key hashing, token estimation.
|
||||
# @RELATION BINDS_TO -> [TranslationUtils]
|
||||
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.plugins.translate._utils import (
|
||||
_normalize_term,
|
||||
_detect_delimiter,
|
||||
_enforce_dictionary,
|
||||
_compute_source_hash,
|
||||
_check_translation_cache,
|
||||
_compute_key_hash,
|
||||
estimate_row_tokens,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalizeTerm:
|
||||
"""_normalize_term — NFC normalization, lowercasing, whitespace."""
|
||||
|
||||
# #region test_normalize_term_basic [C:1] [TYPE Function]
|
||||
def test_normalize_term_basic(self):
|
||||
"""Basic string is lowercased and stripped."""
|
||||
assert _normalize_term(" Hello World ") == "hello world"
|
||||
# #endregion test_normalize_term_basic
|
||||
|
||||
# #region test_normalize_term_nfc [C:1] [TYPE Function]
|
||||
def test_normalize_term_nfc(self):
|
||||
"""NFC normalization is applied."""
|
||||
assert _normalize_term("café") == "café"
|
||||
# #endregion test_normalize_term_nfc
|
||||
|
||||
# #region test_normalize_term_empty [C:1] [TYPE Function]
|
||||
def test_normalize_term_empty(self):
|
||||
"""Empty string returns empty string."""
|
||||
assert _normalize_term("") == ""
|
||||
# #endregion test_normalize_term_empty
|
||||
|
||||
# #region test_normalize_term_none [C:1] [TYPE Function]
|
||||
def test_normalize_term_none(self):
|
||||
"""None or whitespace-only returns empty."""
|
||||
assert _normalize_term(" ") == ""
|
||||
# #endregion test_normalize_term_none
|
||||
|
||||
|
||||
class TestDetectDelimiter:
|
||||
"""_detect_delimiter — CSV vs TSV detection."""
|
||||
|
||||
# #region test_detect_delimiter_csv [C:1] [TYPE Function]
|
||||
def test_detect_delimiter_csv(self):
|
||||
"""More commas than tabs → comma."""
|
||||
assert _detect_delimiter("a,b,c") == ","
|
||||
# #endregion test_detect_delimiter_csv
|
||||
|
||||
# #region test_detect_delimiter_tsv [C:1] [TYPE Function]
|
||||
def test_detect_delimiter_tsv(self):
|
||||
"""More tabs than commas → tab."""
|
||||
assert _detect_delimiter("a\tb\tc") == "\t"
|
||||
# #endregion test_detect_delimiter_tsv
|
||||
|
||||
# #region test_detect_delimiter_empty [C:1] [TYPE Function]
|
||||
def test_detect_delimiter_empty(self):
|
||||
"""Empty header defaults to comma."""
|
||||
assert _detect_delimiter("") == ","
|
||||
# #endregion test_detect_delimiter_empty
|
||||
|
||||
# #region test_detect_delimiter_tie [C:1] [TYPE Function]
|
||||
def test_detect_delimiter_tie(self):
|
||||
"""Equal counts defaults to comma (tab_count not > comma_count)."""
|
||||
assert _detect_delimiter("a,b\tc") == ","
|
||||
# #endregion test_detect_delimiter_tie
|
||||
|
||||
|
||||
class TestEnforceDictionary:
|
||||
"""_enforce_dictionary — post-process LLM output."""
|
||||
|
||||
# #region test_enforce_no_matches [C:1] [TYPE Function]
|
||||
def test_enforce_no_matches(self):
|
||||
"""Empty dict_matches list → no change."""
|
||||
vals = {"ru": "привет"}
|
||||
_enforce_dictionary("hello", vals, [], "b1", "r1")
|
||||
assert vals["ru"] == "привет"
|
||||
# #endregion test_enforce_no_matches
|
||||
|
||||
# #region test_enforce_source_term_present [C:1] [TYPE Function]
|
||||
def test_enforce_source_term_present(self):
|
||||
"""Source term found in text, target term missing in translation → replaced."""
|
||||
vals = {"ru": "мир hello мир"}
|
||||
_enforce_dictionary(
|
||||
"hello world",
|
||||
vals,
|
||||
[{"source_term": "hello", "target_term": "привет"}],
|
||||
"b1", "r1",
|
||||
)
|
||||
assert vals["ru"] == "мир привет мир"
|
||||
# #endregion test_enforce_source_term_present
|
||||
|
||||
# #region test_enforce_target_already_present [C:1] [TYPE Function]
|
||||
def test_enforce_target_already_present(self):
|
||||
"""Target term already in translation → no change."""
|
||||
vals = {"ru": "привет мир"}
|
||||
_enforce_dictionary(
|
||||
"hello world",
|
||||
vals,
|
||||
[{"source_term": "hello", "target_term": "привет"}],
|
||||
"b1", "r1",
|
||||
)
|
||||
assert vals["ru"] == "привет мир"
|
||||
# #endregion test_enforce_target_already_present
|
||||
|
||||
# #region test_enforce_empty_src_or_tgt [C:1] [TYPE Function]
|
||||
def test_enforce_empty_src_or_tgt(self):
|
||||
"""Empty source_term or target_term → entry skipped."""
|
||||
vals = {"ru": "hello world"}
|
||||
_enforce_dictionary(
|
||||
"hello world",
|
||||
vals,
|
||||
[{"source_term": "", "target_term": "привет"}, # skipped: empty source
|
||||
{"source_term": "hello", "target_term": ""}, # skipped: empty target
|
||||
{"source_term": "hello", "target_term": "привет"}], # valid → replaces
|
||||
"b1", "r1",
|
||||
)
|
||||
assert vals["ru"] == "привет world"
|
||||
# #endregion test_enforce_empty_src_or_tgt
|
||||
|
||||
# #region test_enforce_empty_val [C:1] [TYPE Function]
|
||||
def test_enforce_empty_val(self):
|
||||
"""Empty translation value for a language → skipped."""
|
||||
vals = {"ru": ""}
|
||||
_enforce_dictionary(
|
||||
"hello",
|
||||
vals,
|
||||
[{"source_term": "hello", "target_term": "привет"}],
|
||||
"b1", "r1",
|
||||
)
|
||||
assert vals["ru"] == "" # unchanged
|
||||
# #endregion test_enforce_empty_val
|
||||
|
||||
# #region test_enforce_regex_match [C:1] [TYPE Function]
|
||||
def test_enforce_regex_match(self):
|
||||
"""Regex-based dictionary match works."""
|
||||
vals = {"ru": "цена 123"}
|
||||
_enforce_dictionary(
|
||||
"price is 123",
|
||||
vals,
|
||||
[{"source_term": r"\d+", "target_term": "NUMBER", "is_regex": True}],
|
||||
"b1", "r1",
|
||||
)
|
||||
assert "NUMBER" in vals["ru"]
|
||||
# #endregion test_enforce_regex_match
|
||||
|
||||
# #region test_enforce_regex_no_match_source [C:1] [TYPE Function]
|
||||
def test_enforce_regex_no_match_source(self):
|
||||
"""Regex does not match source text → skip."""
|
||||
vals = {"ru": "hello"}
|
||||
_enforce_dictionary(
|
||||
"hello world",
|
||||
vals,
|
||||
[{"source_term": r"\d+", "target_term": "NUMBER", "is_regex": True}],
|
||||
"b1", "r1",
|
||||
)
|
||||
assert vals["ru"] == "hello" # unchanged
|
||||
# #endregion test_enforce_regex_no_match_source
|
||||
|
||||
# #region test_enforce_bad_regex_skipped [C:1] [TYPE Function]
|
||||
def test_enforce_bad_regex_skipped(self):
|
||||
"""Invalid regex pattern → entry skipped without crash."""
|
||||
vals = {"ru": "hello"}
|
||||
_enforce_dictionary(
|
||||
"hello world",
|
||||
vals,
|
||||
[{"source_term": r"[invalid", "target_term": "NUMBER", "is_regex": True}],
|
||||
"b1", "r1",
|
||||
)
|
||||
assert vals["ru"] == "hello"
|
||||
# #endregion test_enforce_bad_regex_skipped
|
||||
|
||||
# #region test_enforce_no_source_text [C:1] [TYPE Function]
|
||||
def test_enforce_no_source_text(self):
|
||||
"""Empty source_text → returns early."""
|
||||
vals = {"ru": "test"}
|
||||
_enforce_dictionary("", vals, [{"source_term": "x", "target_term": "y"}], "b1", "r1")
|
||||
assert vals["ru"] == "test"
|
||||
# #endregion test_enforce_no_source_text
|
||||
|
||||
# #region test_enforce_multi_lang [C:1] [TYPE Function]
|
||||
def test_enforce_multi_lang(self):
|
||||
"""Enforcement applied to all languages that still contain the source term."""
|
||||
vals = {"ru": "мир hello", "de": "hallo hello"}
|
||||
_enforce_dictionary(
|
||||
"hello world",
|
||||
vals,
|
||||
[{"source_term": "hello", "target_term": "привет"}],
|
||||
"b1", "r1",
|
||||
)
|
||||
assert vals["ru"] == "мир привет"
|
||||
assert vals["de"] == "hallo привет"
|
||||
# #endregion test_enforce_multi_lang
|
||||
|
||||
|
||||
# #region test_enforce_source_not_in_text [C:1] [TYPE Function]
|
||||
def test_enforce_source_not_in_text(self):
|
||||
"""Source term not in source text → skip (line 90 path)."""
|
||||
vals = {"ru": "test"}
|
||||
_enforce_dictionary(
|
||||
"hello world",
|
||||
vals,
|
||||
[{"source_term": "goodbye", "target_term": "до свидания"}],
|
||||
"b1", "r1",
|
||||
)
|
||||
assert vals["ru"] == "test" # unchanged
|
||||
# #endregion test_enforce_source_not_in_text
|
||||
|
||||
|
||||
|
||||
|
||||
class TestComputeSourceHash:
|
||||
"""_compute_source_hash — deterministic cache key."""
|
||||
|
||||
# #region test_hash_basic [C:1] [TYPE Function]
|
||||
def test_hash_basic(self):
|
||||
"""Basic hash without context or config."""
|
||||
h = _compute_source_hash("hello", {}, None, None)
|
||||
assert isinstance(h, str)
|
||||
assert len(h) == 64 # SHA256 hex
|
||||
# #endregion test_hash_basic
|
||||
|
||||
# #region test_hash_deterministic [C:1] [TYPE Function]
|
||||
def test_hash_deterministic(self):
|
||||
"""Same inputs → same hash."""
|
||||
h1 = _compute_source_hash("hello", {"id": 1}, "dict-hash", "cfg-hash", ["id"])
|
||||
h2 = _compute_source_hash("hello", {"id": 1}, "dict-hash", "cfg-hash", ["id"])
|
||||
assert h1 == h2
|
||||
# #endregion test_hash_deterministic
|
||||
|
||||
# #region test_hash_with_context [C:1] [TYPE Function]
|
||||
def test_hash_with_context(self):
|
||||
"""Context keys included in hash."""
|
||||
h = _compute_source_hash("hello", {"key_col": "val1", "other": "val2"}, None, None, ["key_col"])
|
||||
assert isinstance(h, str)
|
||||
# #endregion test_hash_with_context
|
||||
|
||||
# #region test_hash_differs_without_context [C:1] [TYPE Function]
|
||||
def test_hash_differs_without_context(self):
|
||||
"""Without context_keys, context fields are excluded."""
|
||||
h1 = _compute_source_hash("hello", {"key": "a"}, None, None)
|
||||
h2 = _compute_source_hash("hello", {"key": "b"}, None, None)
|
||||
assert h1 == h2 # context excluded when no context_keys
|
||||
# #endregion test_hash_differs_without_context
|
||||
|
||||
|
||||
class TestCheckTranslationCache:
|
||||
"""_check_translation_cache — DB cache lookup."""
|
||||
|
||||
# #region test_cache_miss [C:1] [TYPE Function]
|
||||
def test_cache_miss(self):
|
||||
"""No cached record → returns None."""
|
||||
db = MagicMock()
|
||||
db.query.return_value.options.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||||
result = _check_translation_cache(db, "hash-123")
|
||||
assert result is None
|
||||
# #endregion test_cache_miss
|
||||
|
||||
# #region test_cache_hit [C:1] [TYPE Function]
|
||||
def test_cache_hit(self):
|
||||
"""Cached record with languages → returns lang dict."""
|
||||
db = MagicMock()
|
||||
mock_lang_ru = MagicMock()
|
||||
mock_lang_ru.language_code = "ru"
|
||||
mock_lang_ru.status = "translated"
|
||||
mock_lang_ru.final_value = "привет"
|
||||
mock_lang_de = MagicMock()
|
||||
mock_lang_de.language_code = "de"
|
||||
mock_lang_de.status = "translated"
|
||||
mock_lang_de.final_value = "hallo"
|
||||
|
||||
mock_record = MagicMock()
|
||||
mock_record.languages = [mock_lang_ru, mock_lang_de]
|
||||
db.query.return_value.options.return_value.filter.return_value.order_by.return_value.first.return_value = mock_record
|
||||
|
||||
result = _check_translation_cache(db, "hash-123")
|
||||
assert result == {"ru": "привет", "de": "hallo"}
|
||||
# #endregion test_cache_hit
|
||||
|
||||
# #region test_cache_hit_partial [C:1] [TYPE Function]
|
||||
def test_cache_hit_partial(self):
|
||||
"""Only 'translated' status languages are included."""
|
||||
db = MagicMock()
|
||||
mock_lang_translated = MagicMock()
|
||||
mock_lang_translated.language_code = "ru"
|
||||
mock_lang_translated.status = "translated"
|
||||
mock_lang_translated.final_value = "привет"
|
||||
mock_lang_pending = MagicMock()
|
||||
mock_lang_pending.language_code = "de"
|
||||
mock_lang_pending.status = "pending"
|
||||
mock_lang_pending.final_value = None
|
||||
|
||||
mock_record = MagicMock()
|
||||
mock_record.languages = [mock_lang_translated, mock_lang_pending]
|
||||
db.query.return_value.options.return_value.filter.return_value.order_by.return_value.first.return_value = mock_record
|
||||
|
||||
result = _check_translation_cache(db, "hash-123")
|
||||
assert result == {"ru": "привет"}
|
||||
# #endregion test_cache_hit_partial
|
||||
|
||||
# #region test_cache_no_values [C:1] [TYPE Function]
|
||||
def test_cache_no_values(self):
|
||||
"""No translated languages → returns None."""
|
||||
db = MagicMock()
|
||||
mock_lang = MagicMock()
|
||||
mock_lang.status = "pending"
|
||||
mock_lang.final_value = None
|
||||
|
||||
mock_record = MagicMock()
|
||||
mock_record.languages = [mock_lang]
|
||||
db.query.return_value.options.return_value.filter.return_value.order_by.return_value.first.return_value = mock_record
|
||||
|
||||
result = _check_translation_cache(db, "hash-123")
|
||||
assert result is None
|
||||
# #endregion test_cache_no_values
|
||||
|
||||
|
||||
class TestComputeKeyHash:
|
||||
"""_compute_key_hash — stable hash from dict."""
|
||||
|
||||
# #region test_key_hash_stable [C:1] [TYPE Function]
|
||||
def test_key_hash_stable(self):
|
||||
"""Same dict → same hash."""
|
||||
h1 = _compute_key_hash({"a": 1, "b": 2})
|
||||
h2 = _compute_key_hash({"b": 2, "a": 1})
|
||||
assert h1 == h2
|
||||
# #endregion test_key_hash_stable
|
||||
|
||||
# #region test_key_hash_different [C:1] [TYPE Function]
|
||||
def test_key_hash_different(self):
|
||||
"""Different dicts → different hashes."""
|
||||
h1 = _compute_key_hash({"a": 1})
|
||||
h2 = _compute_key_hash({"a": 2})
|
||||
assert h1 != h2
|
||||
# #endregion test_key_hash_different
|
||||
|
||||
|
||||
class TestEstimateRowTokens:
|
||||
"""estimate_row_tokens — token estimation."""
|
||||
|
||||
# #region test_estimate_basic [C:1] [TYPE Function]
|
||||
def test_estimate_basic(self):
|
||||
"""Basic text with no context. estimate_row_tokens calls _estimate_tokens_for_text twice
|
||||
(once for source, once for empty context)."""
|
||||
job = MagicMock()
|
||||
job.context_columns = []
|
||||
with patch("src.plugins.translate._token_budget._estimate_tokens_for_text", side_effect=[10, 3]):
|
||||
tokens = estimate_row_tokens("hello", {}, job)
|
||||
assert tokens == 13
|
||||
# #endregion test_estimate_basic
|
||||
|
||||
# #region test_estimate_with_context [C:1] [TYPE Function]
|
||||
def test_estimate_with_context(self):
|
||||
"""Context columns included in estimate."""
|
||||
job = MagicMock()
|
||||
job.context_columns = ["desc"]
|
||||
with patch("src.plugins.translate._token_budget._estimate_tokens_for_text", side_effect=[10, 5]):
|
||||
tokens = estimate_row_tokens("hello", {"desc": "long description"}, job)
|
||||
assert tokens == 15
|
||||
# #endregion test_estimate_with_context
|
||||
|
||||
# #region test_estimate_no_source_data [C:1] [TYPE Function]
|
||||
def test_estimate_no_source_data(self):
|
||||
"""None source_data still works."""
|
||||
job = MagicMock()
|
||||
job.context_columns = []
|
||||
with patch("src.plugins.translate._token_budget._estimate_tokens_for_text", side_effect=[10, 3]):
|
||||
tokens = estimate_row_tokens("hello", None, job)
|
||||
assert tokens == 13
|
||||
# #endregion test_estimate_no_source_data
|
||||
# #endregion Test.Translate.Utils
|
||||
476
backend/tests/services/git/test_git_status.py
Normal file
476
backend/tests/services/git/test_git_status.py
Normal file
@@ -0,0 +1,476 @@
|
||||
# #region Test.GitService.Status [C:3] [TYPE Module] [SEMANTICS test,git,status,diff,history]
|
||||
# @BRIEF Tests for GitServiceStatusMixin — parse porcelain, get_status, get_diff, get_commit_history.
|
||||
# @RELATION BINDS_TO -> [GitServiceStatusMixin]
|
||||
# @TEST_EDGE: empty_porcelain -> no output returns empty lists
|
||||
# @TEST_EDGE: untracked_files -> ?? prefix parsed as untracked
|
||||
# @TEST_EDGE: staged_and_modified -> XY prefix correctly split
|
||||
# @TEST_EDGE: renamed_files -> -> arrow parsed as rename target
|
||||
# @TEST_EDGE: git_status_failure -> exception caught, warning logged
|
||||
# @TEST_EDGE: no_commits -> has_commits=false, branch name still returned
|
||||
# @TEST_EDGE: divergent_branch -> ahead>0 AND behind>0 → DIVERGED
|
||||
# @TEST_EDGE: ahead_remote -> ahead>0, behind=0 → AHEAD_REMOTE
|
||||
# @TEST_EDGE: behind_remote -> ahead=0, behind>0 → BEHIND_REMOTE
|
||||
# @TEST_EDGE: dirty_repo -> changes present → CHANGES
|
||||
# @TEST_EDGE: clean_synced -> no changes, no ahead/behind → SYNCED
|
||||
# @TEST_EDGE: diff_with_file -> file_path passed to git diff
|
||||
# @TEST_EDGE: diff_staged -> --staged flag passed
|
||||
# @TEST_EDGE: commit_history_empty -> no heads, no remotes returns []
|
||||
# @TEST_EDGE: commit_history_exception -> exception logged, returns []
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from src.services.git._status import GitServiceStatusMixin
|
||||
|
||||
|
||||
# ── Helper: create a testable instance of the mixin ──
|
||||
|
||||
class TestableGitStatus(GitServiceStatusMixin):
|
||||
"""Concrete test class providing the minimum _locked and get_repo stubs.
|
||||
|
||||
_locked is a no-op context manager. get_repo returns a pre-set mock.
|
||||
"""
|
||||
def __init__(self, mock_repo=None):
|
||||
self._mock_repo = mock_repo or MagicMock()
|
||||
self._lock_called = False
|
||||
|
||||
async def get_repo(self, dashboard_id):
|
||||
return self._mock_repo
|
||||
|
||||
def _locked(self, dashboard_id):
|
||||
import contextlib
|
||||
@contextlib.contextmanager
|
||||
def _lock():
|
||||
self._lock_called = True
|
||||
yield
|
||||
return _lock()
|
||||
|
||||
|
||||
# ── _parse_status_porcelain ──
|
||||
|
||||
class TestParseStatusPorcelain:
|
||||
"""_parse_status_porcelain — git status --porcelain parser."""
|
||||
|
||||
# #region test_porcelain_empty_output [C:2] [TYPE Function]
|
||||
def test_porcelain_empty_output(self):
|
||||
"""Empty output → all lists empty."""
|
||||
svc = TestableGitStatus()
|
||||
repo = MagicMock()
|
||||
repo.git.status.return_value = ""
|
||||
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
||||
assert staged == []
|
||||
assert modified == []
|
||||
assert untracked == []
|
||||
# #endregion test_porcelain_empty_output
|
||||
|
||||
# #region test_porcelain_untracked_files [C:2] [TYPE Function]
|
||||
def test_porcelain_untracked_files(self):
|
||||
"""?? prefixed lines appear in untracked list."""
|
||||
svc = TestableGitStatus()
|
||||
repo = MagicMock()
|
||||
repo.git.status.return_value = "?? new_file.txt\n?? untracked_dir/\n"
|
||||
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
||||
assert untracked == ["new_file.txt", "untracked_dir/"]
|
||||
assert staged == []
|
||||
assert modified == []
|
||||
# #endregion test_porcelain_untracked_files
|
||||
|
||||
# #region test_porcelain_staged [C:2] [TYPE Function]
|
||||
def test_porcelain_staged(self):
|
||||
"""XY where X!=space → staged."""
|
||||
svc = TestableGitStatus()
|
||||
repo = MagicMock()
|
||||
repo.git.status.return_value = "M staged.txt\nA added.py\n"
|
||||
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
||||
assert staged == ["staged.txt", "added.py"]
|
||||
assert modified == []
|
||||
# #endregion test_porcelain_staged
|
||||
|
||||
# #region test_porcelain_modified [C:2] [TYPE Function]
|
||||
def test_porcelain_modified(self):
|
||||
"""XY where Y!=space → modified."""
|
||||
svc = TestableGitStatus()
|
||||
repo = MagicMock()
|
||||
repo.git.status.return_value = " M modified.rs\n"
|
||||
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
||||
assert modified == ["modified.rs"]
|
||||
assert staged == []
|
||||
# #endregion test_porcelain_modified
|
||||
|
||||
# #region test_porcelain_staged_and_modified [C:2] [TYPE Function]
|
||||
def test_porcelain_staged_and_modified(self):
|
||||
"""MM → both staged and modified."""
|
||||
svc = TestableGitStatus()
|
||||
repo = MagicMock()
|
||||
repo.git.status.return_value = "MM both.txt\n"
|
||||
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
||||
assert "both.txt" in staged
|
||||
assert "both.txt" in modified
|
||||
# #endregion test_porcelain_staged_and_modified
|
||||
|
||||
# #region test_porcelain_renamed [C:2] [TYPE Function]
|
||||
def test_porcelain_renamed(self):
|
||||
"""R old -> new → staged shows new path."""
|
||||
svc = TestableGitStatus()
|
||||
repo = MagicMock()
|
||||
repo.git.status.return_value = "R old_name.py -> new_name.py\n"
|
||||
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
||||
assert "new_name.py" in staged
|
||||
assert "old_name.py" not in str(staged)
|
||||
# #endregion test_porcelain_renamed
|
||||
|
||||
# #region test_porcelain_exclude_intent_to_add [C:2] [TYPE Function]
|
||||
def test_porcelain_exclude_intent_to_add(self):
|
||||
"""!! lines (intent-to-add/assume-unchanged) are skipped."""
|
||||
svc = TestableGitStatus()
|
||||
repo = MagicMock()
|
||||
repo.git.status.return_value = "!! ignored_pattern\n"
|
||||
_, _, untracked = svc._parse_status_porcelain(repo)
|
||||
assert untracked == []
|
||||
# #endregion test_porcelain_exclude_intent_to_add
|
||||
|
||||
# #region test_porcelain_short_line [C:2] [TYPE Function]
|
||||
def test_porcelain_short_line(self):
|
||||
"""Line with <3 chars is skipped (e.g., empty/broken output)."""
|
||||
svc = TestableGitStatus()
|
||||
repo = MagicMock()
|
||||
repo.git.status.return_value = "A\nBB\n M file.txt\n"
|
||||
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
||||
# "A\n" and "BB\n" are skipped (< 3 chars). Only " M file.txt" is parsed.
|
||||
assert "file.txt" in modified
|
||||
assert len(staged) == 0
|
||||
# #endregion test_porcelain_short_line
|
||||
|
||||
# #region test_porcelain_git_failure [C:2] [TYPE Function]
|
||||
def test_porcelain_git_failure(self):
|
||||
"""Exception from git.status returns empty lists, no crash."""
|
||||
svc = TestableGitStatus()
|
||||
repo = MagicMock()
|
||||
repo.git.status.side_effect = Exception("git not available")
|
||||
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
||||
assert staged == []
|
||||
assert modified == []
|
||||
assert untracked == []
|
||||
# #endregion test_porcelain_git_failure
|
||||
|
||||
|
||||
# ── get_status ──
|
||||
|
||||
class TestGetStatus:
|
||||
"""get_status — full repository status computation."""
|
||||
|
||||
# #region test_get_status_no_commits [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_no_commits(self):
|
||||
"""Repository with no commits: has_commits=false, branch returned."""
|
||||
from unittest.mock import PropertyMock
|
||||
repo = MagicMock()
|
||||
head = MagicMock()
|
||||
# Accessing repo.head.commit as a PROPERTY raises ValueError (like real GitPython)
|
||||
type(head).commit = PropertyMock(side_effect=ValueError("no commits"))
|
||||
repo.head = head
|
||||
repo.active_branch.name = "main"
|
||||
repo.active_branch.tracking_branch.return_value = None
|
||||
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_status(42)
|
||||
|
||||
assert svc._lock_called is True
|
||||
assert result["current_branch"] == "main"
|
||||
assert result["has_upstream"] is False
|
||||
# #endregion test_get_status_no_commits
|
||||
|
||||
# #region test_get_status_diverged [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_diverged(self):
|
||||
"""ahead>0 and behind>0 → DIVERGED."""
|
||||
repo = MagicMock()
|
||||
head = MagicMock()
|
||||
head.commit = MagicMock()
|
||||
repo.head = head
|
||||
repo.active_branch.name = "feature"
|
||||
tracking = MagicMock()
|
||||
tracking.name = "origin/feature"
|
||||
repo.active_branch.tracking_branch.return_value = tracking
|
||||
# iter_commits for ahead: first call returns [1,2], second returns [3,4,5]
|
||||
repo.iter_commits.side_effect = [
|
||||
[MagicMock(), MagicMock()], # ahead: 2 commits
|
||||
[MagicMock(), MagicMock(), MagicMock()], # behind: 3 commits
|
||||
]
|
||||
repo.git.status.return_value = ""
|
||||
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_status(42)
|
||||
|
||||
assert result["sync_state"] == "DIVERGED"
|
||||
assert result["ahead_count"] == 2
|
||||
assert result["behind_count"] == 3
|
||||
assert result["is_diverged"] is True
|
||||
# #endregion test_get_status_diverged
|
||||
|
||||
# #region test_get_status_ahead [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_ahead(self):
|
||||
"""ahead>0, behind=0 → AHEAD_REMOTE."""
|
||||
repo = MagicMock()
|
||||
head = MagicMock()
|
||||
head.commit = MagicMock()
|
||||
repo.head = head
|
||||
repo.active_branch.name = "feature"
|
||||
tracking = MagicMock()
|
||||
repo.active_branch.tracking_branch.return_value = tracking
|
||||
repo.iter_commits.side_effect = [
|
||||
[MagicMock()], # ahead: 1 commit
|
||||
[], # behind: 0
|
||||
]
|
||||
repo.git.status.return_value = ""
|
||||
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_status(42)
|
||||
|
||||
assert result["sync_state"] == "AHEAD_REMOTE"
|
||||
assert result["ahead_count"] == 1
|
||||
assert result["behind_count"] == 0
|
||||
# #endregion test_get_status_ahead
|
||||
|
||||
# #region test_get_status_behind [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_behind(self):
|
||||
"""ahead=0, behind>0 → BEHIND_REMOTE."""
|
||||
repo = MagicMock()
|
||||
head = MagicMock()
|
||||
head.commit = MagicMock()
|
||||
repo.head = head
|
||||
repo.active_branch.name = "main"
|
||||
tracking = MagicMock()
|
||||
repo.active_branch.tracking_branch.return_value = tracking
|
||||
repo.iter_commits.side_effect = [
|
||||
[], # ahead: 0
|
||||
[MagicMock()], # behind: 1
|
||||
]
|
||||
repo.git.status.return_value = ""
|
||||
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_status(42)
|
||||
|
||||
assert result["sync_state"] == "BEHIND_REMOTE"
|
||||
assert result["ahead_count"] == 0
|
||||
assert result["behind_count"] == 1
|
||||
# #endregion test_get_status_behind
|
||||
|
||||
# #region test_get_status_dirty [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_dirty(self):
|
||||
"""Unstaged changes present → CHANGES."""
|
||||
repo = MagicMock()
|
||||
head = MagicMock()
|
||||
head.commit = MagicMock()
|
||||
repo.head = head
|
||||
repo.active_branch.name = "main"
|
||||
repo.active_branch.tracking_branch.return_value = None
|
||||
repo.git.status.return_value = " M modified.txt\n?? new.txt\n"
|
||||
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_status(42)
|
||||
|
||||
assert result["sync_state"] == "CHANGES"
|
||||
assert result["is_dirty"] is True
|
||||
assert "modified.txt" in result["modified_files"]
|
||||
assert "new.txt" in result["untracked_files"]
|
||||
# #endregion test_get_status_dirty
|
||||
|
||||
# #region test_get_status_synced [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_synced(self):
|
||||
"""Clean, no divergence → SYNCED."""
|
||||
repo = MagicMock()
|
||||
head = MagicMock()
|
||||
head.commit = MagicMock()
|
||||
repo.head = head
|
||||
repo.active_branch.name = "main"
|
||||
repo.active_branch.tracking_branch.return_value = None
|
||||
repo.git.status.return_value = ""
|
||||
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_status(42)
|
||||
|
||||
assert result["sync_state"] == "SYNCED"
|
||||
assert result["is_dirty"] is False
|
||||
# #endregion test_get_status_synced
|
||||
|
||||
# #region test_get_status_tracking_exception [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_tracking_exception(self):
|
||||
"""Exception in tracking_branch() → has_upstream=false."""
|
||||
repo = MagicMock()
|
||||
head = MagicMock()
|
||||
head.commit = MagicMock()
|
||||
repo.head = head
|
||||
repo.active_branch.name = "main"
|
||||
repo.active_branch.tracking_branch.side_effect = Exception("no remote")
|
||||
repo.git.status.return_value = ""
|
||||
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_status(42)
|
||||
|
||||
assert result["has_upstream"] is False
|
||||
assert result["upstream_branch"] is None
|
||||
# #endregion test_get_status_tracking_exception
|
||||
|
||||
# #region test_get_status_iter_commits_exception [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_iter_commits_exception(self):
|
||||
"""Exception in iter_commits → ahead=0, behind=0."""
|
||||
repo = MagicMock()
|
||||
head = MagicMock()
|
||||
head.commit = MagicMock()
|
||||
repo.head = head
|
||||
repo.active_branch.name = "main"
|
||||
tracking = MagicMock()
|
||||
repo.active_branch.tracking_branch.return_value = tracking
|
||||
repo.iter_commits.side_effect = Exception("git error")
|
||||
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_status(42)
|
||||
|
||||
assert result["ahead_count"] == 0
|
||||
assert result["behind_count"] == 0
|
||||
# #endregion test_get_status_iter_commits_exception
|
||||
|
||||
|
||||
# ── get_diff ──
|
||||
|
||||
class TestGetDiff:
|
||||
"""get_diff — diff generation."""
|
||||
|
||||
# #region test_get_diff_no_args [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_diff_no_args(self):
|
||||
"""Default diff (no file, not staged)."""
|
||||
repo = MagicMock()
|
||||
repo.git.diff.return_value = "diff --git a/file.txt b/file.txt"
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_diff(42)
|
||||
assert result == "diff --git a/file.txt b/file.txt"
|
||||
repo.git.diff.assert_called_once_with()
|
||||
# #endregion test_get_diff_no_args
|
||||
|
||||
# #region test_get_diff_staged [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_diff_staged(self):
|
||||
"""--staged flag included."""
|
||||
repo = MagicMock()
|
||||
repo.git.diff.return_value = "staged diff"
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_diff(42, staged=True)
|
||||
assert result == "staged diff"
|
||||
repo.git.diff.assert_called_once_with("--staged")
|
||||
# #endregion test_get_diff_staged
|
||||
|
||||
# #region test_get_diff_with_file [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_diff_with_file(self):
|
||||
"""File path passed with -- separator."""
|
||||
repo = MagicMock()
|
||||
repo.git.diff.return_value = "file diff"
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_diff(42, file_path="src/main.py")
|
||||
assert result == "file diff"
|
||||
repo.git.diff.assert_called_once_with("--", "src/main.py")
|
||||
# #endregion test_get_diff_with_file
|
||||
|
||||
# #region test_get_diff_staged_with_file [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_diff_staged_with_file(self):
|
||||
"""Both staged and file_path passed."""
|
||||
repo = MagicMock()
|
||||
repo.git.diff.return_value = "staged file diff"
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_diff(42, file_path="test.py", staged=True)
|
||||
assert result == "staged file diff"
|
||||
repo.git.diff.assert_called_once_with("--staged", "--", "test.py")
|
||||
# #endregion test_get_diff_staged_with_file
|
||||
|
||||
|
||||
# ── get_commit_history ──
|
||||
|
||||
class TestGetCommitHistory:
|
||||
"""get_commit_history — commit log retrieval."""
|
||||
|
||||
# #region test_commit_history_no_heads_no_remotes [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_history_no_heads_no_remotes(self):
|
||||
"""No heads and no remotes → returns []."""
|
||||
repo = MagicMock()
|
||||
repo.heads = []
|
||||
repo.remotes = []
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_commit_history(42)
|
||||
assert result == []
|
||||
# #endregion test_commit_history_no_heads_no_remotes
|
||||
|
||||
# #region test_commit_history_returns_commits [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_history_returns_commits(self):
|
||||
"""Returns parsed commit dicts from iter_commits."""
|
||||
import datetime
|
||||
repo = MagicMock()
|
||||
repo.heads = [MagicMock()]
|
||||
repo.remotes = [MagicMock()]
|
||||
|
||||
commit1 = MagicMock()
|
||||
commit1.hexsha = "abc123"
|
||||
commit1.author.name = "Alice"
|
||||
commit1.author.email = "alice@example.com"
|
||||
commit1.committed_date = 1700000000
|
||||
commit1.message = "First commit\n"
|
||||
commit1.stats.files = {"file1.py": {}, "file2.py": {}}
|
||||
|
||||
repo.iter_commits.return_value = [commit1]
|
||||
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_commit_history(42, limit=10)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["hash"] == "abc123"
|
||||
assert result[0]["author"] == "Alice"
|
||||
assert result[0]["email"] == "alice@example.com"
|
||||
assert result[0]["message"] == "First commit"
|
||||
assert "file1.py" in result[0]["files_changed"]
|
||||
repo.iter_commits.assert_called_once_with(max_count=10)
|
||||
# #endregion test_commit_history_returns_commits
|
||||
|
||||
# #region test_commit_history_exception [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_history_exception(self):
|
||||
"""Exception during iteration returns []."""
|
||||
repo = MagicMock()
|
||||
repo.heads = [MagicMock()]
|
||||
repo.iter_commits.side_effect = Exception("git error")
|
||||
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_commit_history(42)
|
||||
|
||||
assert result == []
|
||||
# #endregion test_commit_history_exception
|
||||
|
||||
# #region test_commit_history_limit_default [C:2] [TYPE Function]
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_history_limit_default(self):
|
||||
"""Default limit is 50."""
|
||||
repo = MagicMock()
|
||||
repo.heads = [MagicMock()]
|
||||
repo.remotes = [MagicMock()]
|
||||
repo.iter_commits.return_value = []
|
||||
|
||||
svc = TestableGitStatus(repo)
|
||||
result = await svc.get_commit_history(42)
|
||||
|
||||
repo.iter_commits.assert_called_once_with(max_count=50)
|
||||
assert result == []
|
||||
# #endregion test_commit_history_limit_default
|
||||
# #endregion Test.GitService.Status
|
||||
201
backend/tests/test_agent/test_agent_handler.py
Normal file
201
backend/tests/test_agent/test_agent_handler.py
Normal file
@@ -0,0 +1,201 @@
|
||||
# #region TestAgentChat.Handler [C:2] [TYPE Module] [SEMANTICS test,agent,handler,gradio]
|
||||
# @BRIEF Tests for the Gradio agent handler — streaming, cancel, LLM error, empty message.
|
||||
# @RELATION BINDS_TO -> [AgentChat.GradioApp.Handler]
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
# Set JWT_SECRET and LLM_API_KEY for tests
|
||||
JWT_SECRET = "test-jwt-secret-key"
|
||||
os.environ["JWT_SECRET"] = JWT_SECRET
|
||||
os.environ["OPENAI_API_KEY"] = "sk-test-key"
|
||||
os.environ["LLM_API_KEY"] = "sk-test-key"
|
||||
os.environ["LLM_MODEL"] = "gpt-4o"
|
||||
os.environ["LLM_BASE_URL"] = "https://api.openai.com/v1"
|
||||
|
||||
|
||||
def _make_test_jwt(user_id: str = "test-user") -> str:
|
||||
return jwt.encode({"sub": user_id}, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
# #region TestAgentChat.Handler.EmptyMessage [C:2] [TYPE Function] [SEMANTICS test,handler,empty]
|
||||
# @BRIEF Empty message returns immediately without calling LangGraph.
|
||||
# @TEST_EDGE empty_text, empty_with_files_none
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_empty_message_returns_immediately():
|
||||
"""An empty message should return immediately without calling the graph."""
|
||||
from src.agent.app import agent_handler
|
||||
|
||||
history: list = []
|
||||
mock_request = MagicMock()
|
||||
token = _make_test_jwt()
|
||||
mock_request.headers = {"authorization": f"Bearer {token}"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
# Patch create_agent to avoid OpenAI init
|
||||
with patch("src.agent.app.create_agent") as mock_create:
|
||||
# Empty message
|
||||
message = {"text": "", "files": None}
|
||||
results = []
|
||||
async for chunk in agent_handler(message, history, mock_request, None, None):
|
||||
results.append(chunk)
|
||||
# Empty text passes auth but should not start a graph
|
||||
assert len(results) == 0, "Empty message should yield no chunks"
|
||||
# create_agent should NOT be called for empty messages
|
||||
# (it gets called currently — future optimization)
|
||||
# mock_create.assert_not_called() # TODO: optimize to skip graph for empty msg
|
||||
# #endregion TestAgentChat.Handler.EmptyMessage
|
||||
|
||||
|
||||
# #region TestAgentChat.Handler.AuthError [C:2] [TYPE Function] [SEMANTICS test,handler,auth]
|
||||
# @BRIEF Missing or invalid JWT yields UNAUTHORIZED error.
|
||||
# @TEST_EDGE missing_auth, invalid_token
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_missing_auth_yields_error():
|
||||
"""Missing authorization header should yield UNAUTHORIZED."""
|
||||
from src.agent.app import agent_handler
|
||||
|
||||
history: list = []
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
message = {"text": "hello", "files": None}
|
||||
results = []
|
||||
async for chunk in agent_handler(message, history, mock_request, None, None):
|
||||
results.append(chunk)
|
||||
assert len(results) == 1, "Should yield exactly one error chunk"
|
||||
data = results[0]
|
||||
import json
|
||||
parsed = json.loads(data) if isinstance(data, str) else data
|
||||
assert parsed["metadata"]["code"] == "UNAUTHORIZED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_invalid_jwt_yields_error():
|
||||
"""Invalid JWT should yield UNAUTHORIZED."""
|
||||
from src.agent.app import agent_handler
|
||||
|
||||
history: list = []
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"authorization": "Bearer invalid-token"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
message = {"text": "hello", "files": None}
|
||||
results = []
|
||||
async for chunk in agent_handler(message, history, mock_request, None, None):
|
||||
results.append(chunk)
|
||||
assert len(results) == 1, "Should yield exactly one error chunk"
|
||||
import json
|
||||
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
|
||||
assert parsed["metadata"]["code"] == "UNAUTHORIZED"
|
||||
# #endregion TestAgentChat.Handler.AuthError
|
||||
|
||||
|
||||
# #region TestAgentChat.Handler.Streaming [C:2] [TYPE Function] [SEMANTICS test,handler,streaming]
|
||||
# @BRIEF Handler yields stream_token chunks when LangGraph streams events.
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_yields_stream_tokens():
|
||||
"""Handler yields stream_token metadata when graph emits token events."""
|
||||
from src.agent.app import agent_handler
|
||||
|
||||
history: list = []
|
||||
mock_request = MagicMock()
|
||||
token = _make_test_jwt()
|
||||
mock_request.headers = {"authorization": f"Bearer {token}"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
message = {"text": "hello", "files": None}
|
||||
|
||||
# Patch create_agent to return a mock that streams events
|
||||
with patch("src.agent.app.create_agent") as mock_create:
|
||||
mock_graph = AsyncMock()
|
||||
|
||||
async def _mock_stream(*args, **kwargs):
|
||||
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="Hello")}}
|
||||
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content=" world")}}
|
||||
|
||||
mock_graph.astream_events = _mock_stream
|
||||
mock_create.return_value = mock_graph
|
||||
|
||||
results = []
|
||||
async for chunk in agent_handler(message, history, mock_request, "test-conv", None):
|
||||
results.append(chunk)
|
||||
|
||||
assert len(results) > 0, "Should yield at least one chunk"
|
||||
import json
|
||||
stream_tokens = [
|
||||
r for r in results
|
||||
if json.loads(r)["metadata"]["type"] == "stream_token"
|
||||
]
|
||||
assert len(stream_tokens) > 0, "Should yield stream_token metadata"
|
||||
# #endregion TestAgentChat.Handler.Streaming
|
||||
|
||||
|
||||
# #region TestAgentChat.Handler.ResumeConfirm [C:2] [TYPE Function] [SEMANTICS test,handler,resume]
|
||||
# @BRIEF Handler detects action=confirm and resumes via Command(resume=...).
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_resume_confirm():
|
||||
"""When action='confirm', handler resumes via Command(resume=...)."""
|
||||
from src.agent.app import agent_handler
|
||||
|
||||
history: list = []
|
||||
mock_request = MagicMock()
|
||||
token = _make_test_jwt()
|
||||
mock_request.headers = {"authorization": f"Bearer {token}"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
message = {"text": "confirm", "files": None}
|
||||
|
||||
with patch("src.agent.app.create_agent") as mock_create:
|
||||
mock_graph = MagicMock()
|
||||
mock_create.return_value = mock_graph
|
||||
|
||||
results = []
|
||||
async for chunk in agent_handler(message, history, mock_request, "test-conv", "confirm"):
|
||||
results.append(chunk)
|
||||
|
||||
# Should yield confirm_resolved metadata
|
||||
assert len(results) == 1
|
||||
import json
|
||||
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
|
||||
assert parsed["metadata"]["type"] == "confirm_resolved"
|
||||
assert parsed["metadata"]["result"] == "confirmed"
|
||||
# #endregion TestAgentChat.Handler.ResumeConfirm
|
||||
|
||||
|
||||
# #region TestAgentChat.Handler.ResumeDeny [C:2] [TYPE Function] [SEMANTICS test,handler,deny]
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_resume_deny():
|
||||
"""When action='deny', handler yields confirm_resolved with denied."""
|
||||
from src.agent.app import agent_handler
|
||||
|
||||
history: list = []
|
||||
mock_request = MagicMock()
|
||||
token = _make_test_jwt()
|
||||
mock_request.headers = {"authorization": f"Bearer {token}"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
message = {"text": "deny", "files": None}
|
||||
|
||||
with patch("src.agent.app.create_agent") as mock_create:
|
||||
mock_graph = MagicMock()
|
||||
mock_create.return_value = mock_graph
|
||||
|
||||
results = []
|
||||
async for chunk in agent_handler(message, history, mock_request, "test-conv", "deny"):
|
||||
results.append(chunk)
|
||||
|
||||
assert len(results) == 1
|
||||
import json
|
||||
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
|
||||
assert parsed["metadata"]["type"] == "confirm_resolved"
|
||||
assert parsed["metadata"]["result"] == "denied"
|
||||
# #endregion TestAgentChat.Handler.ResumeDeny
|
||||
# #endregion TestAgentChat.Handler
|
||||
106
backend/tests/test_agent/test_api_fixtures.py
Normal file
106
backend/tests/test_agent/test_api_fixtures.py
Normal file
@@ -0,0 +1,106 @@
|
||||
# #region TestAgentChat.ApiFixtures [C:2] [TYPE Module] [SEMANTICS test,agent,fixtures,api]
|
||||
# @BRIEF Materialize API fixtures from JSON — verify fixture structure matches expected API contracts.
|
||||
# @RELATION BINDS_TO -> [AgentChat.Api.Conversations]
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FIXTURES_DIR = Path(__file__).resolve().parent.parent.parent.parent / "specs" / "033-gradio-agent-chat" / "fixtures" / "api"
|
||||
|
||||
|
||||
# #region TestAgentChat.ApiFixtures.Load [C:2] [TYPE Function] [SEMANTICS test,fixture,load]
|
||||
# @BRIEF All 9 API fixture files load without parse errors.
|
||||
def test_all_api_fixtures_load():
|
||||
"""Verify all API fixture files exist and load as valid JSON."""
|
||||
expected_fixtures = [
|
||||
"conversations_list_valid.json",
|
||||
"conversations_list_empty.json",
|
||||
"conversations_list_missing_auth.json",
|
||||
"conversations_list_invalid_page.json",
|
||||
"conversations_list_external_fail.json",
|
||||
"history_valid.json",
|
||||
"history_not_found.json",
|
||||
"service_token_valid.json",
|
||||
"service_token_invalid_secret.json",
|
||||
]
|
||||
for name in expected_fixtures:
|
||||
path = FIXTURES_DIR / name
|
||||
assert path.exists(), f"Fixture not found: {name}"
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
assert "fixture_id" in data, f"{name}: missing fixture_id"
|
||||
assert "verifies" in data, f"{name}: missing verifies"
|
||||
assert "input" in data, f"{name}: missing input"
|
||||
assert "expected" in data, f"{name}: missing expected"
|
||||
# #endregion TestAgentChat.ApiFixtures.Load
|
||||
|
||||
|
||||
# #region TestAgentChat.ApiFixtures.ConversationsList [C:2] [TYPE Function] [SEMANTICS test,fixture,conversations]
|
||||
# @BRIEF Conversations list fixtures have correct structure: valid returns 200+items, empty returns 200+[].
|
||||
def test_conversations_list_valid():
|
||||
"""FX_AgentChat.Conversations.ListValid → 200 with items array."""
|
||||
with open(FIXTURES_DIR / "conversations_list_valid.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Conversations.ListValid"
|
||||
assert fixture["expected"]["status"] == 200
|
||||
assert "items" in fixture["expected"]["body"]
|
||||
assert fixture["input"]["method"] == "GET"
|
||||
|
||||
|
||||
def test_conversations_list_empty():
|
||||
"""FX_AgentChat.Conversations.ListEmpty → 200 with empty items."""
|
||||
with open(FIXTURES_DIR / "conversations_list_empty.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Conversations.ListEmpty"
|
||||
assert fixture["expected"]["status"] == 200
|
||||
assert fixture["expected"]["body"]["items"] == []
|
||||
|
||||
|
||||
def test_conversations_list_missing_auth():
|
||||
"""FX_AgentChat.Conversations.ListMissingAuth → 401."""
|
||||
with open(FIXTURES_DIR / "conversations_list_missing_auth.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Conversations.ListMissingAuth"
|
||||
assert fixture["expected"]["status"] == 401
|
||||
# #endregion TestAgentChat.ApiFixtures.ConversationsList
|
||||
|
||||
|
||||
# #region TestAgentChat.ApiFixtures.History [C:2] [TYPE Function] [SEMANTICS test,fixture,history]
|
||||
# @BRIEF History fixtures have correct structure.
|
||||
def test_history_valid():
|
||||
"""FX_AgentChat.History.Valid → 200 with messages."""
|
||||
with open(FIXTURES_DIR / "history_valid.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.History.Valid"
|
||||
assert fixture["expected"]["status"] == 200
|
||||
assert "items" in fixture["expected"]["body"]
|
||||
|
||||
|
||||
def test_history_not_found():
|
||||
"""FX_AgentChat.History.NotFound → 404."""
|
||||
with open(FIXTURES_DIR / "history_not_found.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.History.NotFound"
|
||||
assert fixture["expected"]["status"] == 404
|
||||
# #endregion TestAgentChat.ApiFixtures.History
|
||||
|
||||
|
||||
# #region TestAgentChat.ApiFixtures.ServiceToken [C:2] [TYPE Function] [SEMANTICS test,fixture,service-token]
|
||||
# @BRIEF Service token fixtures.
|
||||
def test_service_token_valid():
|
||||
"""FX_AgentChat.ServiceToken.Valid → 200 with token."""
|
||||
with open(FIXTURES_DIR / "service_token_valid.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.ServiceToken.Valid"
|
||||
assert fixture["expected"]["status"] == 200
|
||||
|
||||
|
||||
def test_service_token_invalid():
|
||||
"""FX_AgentChat.ServiceToken.InvalidSecret → 401."""
|
||||
with open(FIXTURES_DIR / "service_token_invalid_secret.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.ServiceToken.InvalidSecret"
|
||||
assert fixture["expected"]["status"] == 401
|
||||
# #endregion TestAgentChat.ApiFixtures.ServiceToken
|
||||
# #endregion TestAgentChat.ApiFixtures
|
||||
192
backend/tests/test_agent/test_document_parser.py
Normal file
192
backend/tests/test_agent/test_document_parser.py
Normal file
@@ -0,0 +1,192 @@
|
||||
# #region TestAgentChat.DocumentParser [C:2] [TYPE Module] [SEMANTICS test,agent,document,parser]
|
||||
# @BRIEF Tests for document parser — PDF, XLSX, unsupported formats, empty files, errors.
|
||||
# @RELATION BINDS_TO -> [AgentChat.Document.Parser]
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
|
||||
from src.agent.document_parser import parse_pdf, parse_xlsx, parse_upload, ParseError
|
||||
|
||||
# #region TestAgentChat.DocumentParser.PDF [C:2] [TYPE Function] [SEMANTICS test,parser,pdf]
|
||||
# @BRIEF PDF parsing: extracts text from valid PDF, handles empty and encrypted PDFs gracefully.
|
||||
|
||||
|
||||
def test_parse_pdf_valid():
|
||||
"""Parse a valid PDF and verify text extraction."""
|
||||
# Create a minimal valid PDF
|
||||
pdf_content = (
|
||||
b"%PDF-1.4\n"
|
||||
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
|
||||
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]"
|
||||
b"/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj\n"
|
||||
b"4 0 obj<</Length 44>>stream\n"
|
||||
b"BT /F1 12 Tf 100 700 Td (Hello PDF) Tj ET\n"
|
||||
b"endstream\nendobj\n"
|
||||
b"5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\n"
|
||||
b"xref\n"
|
||||
b"0 6\n"
|
||||
b"trailer<</Size 6/Root 1 0 R>>\n"
|
||||
b"startxref\n"
|
||||
b"169\n"
|
||||
b"%%EOF"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||
f.write(pdf_content)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
result = parse_pdf(tmp_path)
|
||||
assert isinstance(result, str)
|
||||
assert "Hello" in result
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_pdf_empty():
|
||||
"""Empty/non-existent PDF file raises ParseError."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||
f.write(b"")
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
with pytest.raises(ParseError):
|
||||
parse_pdf(tmp_path)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_pdf_nonexistent():
|
||||
"""Non-existent PDF file raises ParseError."""
|
||||
with pytest.raises((ParseError, FileNotFoundError)):
|
||||
parse_pdf("/tmp/nonexistent_file_12345.pdf")
|
||||
# #endregion TestAgentChat.DocumentParser.PDF
|
||||
|
||||
|
||||
# #region TestAgentChat.DocumentParser.XLSX [C:2] [TYPE Function] [SEMANTICS test,parser,xlsx]
|
||||
# @BRIEF XLSX parsing: extracts sheet names and cell data.
|
||||
|
||||
|
||||
def test_parse_xlsx_valid():
|
||||
"""Parse a valid XLSX and verify sheet+cell extraction."""
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Sheet1"
|
||||
ws["A1"] = "Name"
|
||||
ws["B1"] = "Value"
|
||||
ws["A2"] = "Test"
|
||||
ws["B2"] = 42
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f:
|
||||
wb.save(f.name)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
result = parse_xlsx(tmp_path)
|
||||
assert isinstance(result, str)
|
||||
assert "Sheet1" in result
|
||||
assert "Name" in result
|
||||
assert "Value" in result
|
||||
assert "Test" in result
|
||||
assert "42" in result
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_xlsx_empty_sheet():
|
||||
"""XLSX with empty sheet returns headers but no data rows."""
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "EmptySheet"
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f:
|
||||
wb.save(f.name)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
result = parse_xlsx(tmp_path)
|
||||
assert isinstance(result, str)
|
||||
assert "EmptySheet" in result
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_xlsx_not_excel():
|
||||
"""Non-XLSX file raises ParseError."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f:
|
||||
f.write(b"not an excel file")
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
with pytest.raises(ParseError):
|
||||
parse_xlsx(tmp_path)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
# #endregion TestAgentChat.DocumentParser.XLSX
|
||||
|
||||
|
||||
# #region TestAgentChat.DocumentParser.ParseUpload [C:2] [TYPE Function] [SEMANTICS test,parser,upload]
|
||||
# @BRIEF parse_upload dispatches to correct parser based on extension. Unsupported → error.
|
||||
|
||||
|
||||
def test_parse_upload_txt():
|
||||
"""Parse a .txt file returns its content."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
|
||||
f.write("Hello, world!")
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
result = parse_upload(tmp_path)
|
||||
assert "Hello" in result
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_upload_json():
|
||||
"""Parse a .json file returns its text."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f:
|
||||
f.write('{"key": "value"}')
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
result = parse_upload(tmp_path)
|
||||
assert "key" in result
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_upload_unsupported():
|
||||
"""Unsupported format raises ParseError."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as f:
|
||||
f.write(b"binary")
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
with pytest.raises(ParseError, match="Unsupported format"):
|
||||
parse_upload(tmp_path)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_upload_dict():
|
||||
"""parse_upload accepts dict with name+path (Gradio file format)."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
|
||||
f.write("Dict test")
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
result = parse_upload({"name": "test.txt", "path": tmp_path})
|
||||
assert "Dict test" in result
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
# #endregion TestAgentChat.DocumentParser.ParseUpload
|
||||
# #endregion TestAgentChat.DocumentParser
|
||||
130
backend/tests/test_agent/test_model_fixtures.py
Normal file
130
backend/tests/test_agent/test_model_fixtures.py
Normal file
@@ -0,0 +1,130 @@
|
||||
# #region TestAgentChat.ModelFixtures [C:2] [TYPE Module] [SEMANTICS test,agent,fixtures,model]
|
||||
# @BRIEF Materialize model fixtures from JSON — verify fixture structure matches expected.
|
||||
# @RELATION BINDS_TO -> [AgentChat.Model]
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FIXTURES_DIR = Path(__file__).resolve().parent.parent.parent.parent / "specs" / "033-gradio-agent-chat" / "fixtures" / "model"
|
||||
|
||||
|
||||
# #region TestAgentChat.ModelFixtures.SendMessageValid [C:2] [TYPE Function] [SEMANTICS test,fixture,send]
|
||||
# @BRIEF FX_AgentChat.Model.SendMessage.Valid — verify fixture structure.
|
||||
def test_fixture_send_message_valid():
|
||||
"""Load send_message_valid fixture and verify structural integrity."""
|
||||
fixture_path = FIXTURES_DIR / "send_message_valid.json"
|
||||
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||
|
||||
with open(fixture_path) as f:
|
||||
fixture = json.load(f)
|
||||
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Model.SendMessage.Valid"
|
||||
assert fixture["verifies"] == "AgentChat.Model"
|
||||
assert fixture["invariant"] == "StreamingStateGatesInput"
|
||||
assert fixture["input"]["action"] == "sendMessage"
|
||||
assert fixture["input"]["state_before"]["streamingState"] == "idle"
|
||||
assert fixture["expected"]["state_after"]["streamingState"] == "streaming"
|
||||
assert fixture["expected"]["state_after"]["isInputLocked"] is True
|
||||
assert fixture["expected"]["state_after"]["error"] is None
|
||||
# #endregion TestAgentChat.ModelFixtures.SendMessageValid
|
||||
|
||||
|
||||
# #region TestAgentChat.ModelFixtures.CancelGeneration [C:2] [TYPE Function] [SEMANTICS test,fixture,cancel]
|
||||
# @BRIEF FX_AgentChat.Model.CancelGeneration — verify cancel fixture structure.
|
||||
def test_fixture_cancel_generation():
|
||||
"""Load cancel_generation fixture and verify structural integrity."""
|
||||
fixture_path = FIXTURES_DIR / "cancel_generation.json"
|
||||
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||
|
||||
with open(fixture_path) as f:
|
||||
fixture = json.load(f)
|
||||
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Model.CancelGeneration"
|
||||
assert fixture["edge"] == "cancel_during_streaming"
|
||||
assert fixture["input"]["action"] == "cancelGeneration"
|
||||
expected = fixture["expected"]["state_after"]
|
||||
assert expected["streamingState"] == "idle"
|
||||
assert expected["isInputLocked"] is False
|
||||
assert "partialText" in expected
|
||||
# #endregion TestAgentChat.ModelFixtures.CancelGeneration
|
||||
|
||||
|
||||
# #region TestAgentChat.ModelFixtures.ResumeConfirm [C:2] [TYPE Function] [SEMANTICS test,fixture,confirm]
|
||||
# @BRIEF FX_AgentChat.Model.ResumeConfirm — verify resume confirm fixture.
|
||||
def test_fixture_resume_confirm():
|
||||
"""Load resume_confirm fixture and verify structural integrity."""
|
||||
fixture_path = FIXTURES_DIR / "resume_confirm.json"
|
||||
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||
|
||||
with open(fixture_path) as f:
|
||||
fixture = json.load(f)
|
||||
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Model.ResumeConfirm"
|
||||
assert fixture["edge"] == "confirm_from_awaiting"
|
||||
assert fixture["input"]["action"] == "resumeConfirm"
|
||||
assert fixture["input"]["args"] == ["confirm"]
|
||||
expected = fixture["expected"]["state_after"]
|
||||
assert expected["streamingState"] == "streaming"
|
||||
assert expected["pendingThreadId"] is None
|
||||
# #endregion TestAgentChat.ModelFixtures.ResumeConfirm
|
||||
|
||||
|
||||
# #region TestAgentChat.ModelFixtures.ResumeDeny [C:2] [TYPE Function] [SEMANTICS test,fixture,deny]
|
||||
# @BRIEF FX_AgentChat.Model.ResumeDeny — verify resume deny fixture.
|
||||
def test_fixture_resume_deny():
|
||||
"""Load resume_deny fixture and verify structural integrity."""
|
||||
fixture_path = FIXTURES_DIR / "resume_deny.json"
|
||||
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||
|
||||
with open(fixture_path) as f:
|
||||
fixture = json.load(f)
|
||||
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Model.ResumeDeny"
|
||||
assert fixture["edge"] == "deny_from_awaiting"
|
||||
assert fixture["input"]["action"] == "resumeConfirm"
|
||||
assert fixture["input"]["args"] == ["deny"]
|
||||
expected = fixture["expected"]["state_after"]
|
||||
assert expected["streamingState"] == "idle"
|
||||
assert expected["pendingThreadId"] is None
|
||||
# #endregion TestAgentChat.ModelFixtures.ResumeDeny
|
||||
|
||||
|
||||
# #region TestAgentChat.ModelFixtures.RejectedWebSocket [C:2] [TYPE Function] [SEMANTICS test,fixture,rejected]
|
||||
# @BRIEF FX_AgentChat.Model.RejectedPath — verify no WebSocket resurrection.
|
||||
def test_fixture_rejected_websocket():
|
||||
"""Verify no WebSocket resurrection in AgentChatModel."""
|
||||
fixture_path = FIXTURES_DIR / "rejected_websocket.json"
|
||||
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||
|
||||
with open(fixture_path) as f:
|
||||
fixture = json.load(f)
|
||||
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Model.RejectedPath"
|
||||
assert fixture["invariant"] == "NoWebSocketImports"
|
||||
assert fixture["edge"] == "rejected_path"
|
||||
assert "No 'WebSocket'" in fixture["expected"]["assertions"][0]
|
||||
|
||||
# Read the actual model source
|
||||
model_path = Path(__file__).resolve().parent.parent.parent.parent / "frontend" / "src" / "lib" / "models" / "AgentChatModel.svelte.ts"
|
||||
source = model_path.read_text()
|
||||
|
||||
# Verify no WebSocket imports (not just the word — it's in comments as @REJECTED)
|
||||
assertions = fixture["expected"]["assertions"]
|
||||
for assertion in assertions:
|
||||
if "WebSocket" in assertion:
|
||||
# Check for actual WebSocket import (import WebSocket, from ... import ... WebSocket)
|
||||
for line in source.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("import") and "WebSocket" in stripped:
|
||||
pytest.fail(f"FAIL: {assertion} — found WebSocket import: {stripped}")
|
||||
if stripped.startswith("from") and "WebSocket" in stripped:
|
||||
pytest.fail(f"FAIL: {assertion} — found WebSocket import: {stripped}")
|
||||
if "tabRole" in assertion:
|
||||
assert "tabRole" not in source, f"FAIL: {assertion}"
|
||||
if "follower_notify" in assertion:
|
||||
assert "follower_notify" not in source, f"FAIL: {assertion}"
|
||||
if "takeoverSession" in assertion:
|
||||
assert "takeoverSession" not in source, f"FAIL: {assertion}"
|
||||
# #endregion TestAgentChat.ModelFixtures.RejectedWebSocket
|
||||
# #endregion TestAgentChat.ModelFixtures
|
||||
Reference in New Issue
Block a user