179 lines
5.5 KiB
Python
179 lines
5.5 KiB
Python
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<now()" in engine.queries[1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_workflow_marks_recovered_workflow_running() -> 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("")
|