Добавлены уведомления
This commit is contained in:
@@ -14,11 +14,25 @@ EXPECTED_PATHS = {
|
||||
"/health/ready",
|
||||
"/api/v1/public/app-config",
|
||||
"/api/v1/public/content",
|
||||
"/api/v1/public/notifications",
|
||||
"/api/v1/public/notification-types",
|
||||
"/api/v1/auth/bootstrap",
|
||||
"/api/v1/consents",
|
||||
"/api/v1/analytics/session-start",
|
||||
"/api/v1/me",
|
||||
"/api/v1/me/documents",
|
||||
"/api/v1/notifications",
|
||||
"/api/v1/notifications/counter",
|
||||
"/api/v1/notifications/{notification_id}",
|
||||
"/api/v1/notifications/{notification_id}/read",
|
||||
"/api/v1/notifications/{notification_id}/hide",
|
||||
"/api/v1/notifications/{notification_id}/buttons/{button_code}",
|
||||
"/api/v1/notifications/{notification_id}/cta",
|
||||
"/api/v1/notifications/{notification_id}/documents/{document_id}/download-url",
|
||||
"/api/v1/uploads/init",
|
||||
"/api/v1/uploads/{draft_id}/complete",
|
||||
"/api/v1/uploads",
|
||||
"/api/v1/uploads/{draft_id}",
|
||||
"/api/v1/documents/{document_id}",
|
||||
"/api/v1/documents/{document_id}/download-url",
|
||||
"/api/v1/dialogs",
|
||||
@@ -28,6 +42,8 @@ EXPECTED_PATHS = {
|
||||
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete",
|
||||
"/api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url",
|
||||
"/internal/openlines/v1/inbox",
|
||||
"/internal/notifications/v1/notifications",
|
||||
"/internal/notifications/v1/notifications/cancel",
|
||||
"/internal/settings/v1/otp",
|
||||
}
|
||||
|
||||
@@ -46,6 +62,33 @@ def test_websocket_route_is_registered() -> None:
|
||||
assert any(getattr(route, "path", None) == "/api/v1/realtime" for route in app.routes)
|
||||
|
||||
|
||||
def test_notification_http_methods_match_contract() -> None:
|
||||
paths = app.openapi()["paths"]
|
||||
expected = {
|
||||
"/api/v1/public/notifications": {"get"},
|
||||
"/api/v1/public/notification-types": {"get"},
|
||||
"/api/v1/notifications": {"get"},
|
||||
"/api/v1/notifications/counter": {"get"},
|
||||
"/api/v1/notifications/{notification_id}": {"get"},
|
||||
"/api/v1/notifications/{notification_id}/read": {"post"},
|
||||
"/api/v1/notifications/{notification_id}/hide": {"post"},
|
||||
"/api/v1/notifications/{notification_id}/buttons/{button_code}": {"post"},
|
||||
"/api/v1/notifications/{notification_id}/cta": {"post"},
|
||||
(
|
||||
"/api/v1/notifications/{notification_id}/documents/"
|
||||
"{document_id}/download-url"
|
||||
): {"get"},
|
||||
"/api/v1/uploads/init": {"post"},
|
||||
"/api/v1/uploads/{draft_id}/complete": {"post"},
|
||||
"/api/v1/uploads": {"get"},
|
||||
"/api/v1/uploads/{draft_id}": {"delete"},
|
||||
"/internal/notifications/v1/notifications": {"post"},
|
||||
"/internal/notifications/v1/notifications/cancel": {"post"},
|
||||
}
|
||||
for path, methods in expected.items():
|
||||
assert methods <= paths[path].keys()
|
||||
|
||||
|
||||
def test_websocket_accepts_canonical_base64url_jwt_protocol() -> None:
|
||||
jwt = "header.payload.signature"
|
||||
encoded = base64.urlsafe_b64encode(jwt.encode()).decode().rstrip("=")
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user