mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
124 lines
4.6 KiB
Python
124 lines
4.6 KiB
Python
"""Tests for ContentActions.notify_get — read-one-notification (marks read).
|
|||
|
|
|
||
|
|
`notify_get` used to swallow ANY exception from
|
||
|
|
`get_for_recipient_and_mark_read` into `Envelope.not_found` — including a DB
|
||
|
|
error from the mark-read UPDATE (e.g. hitting `lock_timeout`), which poisoned
|
||
|
|
the session for the rest of the transaction and surfaced later as an opaque
|
||
|
|
`PendingRollbackError`, while also lying to the calling agent that an
|
||
|
|
existing notification didn't exist. The fix narrows the catch to the two real
|
||
|
|
domain outcomes (`NotFoundError`, `PermissionError`); anything else must
|
||
|
|
propagate so the session actually rolls back.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from unittest.mock import AsyncMock, MagicMock
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from roboco.services.base import NotFoundError
|
||
|
|
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
|
||
|
|
from sqlalchemy.exc import OperationalError
|
||
|
|
|
||
|
|
|
||
|
|
def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
|
||
|
|
task = overrides.get("task", AsyncMock())
|
||
|
|
git = overrides.get("git", AsyncMock())
|
||
|
|
a2a = overrides.get("a2a", AsyncMock())
|
||
|
|
journal = overrides.get("journal", AsyncMock())
|
||
|
|
workspace = overrides.get("workspace", AsyncMock())
|
||
|
|
notifications = overrides.get("notifications", AsyncMock())
|
||
|
|
notification_delivery = overrides.get("notification_delivery", AsyncMock())
|
||
|
|
return ContentActionsDeps(
|
||
|
|
task=task,
|
||
|
|
git=git,
|
||
|
|
a2a=a2a,
|
||
|
|
journal=journal,
|
||
|
|
workspace=workspace,
|
||
|
|
notifications=notifications,
|
||
|
|
notification_delivery=notification_delivery,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_notify_get_not_found_error_maps_to_not_found() -> None:
|
||
|
|
"""A genuinely missing notification -> Envelope.not_found."""
|
||
|
|
notification_id = uuid4()
|
||
|
|
notif_delivery = AsyncMock()
|
||
|
|
notif_delivery.get_for_recipient_and_mark_read.side_effect = NotFoundError(
|
||
|
|
resource_type="Notification", resource_id=str(notification_id)
|
||
|
|
)
|
||
|
|
deps = _make_deps(notification_delivery=notif_delivery)
|
||
|
|
ca = ContentActions(deps)
|
||
|
|
|
||
|
|
env = await ca.notify_get(agent_id=uuid4(), notification_id=notification_id)
|
||
|
|
body = env.as_dict()
|
||
|
|
|
||
|
|
assert body["error"] == "not_found"
|
||
|
|
assert str(notification_id) in body["message"]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_notify_get_permission_error_maps_to_not_found() -> None:
|
||
|
|
"""A recipient mismatch -> Envelope.not_found (never leaks a 403/details)."""
|
||
|
|
notification_id = uuid4()
|
||
|
|
notif_delivery = AsyncMock()
|
||
|
|
notif_delivery.get_for_recipient_and_mark_read.side_effect = PermissionError(
|
||
|
|
"view notification: not a recipient"
|
||
|
|
)
|
||
|
|
deps = _make_deps(notification_delivery=notif_delivery)
|
||
|
|
ca = ContentActions(deps)
|
||
|
|
|
||
|
|
env = await ca.notify_get(agent_id=uuid4(), notification_id=notification_id)
|
||
|
|
body = env.as_dict()
|
||
|
|
|
||
|
|
assert body["error"] == "not_found"
|
||
|
|
assert str(notification_id) in body["message"]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_notify_get_db_error_propagates() -> None:
|
||
|
|
"""A DB-shaped failure (e.g. the mark-read UPDATE hitting lock_timeout)
|
||
|
|
must NOT be swallowed into not_found — it has to propagate so the
|
||
|
|
session actually rolls back instead of silently poisoning the
|
||
|
|
transaction for the caller's later commit."""
|
||
|
|
notification_id = uuid4()
|
||
|
|
notif_delivery = AsyncMock()
|
||
|
|
notif_delivery.get_for_recipient_and_mark_read.side_effect = OperationalError(
|
||
|
|
"UPDATE notifications ...", {}, Exception("lock timeout")
|
||
|
|
)
|
||
|
|
deps = _make_deps(notification_delivery=notif_delivery)
|
||
|
|
ca = ContentActions(deps)
|
||
|
|
|
||
|
|
with pytest.raises(OperationalError):
|
||
|
|
await ca.notify_get(agent_id=uuid4(), notification_id=notification_id)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_notify_get_success_returns_notification_and_marks_read() -> None:
|
||
|
|
"""Happy path: the resolved notification's fields land in evidence."""
|
||
|
|
notification_id = uuid4()
|
||
|
|
from_agent = uuid4()
|
||
|
|
n = MagicMock()
|
||
|
|
n.id = notification_id
|
||
|
|
n.type = "alert"
|
||
|
|
n.priority = "normal"
|
||
|
|
n.subject = "subject line"
|
||
|
|
n.body = "body text"
|
||
|
|
n.requires_ack = False
|
||
|
|
n.from_agent = from_agent
|
||
|
|
|
||
|
|
notif_delivery = AsyncMock()
|
||
|
|
notif_delivery.get_for_recipient_and_mark_read.return_value = n
|
||
|
|
deps = _make_deps(notification_delivery=notif_delivery)
|
||
|
|
ca = ContentActions(deps)
|
||
|
|
|
||
|
|
env = await ca.notify_get(agent_id=uuid4(), notification_id=notification_id)
|
||
|
|
body = env.as_dict()
|
||
|
|
|
||
|
|
assert body["error"] is None
|
||
|
|
assert body["evidence"]["id"] == str(notification_id)
|
||
|
|
assert body["evidence"]["subject"] == "subject line"
|
||
|
|
assert body["evidence"]["from_agent"] == str(from_agent)
|
||
|
|
notif_delivery.get_for_recipient_and_mark_read.assert_awaited_once()
|