Финальная стабильная реализация

This commit is contained in:
mi
2026-08-21 11:48:24 +03:00
parent 6e24278b10
commit d2415fcfeb
17 changed files with 662 additions and 57 deletions
@@ -1,11 +1,15 @@
from __future__ import annotations
import uuid
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
from app.repository import Repository
from app.domain import safe_hash
from app.reconciliation import IncrementalReconciler
from app.repository import LeasedTask, Repository
class FakeResult:
@@ -16,9 +20,15 @@ class FakeResult:
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):
@@ -38,10 +48,29 @@ class FakeConnection:
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:
@@ -55,3 +84,95 @@ async def test_status_uses_common_status_alias_for_workflows() -> None:
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("")