590 lines
19 KiB
Python
590 lines
19 KiB
Python
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()
|