Текущая стабильная сборка с исправлением интеграционных косяков

This commit is contained in:
mi
2026-08-20 11:03:32 +03:00
parent c7a80e7256
commit 6e24278b10
12 changed files with 127 additions and 32 deletions
@@ -15,4 +15,4 @@ COPY --chown=10001:10001 alembic.ini openapi.yaml /srv/
WORKDIR /srv
USER 10001:10001
EXPOSE 8080
ENTRYPOINT ["han-bitrix-sync-api"]
CMD ["han-bitrix-sync-api"]
@@ -9,7 +9,7 @@ from urllib.parse import urlsplit
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
FIELD_RE = re.compile(r"^UF_CRM_[0-9]+$")
FIELD_RE = re.compile(r"^UF_CRM_[A-Za-z0-9]+$")
MEMBER_RE = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
@@ -107,7 +107,7 @@ class Settings(BaseSettings):
"contact_citizenship_field",
):
if not FIELD_RE.fullmatch(str(getattr(self, name))):
raise ValueError(f"{name} must match UF_CRM_<digits>")
raise ValueError(f"{name} must match UF_CRM_<latin letters or digits>")
if not MEMBER_RE.fullmatch(str(self.portal_member_id)):
raise ValueError("portal_member_id has invalid format")
@@ -5,7 +5,8 @@ import uuid
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import text
from sqlalchemy import bindparam, text
from sqlalchemy.dialects.postgresql import JSONB
from app.config import Settings
from app.crm import CrmClient, CrmOutcome, CrmResult
@@ -19,6 +20,26 @@ from app.domain import (
)
from app.repository import LeasedTask, LeasedWebhook, Profile, Repository
INSERT_CRM_COMMAND = text(
"""
INSERT INTO bitrix_sync.crm_commands
(id,workflow_id,command_type,safe_request,status,attempt_count,
next_attempt_at,created_at,updated_at)
VALUES (:id,:workflow_id,:type,:safe_request,'in_flight',1,now(),now(),now())
"""
).bindparams(bindparam("safe_request", type_=JSONB()))
UPDATE_CRM_COMMAND = text(
"""
UPDATE bitrix_sync.crm_commands
SET status=:status,safe_error_code=:error,http_status=:http_status,
safe_response=:safe_response,
completed_at=CASE WHEN :terminal THEN now() END,
updated_at=now()
WHERE id=:id
"""
).bindparams(bindparam("safe_response", type_=JSONB()))
class BusinessConflict(Exception):
def __init__(self, code: str, candidates: list[str] | None = None) -> None:
@@ -452,14 +473,7 @@ class WorkflowEngine:
command_id = uuid.uuid4()
async with self.repository.transaction() as connection:
await connection.execute(
text(
"""
INSERT INTO bitrix_sync.crm_commands
(id,workflow_id,command_type,safe_request,status,attempt_count,
next_attempt_at,created_at,updated_at)
VALUES (:id,:workflow_id,:type,:safe_request,'in_flight',1,now(),now(),now())
"""
),
INSERT_CRM_COMMAND,
{
"id": command_id,
"workflow_id": workflow_id,
@@ -479,16 +493,7 @@ class WorkflowEngine:
status = result.outcome.value
async with self.repository.transaction() as connection:
await connection.execute(
text(
"""
UPDATE bitrix_sync.crm_commands
SET status=:status,safe_error_code=:error,http_status=:http_status,
safe_response=:safe_response,
completed_at=CASE WHEN :terminal THEN now() END,
updated_at=now()
WHERE id=:id
"""
),
UPDATE_CRM_COMMAND,
{
"id": command_id,
"status": status,
@@ -506,7 +506,8 @@ class Repository:
queries = {
"queue": "SELECT status, count(*) count FROM han_app.sync_queue GROUP BY status",
"workflows": (
"SELECT state, count(*) count FROM bitrix_sync.workflow_instances GROUP BY state"
"SELECT state AS status, count(*) count "
"FROM bitrix_sync.workflow_instances GROUP BY state"
),
"commands": (
"SELECT status, count(*) count "
@@ -37,8 +37,25 @@ def test_full_mode_rejects_portal_host_mismatch() -> None:
def test_rest_field_conversion_is_deterministic() -> None:
assert Settings.rest_field_name("UF_CRM_1778692456") == "ufCrm_1778692456"
assert Settings.rest_field_name("UF_CRM_6a70c275346a7") == "ufCrm_6a70c275346a7"
assert Settings.rest_field_name("UF_CRM_AbC123") == "ufCrm_AbC123"
with pytest.raises(ValueError):
Settings.rest_field_name("uf_crm_1")
with pytest.raises(ValueError):
Settings.rest_field_name("UF_CRM_123_abc")
def test_image_default_allows_compose_process_role_override() -> None:
service_root = Path(__file__).parents[1]
dockerfile = (service_root / "Dockerfile").read_text(encoding="utf-8")
compose = (service_root / "compose.fragment.yaml").read_text(encoding="utf-8")
production_compose = (service_root.parent / "docker-compose.yml").read_text(encoding="utf-8")
assert 'CMD ["han-bitrix-sync-api"]' in dockerfile
assert 'ENTRYPOINT ["han-bitrix-sync-api"]' not in dockerfile
for source in (compose, production_compose):
assert 'command: ["han-bitrix-sync-worker"]' in source
assert 'command: ["han-bitrix-sync-reconciliation"]' in source
def test_alembic_chain_preserves_legacy_baseline() -> None:
@@ -4,9 +4,10 @@ import uuid
from contextlib import asynccontextmanager
import pytest
from sqlalchemy.dialects.postgresql import JSONB
from app.crm import CrmOutcome, CrmResult
from app.engine import WorkflowEngine
from app.engine import INSERT_CRM_COMMAND, UPDATE_CRM_COMMAND, WorkflowEngine
from app.repository import Profile
@@ -57,6 +58,11 @@ class FakeCrm:
return CrmResult(CrmOutcome.SUCCEEDED, True)
def test_crm_command_json_payloads_have_explicit_jsonb_types() -> None:
assert isinstance(INSERT_CRM_COMMAND._bindparams["safe_request"].type, JSONB)
assert isinstance(UPDATE_CRM_COMMAND._bindparams["safe_response"].type, JSONB)
@pytest.mark.asyncio
async def test_multiple_contacts_choose_numeric_newest(full_settings) -> None:
user_id = uuid.uuid4()
@@ -0,0 +1,57 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from types import SimpleNamespace
import pytest
from app.repository import Repository
class FakeResult:
def __init__(self, *, rows=(), scalar=None) -> None:
self.rows = rows
self.scalar = scalar
def __iter__(self):
return iter(self.rows)
def scalar_one_or_none(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:
@asynccontextmanager
async def connect(self):
yield FakeConnection()
@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