from __future__ import annotations import uuid from contextlib import asynccontextmanager from datetime import UTC, datetime from types import SimpleNamespace import pytest from app.domain import safe_hash from app.reconciliation import IncrementalReconciler from app.repository import LeasedTask, Repository class FakeResult: def __init__(self, *, rows=(), scalar=None) -> None: self.rows = rows self.scalar = scalar def __iter__(self): return iter(self.rows) def mappings(self): return self def scalar_one_or_none(self): return self.scalar def scalar_one(self): return self.scalar class FakeConnection: async def execute(self, statement): sql = str(statement) if "han_app.sync_queue" in sql: return FakeResult(rows=[SimpleNamespace(status="pending", count=2)]) if "workflow_instances" in sql: assert "state AS status" in sql return FakeResult(rows=[SimpleNamespace(status="created", count=3)]) if "crm_commands" in sql: return FakeResult(rows=[SimpleNamespace(status="succeeded", count=4)]) if "webhook_inbox" in sql: return FakeResult(scalar=1.5) if "settings_versions" in sql: return FakeResult(scalar=1) raise AssertionError(f"unexpected status query: {sql}") class FakeEngine: def __init__(self) -> None: self.queries: list[str] = [] @asynccontextmanager async def connect(self): yield FakeConnection() @asynccontextmanager async def begin(self): yield ClaimConnection(self.queries) class ClaimConnection: def __init__(self, queries: list[str]) -> None: self.queries = queries async def execute(self, statement, params): sql = str(statement) self.queries.append(sql) if "INSERT INTO bitrix_sync.workflow_instances" in sql: return FakeResult(scalar=params["id"]) return FakeResult() @pytest.mark.asyncio async def test_status_uses_common_status_alias_for_workflows() -> None: repository = object.__new__(Repository) repository.engine = FakeEngine() result = await repository.status() assert result["queue"] == {"pending": 2} assert result["workflows"] == {"created": 3} assert result["commands"] == {"succeeded": 4} assert result["webhook_lag_seconds"] == 1.5 assert result["settings_version"] == 1 @pytest.mark.asyncio async def test_claims_recover_expired_leases() -> None: repository = object.__new__(Repository) engine = FakeEngine() repository.engine = engine assert await repository.claim_tasks("worker", 10, 60) == [] assert await repository.claim_webhooks("worker", 10, 60) == [] assert "status='leased' AND locked_until < now()" in engine.queries[0] assert "status='processing' AND locked_until None: repository = object.__new__(Repository) engine = FakeEngine() repository.engine = engine task = LeasedTask(uuid.uuid4(), "contact.map_or_create", uuid.uuid4(), uuid.uuid4(), 0) workflow_id = await repository.create_workflow(task) assert isinstance(workflow_id, uuid.UUID) assert "SET state='running'" in engine.queries[1] @pytest.mark.asyncio async def test_reconciliation_uses_unambiguous_external_id_alias() -> None: queries: list[str] = [] class Connection: async def execute(self, statement, params): queries.append(str(statement)) return FakeResult() class ReconciliationRepository: @asynccontextmanager async def transaction(self): yield Connection() reconciler = IncrementalReconciler(ReconciliationRepository(), None, "ufCrm_1") await reconciler._enqueue_changed( [ ("29406", "2026-08-20T13:21:00+00:00"), ("29502", "2026-08-20T13:22:00+00:00"), ] ) assert "AS candidate(external_id,event_id)" in queries[0] assert "w.external_entity_id=candidate.external_id" in queries[0] assert "ON CONFLICT (receiver_type,event_id)" in queries[0] @pytest.mark.asyncio async def test_apply_crm_profile_hashes_without_database_digest() -> None: calls: list[tuple[str, dict | None]] = [] class Connection: async def execute(self, statement, params=None): calls.append((str(statement), params)) return FakeResult() class Engine: @asynccontextmanager async def begin(self): yield Connection() repository = object.__new__(Repository) repository.engine = Engine() source_updated_at = datetime.now(UTC) await repository.apply_crm_profile( uuid.uuid4(), "29406", full_name="Тестовый пользователь", citizenship=None, email=None, source_updated_at=source_updated_at, source="webhook", ) profile_sql, profile_params = calls[1] snapshot_sql, snapshot_params = calls[2] assert "source_updated_at=:source_updated_at" in profile_sql assert profile_params["source_updated_at"] == source_updated_at assert "digest(" not in snapshot_sql assert "CAST(:external_id AS varchar(128))" in snapshot_sql assert "CAST(:source AS varchar(24))" in snapshot_sql assert snapshot_params["full_name_hash"] == safe_hash("Тестовый пользователь") assert snapshot_params["email_hash"] == safe_hash("")