# #region Test.OrchestratorConfig [C:3] [TYPE Module] [SEMANTICS test, translate, config, hash, snapshot] # @BRIEF Tests for orchestrator_config.py — compute_config_hash, compute_dict_snapshot_hash. # @RELATION BINDS_TO -> [orchestrator_config] # @TEST_CONTRACT: compute_config_hash -> str | deterministic 16-char hash from job config # @TEST_CONTRACT: compute_dict_snapshot_hash -> str | hash from dictionary entries import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) import os os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest from src.plugins.translate.orchestrator_config import ( compute_config_hash, compute_dict_snapshot_hash, ) class TestComputeConfigHash: """compute_config_hash — deterministic hash from job config.""" def _make_job(self, overrides: dict | None = None) -> MagicMock: """Create a mock job with default config.""" job = MagicMock() job.source_dialect = "postgresql" job.target_dialect = "clickhouse" job.source_datasource_id = "ds-1" job.translation_column = "name" job.context_columns = ["desc"] job.target_languages = ["ru", "de"] job.provider_id = "provider-1" job.batch_size = 50 job.upsert_strategy = "MERGE" if overrides: for k, v in overrides.items(): setattr(job, k, v) return job def test_hash_length(self): """Hash is 16 characters.""" job = self._make_job() h = compute_config_hash(job) assert isinstance(h, str) assert len(h) == 16 def test_deterministic(self): """Same config produces same hash.""" job1 = self._make_job() job2 = self._make_job() assert compute_config_hash(job1) == compute_config_hash(job2) def test_different_config_different_hash(self): """Different config produces different hash.""" job1 = self._make_job({"batch_size": 50}) job2 = self._make_job({"batch_size": 100}) assert compute_config_hash(job1) != compute_config_hash(job2) def test_source_dialect_affects_hash(self): """Changing source_dialect changes hash.""" job1 = self._make_job({"source_dialect": "postgresql"}) job2 = self._make_job({"source_dialect": "mysql"}) assert compute_config_hash(job1) != compute_config_hash(job2) def test_target_languages_sorted(self): """target_languages are sorted before hashing.""" job1 = self._make_job({"target_languages": ["de", "ru"]}) job2 = self._make_job({"target_languages": ["ru", "de"]}) assert compute_config_hash(job1) == compute_config_hash(job2) def test_target_languages_empty(self): """Empty target_languages handled.""" job = self._make_job({"target_languages": None}) h = compute_config_hash(job) assert isinstance(h, str) assert len(h) == 16 class TestComputeDictSnapshotHash: """compute_dict_snapshot_hash — dictionary state hash.""" def test_no_dictionary_links(self): """No dictionary links returns hash of empty bytes.""" db = MagicMock() db.query.return_value.filter.return_value.all.return_value = [] h = compute_dict_snapshot_hash(db, "job-1") assert isinstance(h, str) assert len(h) == 16 def test_with_dictionary_links(self): """Dictionary links with entries produce a hash.""" db = MagicMock() mock_link = MagicMock() mock_link.dictionary_id = "dict-1" db.query.return_value.filter.return_value.all.return_value = [mock_link] db.query.return_value.filter.return_value.scalar.return_value = 5 # entry_count db.query.return_value.filter.return_value.scalar.side_effect = [ 5, # entry_count for dict-1 datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc), # max_updated ] h = compute_dict_snapshot_hash(db, "job-1") assert isinstance(h, str) assert len(h) == 16 def test_deterministic_with_same_data(self): """Same data produces same hash.""" db1 = MagicMock() db2 = MagicMock() mock_link = MagicMock() mock_link.dictionary_id = "dict-1" db1.query.return_value.filter.return_value.all.return_value = [mock_link] db2.query.return_value.filter.return_value.all.return_value = [mock_link] db1.query.return_value.filter.return_value.scalar.side_effect = [5, datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)] db2.query.return_value.filter.return_value.scalar.side_effect = [5, datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)] h1 = compute_dict_snapshot_hash(db1, "job-1") h2 = compute_dict_snapshot_hash(db2, "job-1") assert h1 == h2 def test_different_entry_count_changes_hash(self): """Different entry count produces different hash.""" db1 = MagicMock() db2 = MagicMock() mock_link = MagicMock() mock_link.dictionary_id = "dict-1" db1.query.return_value.filter.return_value.all.return_value = [mock_link] db2.query.return_value.filter.return_value.all.return_value = [mock_link] db1.query.return_value.filter.return_value.scalar.side_effect = [5, datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)] db2.query.return_value.filter.return_value.scalar.side_effect = [10, datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc)] h1 = compute_dict_snapshot_hash(db1, "job-1") h2 = compute_dict_snapshot_hash(db2, "job-1") assert h1 != h2 def test_max_updated_none_handled(self): """When max_updated is None, '0' is used.""" db = MagicMock() mock_link = MagicMock() mock_link.dictionary_id = "dict-1" db.query.return_value.filter.return_value.all.return_value = [mock_link] db.query.return_value.filter.return_value.scalar.side_effect = [5, None] h = compute_dict_snapshot_hash(db, "job-1") assert isinstance(h, str) assert len(h) == 16 def test_multiple_dictionaries(self): """Multiple dictionary links handled.""" db = MagicMock() link1 = MagicMock() link1.dictionary_id = "dict-a" link2 = MagicMock() link2.dictionary_id = "dict-b" db.query.return_value.filter.return_value.all.return_value = [link2, link1] # unsorted db.query.return_value.filter.return_value.scalar.side_effect = [ 3, datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc), # dict-a 7, datetime(2026, 2, 1, 8, 0, 0, tzinfo=timezone.utc), # dict-b ] h = compute_dict_snapshot_hash(db, "job-1") assert isinstance(h, str) assert len(h) == 16 # #endregion Test.OrchestratorConfig