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()