Финальная стабильная реализация
This commit is contained in:
@@ -21,5 +21,6 @@ def full_settings() -> Settings:
|
||||
contact_user_id_field="UF_CRM_100",
|
||||
contact_registered_field="UF_CRM_101",
|
||||
contact_citizenship_field="UF_CRM_102",
|
||||
contact_source="WEB",
|
||||
webhook_allowed_cidrs="203.0.113.0/24",
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ def test_full_mode_rejects_portal_host_mismatch() -> None:
|
||||
contact_user_id_field="UF_CRM_1",
|
||||
contact_registered_field="UF_CRM_2",
|
||||
contact_citizenship_field="UF_CRM_3",
|
||||
contact_source="WEB",
|
||||
webhook_allowed_cidrs="203.0.113.0/24",
|
||||
)
|
||||
|
||||
@@ -56,6 +57,10 @@ def test_image_default_allows_compose_process_role_override() -> None:
|
||||
for source in (compose, production_compose):
|
||||
assert 'command: ["han-bitrix-sync-worker"]' in source
|
||||
assert 'command: ["han-bitrix-sync-reconciliation"]' in source
|
||||
reconciliation_service = source.split(" bitrix-sync-reconciliation:", 1)[1].split(
|
||||
"\n bitrix-sync-", 1
|
||||
)[0]
|
||||
assert 'restart: "no"' in reconciliation_service
|
||||
|
||||
|
||||
def test_alembic_chain_preserves_legacy_baseline() -> None:
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
@@ -14,9 +15,54 @@ from app.repository import Profile
|
||||
class Result:
|
||||
rowcount = 1
|
||||
|
||||
def __init__(self, *, row=None, scalar=None) -> None:
|
||||
self.row = row
|
||||
self.scalar = scalar
|
||||
|
||||
def mappings(self):
|
||||
return self
|
||||
|
||||
def one(self):
|
||||
return self.row
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self.scalar
|
||||
|
||||
|
||||
class Connection:
|
||||
def __init__(self, statements: list[str]) -> None:
|
||||
self.statements = statements
|
||||
|
||||
async def execute(self, statement, params=None):
|
||||
sql = str(statement)
|
||||
self.statements.append(sql)
|
||||
if "INSERT INTO bitrix_sync.business_alerts" in sql:
|
||||
now = datetime.now(UTC)
|
||||
return Result(
|
||||
row={
|
||||
"id": uuid.uuid4(),
|
||||
"alert_number": 1,
|
||||
"fingerprint": "fingerprint",
|
||||
"alert_type": params["type"],
|
||||
"severity": "warning",
|
||||
"app_user_id": params["user_id"],
|
||||
"selected_external_id": params["selected_external_id"],
|
||||
"candidate_external_ids": params["candidates"],
|
||||
"remote_item_id": None,
|
||||
"occurrence_count": 1,
|
||||
"first_occurred_at": now,
|
||||
"last_occurred_at": now,
|
||||
}
|
||||
)
|
||||
if "SELECT value_json FROM bitrix_sync.settings" in sql:
|
||||
return Result(
|
||||
scalar={
|
||||
"entity_type_id": 178,
|
||||
"category_id": 5,
|
||||
"stage_new": "DT178_5:NEW",
|
||||
"field_ids": {"candidate_external_ids": "ufCrm_999"},
|
||||
}
|
||||
)
|
||||
return Result()
|
||||
|
||||
|
||||
@@ -27,7 +73,7 @@ class FakeRepository:
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
yield Connection()
|
||||
yield Connection(self.statements)
|
||||
|
||||
async def active_mapping(self, user_id):
|
||||
return self.mapping
|
||||
@@ -37,8 +83,16 @@ class FakeRepository:
|
||||
|
||||
|
||||
class FakeCrm:
|
||||
def __init__(self, user_id: uuid.UUID) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
user_id: uuid.UUID,
|
||||
*,
|
||||
existing_identity: bool = False,
|
||||
foreign_owner: bool = False,
|
||||
) -> None:
|
||||
self.user_id = user_id
|
||||
self.existing_identity = existing_identity
|
||||
self.foreign_owner = foreign_owner
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
|
||||
async def call(self, method, params, *, mutating):
|
||||
@@ -52,9 +106,19 @@ class FakeCrm:
|
||||
{
|
||||
"ID": contact_id,
|
||||
"CREATED_TIME": "2026-08-06T10:00:00Z",
|
||||
"UF_CRM_100": None,
|
||||
"UF_CRM_100": (
|
||||
str(self.user_id)
|
||||
if self.existing_identity and contact_id == "10"
|
||||
else str(uuid.UUID(int=1))
|
||||
if self.foreign_owner and contact_id == "10"
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
if method == "crm.contact.add":
|
||||
return CrmResult(CrmOutcome.SUCCEEDED, "11")
|
||||
if method == "crm.item.add":
|
||||
return CrmResult(CrmOutcome.SUCCEEDED, {"item": {"id": "500"}})
|
||||
return CrmResult(CrmOutcome.SUCCEEDED, True)
|
||||
|
||||
|
||||
@@ -76,3 +140,46 @@ async def test_multiple_contacts_choose_numeric_newest(full_settings) -> None:
|
||||
updates = [params for method, params in crm.calls if method == "crm.contact.update"]
|
||||
assert updates[0]["id"] == "10"
|
||||
assert not any(method == "crm.contact.add" for method, _ in crm.calls)
|
||||
assert any(method == "crm.item.add" for method, _ in crm.calls)
|
||||
alert_add = next(params for method, params in crm.calls if method == "crm.item.add")
|
||||
assert alert_add["fields"]["contactIds"] == [9, 10]
|
||||
assert alert_add["fields"]["sourceId"] == "WEB"
|
||||
assert "ufCrm_999" not in alert_add["fields"]
|
||||
assert any("entity_external_mapping" in statement for statement in repository.statements)
|
||||
assert not any("digest(" in statement for statement in repository.statements)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recovered_duplicate_creates_alert_before_mapping(full_settings) -> None:
|
||||
user_id = uuid.uuid4()
|
||||
repository = FakeRepository()
|
||||
crm = FakeCrm(user_id, existing_identity=True)
|
||||
engine = WorkflowEngine(repository, crm, full_settings)
|
||||
|
||||
await engine._map_or_create(
|
||||
uuid.uuid4(),
|
||||
Profile(user_id=user_id, phone="+79001234567", identity_status="A", profile_status="A"),
|
||||
)
|
||||
|
||||
methods = [method for method, _ in crm.calls]
|
||||
assert "crm.item.add" in methods
|
||||
assert "crm.contact.update" not in methods
|
||||
assert any("entity_external_mapping" in statement for statement in repository.statements)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_foreign_owned_contact_alert_includes_new_contact(full_settings) -> None:
|
||||
user_id = uuid.uuid4()
|
||||
repository = FakeRepository()
|
||||
crm = FakeCrm(user_id, foreign_owner=True)
|
||||
engine = WorkflowEngine(repository, crm, full_settings)
|
||||
|
||||
await engine._map_or_create(
|
||||
uuid.uuid4(),
|
||||
Profile(user_id=user_id, phone="+79001234567", identity_status="A", profile_status="A"),
|
||||
)
|
||||
|
||||
methods = [method for method, _ in crm.calls]
|
||||
alert_add = next(params for method, params in crm.calls if method == "crm.item.add")
|
||||
assert methods.index("crm.contact.add") < methods.index("crm.item.add")
|
||||
assert alert_add["fields"]["contactIds"] == [9, 10, 11]
|
||||
|
||||
@@ -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("")
|
||||
|
||||
Reference in New Issue
Block a user