Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,153 @@
import hashlib
import uuid
from types import SimpleNamespace
import pytest
from starlette.requests import Request
from app.main import (
app,
client_ip,
request_trace_id,
required_user_audit_context,
user_agent_hash,
)
from app.schemas import Device
from app.services import AuditContext, DomainError, audit, device_snapshot
def request(
*,
peer: str = "172.18.0.5",
forwarded: str | None = None,
traceparent: str | None = None,
user_agent: str | None = None,
ux_session_id: str | None = None,
trusted: str = "172.16.0.0/12",
) -> Request:
headers: list[tuple[bytes, bytes]] = []
for name, value in (
("x-forwarded-for", forwarded),
("traceparent", traceparent),
("user-agent", user_agent),
("x-ux-session-id", ux_session_id),
):
if value is not None:
headers.append((name.encode(), value.encode()))
settings = SimpleNamespace(trusted_proxy_cidrs=trusted)
app = SimpleNamespace(state=SimpleNamespace(settings=settings))
return Request(
{
"type": "http",
"method": "GET",
"path": "/",
"headers": headers,
"client": (peer, 12345),
"server": ("test", 443),
"scheme": "https",
"query_string": b"",
"app": app,
}
)
def test_client_ip_only_trusts_forwarded_header_from_configured_proxy() -> None:
assert client_ip(request(forwarded="203.0.113.10")) == "203.0.113.10"
assert (
client_ip(request(peer="198.51.100.7", forwarded="203.0.113.10"))
== "198.51.100.7"
)
assert client_ip(request(peer="not-an-ip")) is None
def test_trace_id_uses_valid_w3c_header_and_generates_fallback() -> None:
trace_id = "1" * 32
assert request_trace_id(request(traceparent=f"00-{trace_id}-{'2' * 16}-01")) == trace_id
fallback = request_trace_id(request(traceparent="invalid"))
assert len(fallback) == 32
int(fallback, 16)
def test_user_agent_is_hashed_and_device_parameters_are_preserved() -> None:
agent = "Example Browser/1.0"
assert user_agent_hash(request(user_agent=agent)) == hashlib.sha256(agent.encode()).hexdigest()
snapshot = device_snapshot(
Device(platform="web", app_version="1.2.3", device_id="raw-device-id")
)
assert snapshot == {
"platform": "web",
"app_version": "1.2.3",
"device_id": "raw-device-id",
}
def test_audit_copies_request_context_and_bounded_metadata() -> None:
session_id = uuid.uuid4()
user_id = uuid.uuid4()
context = AuditContext(
request_id=str(uuid.uuid4()),
trace_id="a" * 32,
ux_session_id=session_id,
user_agent_hash="b" * 64,
client_ip="203.0.113.10",
)
event = audit(
"dialog.created",
context,
user_id,
"dialog",
uuid.uuid4(),
metadata={"status": "open"},
)
assert event.user_id == user_id
assert event.ux_session_id == session_id
assert event.trace_id == "a" * 32
assert event.user_agent_hash == "b" * 64
assert event.metadata_json == {"status": "open"}
assert event.outcome == "success"
assert not hasattr(event, "ip")
failed = audit("message.failed", context, user_id, outcome="failed")
assert failed.outcome == "failed"
def test_post_session_routes_publish_required_ux_header_in_openapi() -> None:
operation = app.openapi()["paths"]["/api/v1/dialogs"]["post"]
ux_header = next(
parameter
for parameter in operation["parameters"]
if parameter["name"] == "X-Ux-Session-Id"
)
assert ux_header["in"] == "header"
assert ux_header["required"] is True
@pytest.mark.asyncio
async def test_user_audit_context_requires_existing_owned_session() -> None:
session_id = uuid.uuid4()
user = SimpleNamespace(id=uuid.uuid4())
incoming = request(
forwarded="203.0.113.10",
ux_session_id=str(session_id),
user_agent="Example Browser/1.0",
)
incoming.state.request_id = str(uuid.uuid4())
incoming.state.trace_id = "c" * 32
incoming.state.user_agent_hash = user_agent_hash(incoming)
class Db:
async def scalar(self, _query):
return session_id
context = await required_user_audit_context(incoming, Db(), user, str(session_id))
assert context.ux_session_id == session_id
assert context.client_ip == "203.0.113.10"
missing = request(forwarded="203.0.113.10")
missing.state.request_id = str(uuid.uuid4())
missing.state.trace_id = "d" * 32
missing.state.user_agent_hash = None
with pytest.raises(DomainError, match="X-Ux-Session-Id is required"):
await required_user_audit_context(missing, Db(), user, "")
@@ -0,0 +1,78 @@
from pathlib import Path
import pytest
from app.cli.seed_settings import load_seed
from app.services import REQUIRED_SETTINGS
def test_production_like_seed_contains_all_mandatory_settings() -> None:
path = Path(__file__).resolve().parents[3] / "deployment/app-settings.production-like.yaml"
rows = load_seed(path)
assert REQUIRED_SETTINGS <= {row["setting_key"] for row in rows}
assert all(row["record_status"] == "A" for row in rows)
values = {row["setting_key"]: row["setting_value"] for row in rows}
assert values["otp.phone.code_length"] == "6"
assert values["otp.phone.ttl_seconds"] == "60"
assert values["otp.phone.sms_order_timeout_ms"] == "3000"
assert values["chat.message.max_length"] == "4000"
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
"schema_version: 1\nsettings:\n"
" bad.integer: {type: integer, value: nope, public: false}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="integer value expected"):
load_seed(path)
@pytest.mark.parametrize(
("key", "value", "message"),
[
("otp.phone.code_length", 3, "between 4 and 10"),
("otp.phone.ttl_seconds", 61, "divisible by 60"),
("otp.phone.sms_order_timeout_ms", 0, "must be positive"),
],
)
def test_seed_rejects_invalid_otp_settings(
tmp_path: Path, key: str, value: int, message: str
) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
"schema_version: 1\nsettings:\n"
f" {key}: {{type: integer, value: {value}, public: false}}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match=message):
load_seed(path)
def test_seed_rejects_public_otp_setting(tmp_path: Path) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
"schema_version: 1\nsettings:\n"
" otp.phone.code_length: {type: integer, value: 6, public: true}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="must not be public"):
load_seed(path)
@pytest.mark.parametrize("value", [0, 10001])
def test_seed_rejects_invalid_chat_message_max_length(tmp_path: Path, value: int) -> None:
path = tmp_path / "settings.yaml"
path.write_text(
"schema_version: 1\nsettings:\n"
f" chat.message.max_length: {{type: integer, value: {value}, public: true}}\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="value must be between 1 and 10000"):
load_seed(path)
@@ -0,0 +1,104 @@
import uuid
import pytest
from pydantic import TypeAdapter, ValidationError
from app.auth import canonical_phone
from app.integrations import CircuitBreaker, RateLimiter
from app.postgres import asyncpg_dsn
from app.schemas import (
FileMessageRequest,
MessageRequest,
TextMessageRequest,
canonical_fingerprint,
decode_cursor,
encode_cursor,
)
from app.services import MESSAGE_SAFETY_REPLIES, safety_reply_message
def test_asyncpg_receives_libpq_dsn_without_sqlalchemy_driver() -> None:
url = (
"postgresql+asyncpg://user:password@db:5433/han_chat"
"?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem"
)
assert asyncpg_dsn(url) == url.replace("postgresql+asyncpg://", "postgresql://", 1)
def test_phone_claim_priority_and_e164_validation() -> None:
claims = {"phone_number": "+74999591007", "preferred_username": "+12025550123"}
assert canonical_phone(claims) == "+74999591007"
assert canonical_phone({"phone_number": "89999999999"}) is None
def test_message_discriminated_union() -> None:
adapter = TypeAdapter(MessageRequest)
assert isinstance(
adapter.validate_python({"content_kind": "text", "text": "Здравствуйте"}),
TextMessageRequest,
)
assert isinstance(
adapter.validate_python(
{
"content_kind": "file",
"attachment_id": str(uuid.uuid4()),
"checksum": "sha256:" + "a" * 64,
}
),
FileMessageRequest,
)
with pytest.raises(ValidationError):
adapter.validate_python(
{"content_kind": "text", "text": "", "attachment_id": str(uuid.uuid4())}
)
longest = adapter.validate_python({"content_kind": "text", "text": "а" * 10_000})
assert len(longest.text) == 10_000
with pytest.raises(ValidationError):
adapter.validate_python({"content_kind": "text", "text": "а" * 10_001})
def test_message_safety_business_replies_are_content_specific() -> None:
assert "переформулировать" in MESSAGE_SAFETY_REPLIES["text"]
assert "документ" in MESSAGE_SAFETY_REPLIES["file"]
reply = safety_reply_message(uuid.uuid4(), "text")
assert reply.sender_type == "company"
assert reply.safety_status == "allowed"
assert reply.delivery_status == "delivered"
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)
second = canonical_fingerprint("POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, user)
assert first == second
assert first != canonical_fingerprint(
"POST", "/dialogs/{id}", {"id": "1"}, {"a": 1, "b": 2}, uuid.uuid4()
)
def test_cursor_roundtrip_and_tamper_rejection() -> None:
secret = b"test-secret" * 4
cursor = encode_cursor({"id": str(uuid.uuid4()), "created_at": "2026-01-01"}, secret)
assert decode_cursor(cursor, secret)["created_at"] == "2026-01-01"
with pytest.raises(ValueError, match="invalid cursor"):
decode_cursor(cursor[:-2] + "aa", secret)
def test_rate_limit_keys_do_not_expose_identity() -> None:
key = RateLimiter.key("ip", "203.0.113.7", "public", 60)
assert "203.0.113.7" not in key
assert key.startswith("han:api:rl:ip:")
def test_circuit_breaker_opens_and_half_opens(monkeypatch: pytest.MonkeyPatch) -> None:
clock = [10.0]
monkeypatch.setattr("app.integrations.time.monotonic", lambda: clock[0])
breaker = CircuitBreaker(2, 30)
breaker.failure()
breaker.failure()
assert not breaker.allow()
clock[0] = 41.0
assert breaker.allow()
breaker.success()
assert breaker.allow()
@@ -0,0 +1,31 @@
from app.logging_security import REDACTED, redact_event, sanitize_text
def test_redacts_sensitive_fields_recursively() -> None:
event = {
"authorization": "Bearer top-secret",
"nested": {
"database_url": "postgresql://user:password@db/app",
"safe": "kept",
},
}
redacted = redact_event(None, "info", event)
assert redacted["authorization"] == REDACTED
assert redacted["nested"]["database_url"] == REDACTED
assert redacted["nested"]["safe"] == "kept"
def test_redacts_credentials_embedded_in_text() -> None:
value = (
"POST https://callback-user:callback-password@example.test/cb"
"?token=query-secret Authorization=Bearer header-secret"
)
redacted = sanitize_text(value)
assert "callback-password" not in redacted
assert "query-secret" not in redacted
assert "header-secret" not in redacted
assert redacted.count(REDACTED) == 3
@@ -0,0 +1,589 @@
import ast
import json
import uuid
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock, Mock
import pytest
from pydantic import ValidationError
from app.db import Document
from app.notification_models import (
ClientUploadDraft,
Notification,
NotificationButton,
NotificationDocument,
NotificationSource,
NotificationType,
uuid7,
)
from app.notification_schemas import NotificationCreateRequest
from app.notification_service import (
_apply_hidden_ttl,
apply_read_or_hide,
authenticate_source,
catalog,
create_notification,
document_download,
expire_notifications,
fingerprint,
invoke_cta_state,
press_button,
source_token_hash,
validate_create,
)
from app.realtime import USER_CHANNEL_PREFIX, RealtimeFanout
from app.services import AuditContext, DomainError, SettingsSnapshot
from app.workers import notification_draft_cleanup_once
def test_notification_migration_does_not_prepare_multiple_sql_commands() -> None:
migration = Path("alembic/versions/0008_notification_center_v1.py")
tree = ast.parse(migration.read_text(encoding="utf-8"))
for call in (node for node in ast.walk(tree) if isinstance(node, ast.Call)):
if (
isinstance(call.func, ast.Attribute)
and call.func.attr == "execute"
and call.args
and isinstance(call.args[0], ast.Call)
and isinstance(call.args[0].func, ast.Attribute)
and call.args[0].func.attr == "text"
and call.args[0].args
and isinstance(call.args[0].args[0], ast.Constant)
and isinstance(call.args[0].args[0].value, str)
):
assert call.args[0].args[0].value.count(";") <= 1
def create_body(**changes: object) -> NotificationCreateRequest:
values: dict[str, object] = {
"user_id": uuid.uuid4(),
"notification_type": "news",
"source": "producer_test",
"external_id": "event-1",
"notification_datetime": "2026-07-27T12:00:00Z",
"header": "Новость",
"details": {"details_text": "Текст"},
}
values.update(changes)
return NotificationCreateRequest.model_validate(values)
def context() -> AuditContext:
return AuditContext("request-1", "trace-1", None, None, None)
def snapshot(default_ttl: int = 3) -> SettingsSnapshot:
return SettingsSnapshot(
{
"notification.hidden.default_ttl_days": str(default_ttl),
"notification.center.max_items": "15",
},
"v1",
)
def notification(**changes: object) -> Notification:
values: dict[str, object] = {
"id": uuid.uuid4(),
"user_id": uuid.uuid4(),
"notification_type": "news",
"source": "producer_test",
"external_id": "event-1",
"request_fingerprint": "a" * 64,
"notification_datetime": datetime.now(UTC),
"header": "Header",
"lifecycle_status": "active",
"visibility": "visible",
"is_read": False,
"date_expired": None,
"close_reason": None,
"closed_at": None,
}
values.update(changes)
return Notification(**values)
def kind(**changes: object) -> NotificationType:
values: dict[str, object] = {
"id": uuid.uuid4(),
"code": "news",
"contour": "P",
"priority": 4,
"countable": True,
"label": "Новость",
"color_token": "info",
"icon_code": "news",
"cta_text": "Подробнее",
"cta_action": "open_detail",
"cta_sets_hidden": False,
"cta_close_reason": None,
"button_primary_code": "gotit",
"button_secondary_code": None,
"hidden_ttl_days": None,
"documents_allowed": False,
"hide_on_document_download": False,
"required_detail_blocks": [],
}
values.update(changes)
return NotificationType(**values)
class ScalarRows:
def __init__(self, rows: list[object]) -> None:
self.rows = rows
def scalars(self) -> "ScalarRows":
return self
def all(self) -> list[object]:
return self.rows
def test_uuid7_has_rfc_version_variant_and_embedded_timestamp(
monkeypatch: pytest.MonkeyPatch,
) -> None:
timestamp_ns = 1_722_340_800_123_000_000
monkeypatch.setattr("app.notification_models.time.time_ns", lambda: timestamp_ns)
monkeypatch.setattr("app.notification_models.secrets.randbits", lambda bits: (1 << bits) - 1)
value = uuid7()
assert value.version == 7
assert value.variant == uuid.RFC_4122
assert value.int >> 80 == timestamp_ns // 1_000_000
def test_fingerprint_is_canonical_stable_and_sensitive_to_body() -> None:
first = create_body()
same = NotificationCreateRequest.model_validate(first.model_dump(mode="json"))
changed = create_body(header="Другая новость")
assert fingerprint(first) == fingerprint(same)
assert fingerprint(first) != fingerprint(changed)
assert len(fingerprint(first)) == 64
def test_create_schema_rejects_read_only_or_unknown_detail_blocks() -> None:
with pytest.raises(ValidationError) as pending:
create_body(details={"details_text": "Text", "pending_documents": []})
with pytest.raises(ValidationError) as unknown:
create_body(details={"details_text": "Text", "invented": True})
assert "pending_documents" in str(pending.value)
assert "invented" in str(unknown.value)
def test_catalog_driven_create_validation_covers_required_and_forbidden_fields() -> None:
action = SimpleNamespace(required_instance_fields=["details"])
docs_kind = kind(
required_detail_blocks=["documents"],
documents_allowed=True,
button_primary_code="gotit",
)
with pytest.raises(DomainError) as error:
validate_create(create_body(details={"details_text": "No documents"}), docs_kind, action)
assert error.value.code == "validation_error"
assert error.value.details["fields"] == ["details.documents"]
with pytest.raises(DomainError) as forbidden:
validate_create(
create_body(
details={"details_text": "Text"},
payment_url="https://pay.example/order",
),
kind(),
action,
)
assert forbidden.value.details["fields"] == ["payment_url"]
@pytest.mark.asyncio
async def test_duplicate_create_returns_existing_only_for_matching_fingerprint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
body = create_body()
existing = notification(request_fingerprint=fingerprint(body))
session = SimpleNamespace(
scalar=AsyncMock(return_value=existing),
add=Mock(),
commit=AsyncMock(),
)
dto = {"id": str(existing.id)}
monkeypatch.setattr(
"app.notification_service.notification_dto", AsyncMock(return_value=dto)
)
result, status = await create_notification(
session,
body,
SimpleNamespace(code="producer_test"),
SimpleNamespace(),
SimpleNamespace(),
context(),
)
assert (result, status) == (dto, 200)
existing.request_fingerprint = "0" * 64
with pytest.raises(DomainError) as conflict:
await create_notification(
session,
body,
SimpleNamespace(code="producer_test"),
SimpleNamespace(),
SimpleNamespace(),
context(),
)
assert conflict.value.code == "notification_conflict"
assert conflict.value.status == 409
assert conflict.value.details == {"notification_id": str(existing.id)}
def test_hidden_ttl_preserves_existing_expiry_and_uses_type_or_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
now = datetime(2026, 7, 27, 12, tzinfo=UTC)
monkeypatch.setattr("app.notification_service.datetime", SimpleNamespace(now=lambda tz: now))
existing = now + timedelta(hours=2)
with_expiry = notification(date_expired=existing)
type_specific = notification()
defaulted = notification()
_apply_hidden_ttl(with_expiry, kind(hidden_ttl_days=9), snapshot())
_apply_hidden_ttl(type_specific, kind(hidden_ttl_days=5), snapshot())
_apply_hidden_ttl(defaulted, kind(hidden_ttl_days=None), snapshot(3))
assert with_expiry.date_expired == existing
assert type_specific.date_expired == now + timedelta(days=5)
assert defaulted.date_expired == now + timedelta(days=3)
assert {with_expiry.visibility, type_specific.visibility, defaulted.visibility} == {"hidden"}
@pytest.mark.asyncio
async def test_hide_applies_ttl_without_marking_read(
monkeypatch: pytest.MonkeyPatch,
) -> None:
item = notification()
monkeypatch.setattr(
"app.notification_service.owned_notification",
AsyncMock(return_value=(item, kind(hidden_ttl_days=2))),
)
monkeypatch.setattr("app.notification_service.unread_count", AsyncMock(return_value=1))
session = SimpleNamespace(add=Mock(), commit=AsyncMock())
fanout = SimpleNamespace(publish_user=AsyncMock())
result = await apply_read_or_hide(
session,
item.user_id,
item.id,
"hide",
snapshot(),
fanout,
context(),
)
assert result["visibility"] == "hidden"
assert result["is_read"] is False
assert item.date_expired is not None
event = fanout.publish_user.await_args.args[1]
assert event["date_expired"] == item.date_expired
@pytest.mark.asyncio
async def test_cta_and_buttons_follow_catalog_lifecycle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
item = notification()
cta_kind = kind(cta_action="open_detail")
monkeypatch.setattr(
"app.notification_service.owned_notification",
AsyncMock(return_value=(item, cta_kind)),
)
monkeypatch.setattr("app.notification_service.unread_count", AsyncMock(return_value=0))
session = SimpleNamespace(add=Mock(), commit=AsyncMock(), scalar=AsyncMock())
fanout = SimpleNamespace(publish_user=AsyncMock())
_, _, cta_result = await invoke_cta_state(
session, item.user_id, item.id, snapshot(), fanout, context()
)
assert item.is_read is True
assert item.visibility == "visible"
assert item.lifecycle_status == "active"
assert cta_result["result"]["action"] == "open_detail"
button = NotificationButton(
id=uuid.uuid4(),
code="done",
label="Готово",
sets_hidden=False,
applies_hidden_ttl=False,
close_reason="user_done",
submits_documents=False,
)
session.scalar.return_value = button
cta_kind.button_primary_code = "done"
result = await press_button(
session, item.user_id, item.id, "done", snapshot(), fanout, context()
)
assert result["lifecycle_status"] == "closed"
assert result["close_reason"] == "user_done"
assert item.closed_at is not None
@pytest.mark.asyncio
async def test_first_download_of_any_document_hides_once_and_preserves_expiry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
expires = datetime.now(UTC) + timedelta(hours=1)
item = notification(date_expired=expires)
download_kind = kind(
code="docs_ready",
documents_allowed=True,
hide_on_document_download=True,
)
link = NotificationDocument(
id=uuid.uuid4(),
notification_id=item.id,
document_id=uuid.uuid4(),
sort_order=0,
download_url_issued_at=None,
)
document = Document(
id=link.document_id,
user_id=item.user_id,
name="result.pdf",
mime_type="application/pdf",
size_bytes=42,
checksum_sha256="a" * 64,
storage_bucket="documents",
object_key="documents/result.pdf",
sent_at=datetime.now(UTC),
)
row_result = SimpleNamespace(one_or_none=lambda: (link, document))
session = SimpleNamespace(
execute=AsyncMock(return_value=row_result),
scalar=AsyncMock(return_value=0),
add=Mock(),
commit=AsyncMock(),
)
monkeypatch.setattr(
"app.notification_service.owned_notification",
AsyncMock(return_value=(item, download_kind)),
)
monkeypatch.setattr("app.notification_service.unread_count", AsyncMock(return_value=0))
fanout = SimpleNamespace(publish_user=AsyncMock())
s3 = SimpleNamespace(presign_get=AsyncMock(return_value="https://download.example/file"))
result = await document_download(
session,
item.user_id,
item.id,
document.id,
snapshot(),
s3,
fanout,
context(),
)
assert result["download_url"] == "https://download.example/file"
assert item.is_read is True
assert item.visibility == "hidden"
assert item.date_expired == expires
assert link.download_url_issued_at is not None
fanout.publish_user.assert_awaited_once()
@pytest.mark.asyncio
async def test_source_token_hash_and_producer_authentication() -> None:
value = "producer-secret"
source = NotificationSource(
id=uuid.uuid4(),
code="producer_test",
description="test",
token_hash=source_token_hash(value),
token_rotated_at=None,
record_status="A",
)
session = SimpleNamespace(execute=AsyncMock(return_value=ScalarRows([source])))
assert await authenticate_source(session, f"Bearer {value}") is source
assert source.token_hash != value
with pytest.raises(DomainError) as invalid:
await authenticate_source(session, "Bearer wrong")
assert (invalid.value.code, invalid.value.status) == ("unauthorized", 401)
@pytest.mark.asyncio
async def test_catalog_dto_is_sorted_and_contains_only_public_behavior() -> None:
types = [
kind(code="news", contour="P", priority=4, button_primary_code="gotit"),
kind(
code="urgent",
contour="P",
priority=1,
label="Срочно",
button_primary_code="done",
button_secondary_code="later",
),
]
buttons = [
NotificationButton(id=uuid.uuid4(), code="done", label="Готово"),
NotificationButton(id=uuid.uuid4(), code="later", label="Позже"),
NotificationButton(id=uuid.uuid4(), code="gotit", label="Понятно"),
]
session = SimpleNamespace(
execute=AsyncMock(side_effect=[ScalarRows(types), ScalarRows(buttons)])
)
result = await catalog(session)
assert [item["code"] for item in result] == ["urgent", "news"]
assert result[0]["button_primary"] == {"code": "done", "label": "Готово"}
assert result[0]["button_secondary"] == {"code": "later", "label": "Позже"}
assert not {
"hidden_ttl_days",
"cta_sets_hidden",
"cta_close_reason",
"required_detail_blocks",
} & result[0].keys()
button_statement = session.execute.await_args_list[1].args[0]
assert "notification_buttons.record_status" in str(button_statement)
@pytest.mark.asyncio
async def test_realtime_uses_per_user_channel_and_strips_internal_identity() -> None:
user_id = uuid.uuid4()
class PubSub:
def __init__(self) -> None:
self.channels: tuple[str, ...] = ()
async def subscribe(self, *channels: str) -> None:
self.channels = channels
async def get_message(self, **_kwargs: object) -> dict[str, str]:
return {
"data": json.dumps(
{
"type": "notification.updated",
"_user_id": str(user_id),
"notification_id": str(uuid.uuid4()),
}
)
}
async def unsubscribe(self, *_channels: str) -> None:
return None
async def aclose(self) -> None:
return None
pubsub = PubSub()
redis = SimpleNamespace(publish=AsyncMock(), pubsub=lambda: pubsub)
fanout = RealtimeFanout(redis)
await fanout.publish_user(user_id, {"type": "notification.created"})
channel, payload = redis.publish.await_args.args
assert channel == USER_CHANNEL_PREFIX + str(user_id)
assert json.loads(payload)["_user_id"] == str(user_id)
stream = cast(
AsyncGenerator[dict[str, Any], None],
fanout.events(set(), user_id, notifications=True),
)
event = await anext(stream)
await stream.aclose()
assert pubsub.channels == (USER_CHANNEL_PREFIX + str(user_id),)
assert event["type"] == "notification.updated"
assert "_user_id" not in event
@pytest.mark.asyncio
async def test_expire_job_is_locked_set_based_and_commits() -> None:
session = SimpleNamespace(
scalar=AsyncMock(return_value=True),
execute=AsyncMock(
side_effect=[
SimpleNamespace(rowcount=3),
SimpleNamespace(rowcount=2),
]
),
add=Mock(),
commit=AsyncMock(),
)
assert await expire_notifications(session) == (3, 2)
assert session.execute.await_count == 2
personal_sql = str(session.execute.await_args_list[0].args[0])
guest_sql = str(session.execute.await_args_list[1].args[0])
assert "lifecycle_status" in personal_sql and "date_expired" in personal_sql
assert "lifecycle_status" in guest_sql and "date_expired" in guest_sql
session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_draft_cleanup_deletes_objects_but_keeps_submitted_object(
monkeypatch: pytest.MonkeyPatch,
) -> None:
abandoned = ClientUploadDraft(
id=uuid.uuid4(),
user_id=uuid.uuid4(),
context_type="notification",
context_id=uuid.uuid4(),
original_file_name="a.pdf",
safe_file_name="a.pdf",
mime_type="application/pdf",
size_bytes=1,
storage_bucket="quarantine",
object_key="q/a",
quarantine_object_key="q/a",
state="draft",
)
submitted = ClientUploadDraft(
id=uuid.uuid4(),
user_id=abandoned.user_id,
context_type="notification",
context_id=abandoned.context_id,
original_file_name="b.pdf",
safe_file_name="b.pdf",
mime_type="application/pdf",
size_bytes=1,
storage_bucket="attachments",
object_key="a/b",
quarantine_object_key=None,
state="submitted",
)
session = SimpleNamespace(
execute=AsyncMock(side_effect=[ScalarRows([abandoned, submitted]), None, None]),
commit=AsyncMock(),
)
class Sessions:
async def __aenter__(self) -> object:
return session
async def __aexit__(self, *_args: object) -> None:
return None
db = SimpleNamespace(sessions=lambda: Sessions())
s3 = SimpleNamespace(delete_quarantine=AsyncMock(), delete=AsyncMock())
monkeypatch.setattr(
"app.workers.load_settings",
AsyncMock(
return_value=SettingsSnapshot(
{"notification.upload_draft.ttl_days": "7"}, "v1"
)
),
)
assert await notification_draft_cleanup_once(db, s3) == 2
s3.delete_quarantine.assert_awaited_once_with("q/a")
s3.delete.assert_not_awaited()
assert session.execute.await_count == 3
session.commit.assert_awaited_once()
@@ -0,0 +1,25 @@
from opentelemetry.sdk.trace import TracerProvider
from app import telemetry
def test_telemetry_is_fail_open_without_endpoint(monkeypatch) -> None:
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
monkeypatch.setattr(telemetry, "_runtime", None)
assert telemetry.init_telemetry("test-service") is None
def test_structlog_processor_adds_active_trace_context_only() -> None:
provider = TracerProvider()
tracer = provider.get_tracer("test")
event = {"event": "safe", "request_id": "request-1"}
with tracer.start_as_current_span("operation"):
result = telemetry.add_trace_context(None, "info", event)
assert result["event"] == "safe"
assert result["request_id"] == "request-1"
assert len(result["trace_id"]) == 32
assert len(result["span_id"]) == 16
assert set(result) == {"event", "request_id", "trace_id", "span_id"}