Реализация на отдельных двух машинах с протестированным взаимодействием по проверке сообщений
This commit is contained in:
@@ -13,6 +13,7 @@ from app.integrations import (
|
||||
)
|
||||
from app.realtime import CHANNEL_PREFIX
|
||||
from app.settings import Settings
|
||||
from app.workers import worker_http_clients
|
||||
|
||||
|
||||
def settings() -> Settings:
|
||||
@@ -25,8 +26,9 @@ def settings() -> Settings:
|
||||
"KEYCLOAK_INTERNAL_URL": "http://keycloak:8080",
|
||||
"KEYCLOAK_REALM": "han",
|
||||
"KEYCLOAK_AUDIENCE": "api",
|
||||
"MESSAGE_SAFETY_URL": "http://safety:8080",
|
||||
"MESSAGE_SAFETY_URL": "https://processing.internal:8443",
|
||||
"MESSAGE_SAFETY_SERVICE_TOKEN": "safety-token",
|
||||
"MESSAGE_SAFETY_CA_FILE": "/run/config/message-safety-internal-ca.pem",
|
||||
"BITRIX_LOCAL_APP_BASE_URL": "http://bitrix:8080",
|
||||
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": "bitrix-token",
|
||||
"BITRIX_API_INBOX_TOKEN": "inbox-token",
|
||||
@@ -42,6 +44,85 @@ def settings() -> Settings:
|
||||
return Settings.model_validate(common)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_uses_isolated_tls_client_for_remote_safety(monkeypatch) -> None:
|
||||
created = []
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.closed = False
|
||||
created.append(self)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
self.closed = True
|
||||
|
||||
monkeypatch.setattr("app.workers.httpx.AsyncClient", FakeAsyncClient)
|
||||
|
||||
async with worker_http_clients(settings()) as (openlines_http, safety_http):
|
||||
assert openlines_http.kwargs == {}
|
||||
assert safety_http.kwargs == {
|
||||
"verify": "/run/config/message-safety-internal-ca.pem"
|
||||
}
|
||||
assert openlines_http is not safety_http
|
||||
|
||||
assert all(client.closed for client in created)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safety_ready_uses_private_status_alias_and_accepts_redis_degradation() -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/internal/safety/status"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"status": "degraded",
|
||||
"processing_mode": "standard",
|
||||
"config_version": 1,
|
||||
"capabilities": {
|
||||
"text": "ready",
|
||||
"links": "ready",
|
||||
"files": "ready",
|
||||
"worker": "ready",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
assert await SafetyClient(settings(), http).ready() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("processing_mode", "files"),
|
||||
(("mock", "ready"), ("standard", "unavailable")),
|
||||
)
|
||||
async def test_safety_ready_rejects_unsafe_mode_or_unavailable_capability(
|
||||
processing_mode: str, files: str
|
||||
) -> None:
|
||||
async def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"status": "degraded",
|
||||
"processing_mode": processing_mode,
|
||||
"config_version": 1,
|
||||
"capabilities": {
|
||||
"text": "ready",
|
||||
"links": "ready",
|
||||
"files": files,
|
||||
"worker": "ready",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
assert await SafetyClient(settings(), http).ready() is False
|
||||
|
||||
|
||||
def test_realtime_channel_matches_redis_acl_namespace() -> None:
|
||||
assert CHANNEL_PREFIX == "han:rt:dialog:"
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
@@ -14,7 +17,12 @@ from app.schemas import (
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
)
|
||||
from app.services import MESSAGE_SAFETY_REPLIES, safety_reply_message
|
||||
from app.services import (
|
||||
MESSAGE_SAFETY_REPLIES,
|
||||
ensure_delivery_outbox,
|
||||
safety_reply_message,
|
||||
safety_task_recovery_at,
|
||||
)
|
||||
|
||||
|
||||
def test_asyncpg_receives_libpq_dsn_without_sqlalchemy_driver() -> None:
|
||||
@@ -67,6 +75,41 @@ def test_message_safety_business_replies_are_content_specific() -> None:
|
||||
assert reply.delivery_status == "delivered"
|
||||
|
||||
|
||||
def test_safety_recovery_starts_after_synchronous_polling_window() -> None:
|
||||
now = datetime(2026, 8, 19, tzinfo=UTC)
|
||||
settings = SimpleNamespace(
|
||||
message_safety_task_poll_max_sec=300,
|
||||
message_safety_task_poll_interval_sec=2,
|
||||
)
|
||||
|
||||
assert (safety_task_recovery_at(now, settings) - now).total_seconds() == 307
|
||||
|
||||
|
||||
async def test_delivery_outbox_returns_concurrent_insert_winner() -> None:
|
||||
existing = object()
|
||||
session = SimpleNamespace(
|
||||
execute=AsyncMock(
|
||||
side_effect=[
|
||||
SimpleNamespace(scalar_one_or_none=lambda: None),
|
||||
SimpleNamespace(scalar_one=lambda: existing),
|
||||
]
|
||||
),
|
||||
get=AsyncMock(),
|
||||
)
|
||||
|
||||
result = await ensure_delivery_outbox(
|
||||
session,
|
||||
message_id=uuid.uuid4(),
|
||||
external_chat_id=uuid.uuid4(),
|
||||
payload_json={"message_id": "test"},
|
||||
next_attempt_at=datetime(2026, 8, 19, tzinfo=UTC),
|
||||
)
|
||||
|
||||
assert result is existing
|
||||
assert session.execute.await_count == 2
|
||||
session.get.assert_not_awaited()
|
||||
|
||||
|
||||
def test_fingerprint_is_canonical_and_user_scoped() -> None:
|
||||
user = uuid.uuid4()
|
||||
first = canonical_fingerprint("post", "/dialogs/{id}", {"id": "1"}, {"b": 2, "a": 1}, user)
|
||||
|
||||
Reference in New Issue
Block a user