"""Tests for the notify tool — app push / Synapse legs per push_target."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from navi.synapse.settings_store import SynapseSettings
from navi.tools import NotifyTool
from navi.tools._internal.base import ToolContext
def _tool(monkeypatch, settings_row, *, push_service=None, source_ready=True):
async def fake_get(self, user_id):
return settings_row
monkeypatch.setattr("navi.synapse.settings_store.SynapseSettingsStore.get", fake_get)
async def fake_pool(self):
return None
monkeypatch.setattr(NotifyTool, "_store_pool", fake_pool)
monkeypatch.setattr(NotifyTool, "_push_service", staticmethod(lambda: push_service))
import navi.synapse.outbound as outbound
monkeypatch.setattr(outbound, "synapse_source_ready", lambda: source_ready)
emitted = []
async def fake_emit(**kwargs):
emitted.append(kwargs)
return MagicMock(id="ev-1")
async def fake_emit_low_level(subject, action, payload, priority="low", dedup_key=None):
emitted.append(
{"subject": subject, "action": action, "payload": payload,
"priority": priority, "dedup_key": dedup_key}
)
monkeypatch.setattr(outbound, "emit_low_level", fake_emit_low_level)
return NotifyTool(), emitted
async def test_no_user_context_fails(monkeypatch):
tool, _ = _tool(monkeypatch, SynapseSettings(user_id="u1"))
result = await tool.execute({"message": "hi"})
assert not result.success
assert "user" in result.error.lower()
async def test_empty_message_fails(monkeypatch):
tool, _ = _tool(monkeypatch, SynapseSettings(user_id="u1"))
result = await tool.execute({"message": " "}, ctx=ToolContext(user_id="u1"))
assert not result.success
async def test_unknown_level_fails(monkeypatch):
tool, _ = _tool(monkeypatch, SynapseSettings(user_id="u1"))
result = await tool.execute(
{"message": "hi", "level": "panic"}, ctx=ToolContext(user_id="u1")
)
assert not result.success
assert "panic" in result.error
async def test_app_target_delivers_app_only(monkeypatch):
push = MagicMock(enabled=True, notify_custom=AsyncMock())
tool, emitted = _tool(monkeypatch, SynapseSettings(user_id="u1", push_target="app"),
push_service=push)
result = await tool.execute({"message": "done"}, ctx=ToolContext(user_id="u1", session_id="s1"))
assert result.success
assert "Delivered: app" in result.output
assert "synapse" not in result.output
push.notify_custom.assert_awaited_once()
session_arg, user_arg = push.notify_custom.await_args.args
title, body = push.notify_custom.await_args.kwargs["title"], push.notify_custom.await_args.kwargs["body"]
assert (session_arg, user_arg) == ("s1", "u1")
assert body == "done"
assert emitted == []
async def test_app_synapse_delivers_both(monkeypatch):
push = MagicMock(enabled=True, notify_custom=AsyncMock())
tool, emitted = _tool(monkeypatch, SynapseSettings(user_id="u1", push_target="app_synapse"),
push_service=push)
result = await tool.execute(
{"message": "need your call", "level": "intervention"},
ctx=ToolContext(user_id="u1"),
)
assert result.success
assert "Delivered: app, synapse" in result.output
assert len(emitted) == 1
assert emitted[0]["subject"] == "navi-notification"
assert emitted[0]["action"] == "intervention"
assert emitted[0]["priority"] == "critical"
async def test_synapse_only_skips_app(monkeypatch):
push = MagicMock(enabled=True, notify_custom=AsyncMock())
tool, emitted = _tool(monkeypatch, SynapseSettings(user_id="u1", push_target="synapse"),
push_service=push)
result = await tool.execute({"message": "done"}, ctx=ToolContext(user_id="u1"))
assert result.success
assert "Delivered: synapse" in result.output
# the app leg is not requested at all, not "skipped"
assert "Skipped" not in result.output
push.notify_custom.assert_not_awaited()
assert len(emitted) == 1
async def test_synapse_leg_skipped_when_not_configured(monkeypatch):
push = MagicMock(enabled=True, notify_custom=AsyncMock())
tool, _ = _tool(monkeypatch, SynapseSettings(user_id="u1", push_target="synapse"),
push_service=push, source_ready=False)
result = await tool.execute({"message": "done"}, ctx=ToolContext(user_id="u1"))
assert result.success
assert "Skipped: synapse (source key not configured)" in result.output
assert "Delivered" not in result.output
async def test_app_leg_skipped_when_push_service_missing(monkeypatch):
tool, emitted = _tool(
monkeypatch, SynapseSettings(user_id="u1", push_target="app"),
push_service=None, source_ready=False,
)
result = await tool.execute({"message": "done"}, ctx=ToolContext(user_id="u1"))
assert result.success
assert "Skipped: app (web push not configured)" in result.output
assert emitted == []
async def test_warning_maps_to_high_priority(monkeypatch):
tool, emitted = _tool(monkeypatch, SynapseSettings(user_id="u1", push_target="synapse"))
await tool.execute({"message": "hm", "level": "warning"}, ctx=ToolContext(user_id="u1"))
assert emitted[0]["priority"] == "high"
def test_registered_as_builtin():
"""The tool is wired into the default registry by name."""
from navi.core.registry import ToolRegistry
tools = ToolRegistry()
tools.register(NotifyTool(), builtin=True)
assert tools.get("notify") is not None
# ── real pool wiring ─────────────────────────────────────────────────────────
async def test_reads_settings_through_the_real_pool(monkeypatch):
"""Regression: `PgSessionStore._get_pool` is async, and the tool used to hand
its coroutine straight to SynapseSettingsStore — so every notify call died
with "'coroutine' object has no attribute 'fetchrow'".
Nothing is faked below the tool: the settings row comes out of the fake
asyncpg connection, and push_target is taken from it (the row says "synapse",
the default would be "app").
"""
import inspect
from tests.conftest_factory import FakeConnection, FakePool, FakeRecord
assert inspect.iscoroutinefunction(NotifyTool._store_pool)
conn = FakeConnection()
conn.enqueue(FakeRecord(
user_id="u1", reactions_enabled=False, push_target="synapse",
completion_notify="important", instructions="", updated_at=None,
))
pool = FakePool(conn)
class _SessionStore:
async def _get_pool(self):
return pool
monkeypatch.setattr("navi.api.deps.get_session_store", lambda: _SessionStore())
import navi.synapse.outbound as outbound
emitted = []
async def fake_emit_low_level(subject, action, payload, priority="low", dedup_key=None):
emitted.append({"subject": subject, "action": action, "priority": priority})
return MagicMock(id="ev-1")
monkeypatch.setattr(outbound, "synapse_source_ready", lambda: True)
monkeypatch.setattr(outbound, "emit_low_level", fake_emit_low_level)
result = await NotifyTool().execute(
{"message": "ping"}, ctx=ToolContext(user_id="u1", session_id="s1")
)
assert result.success, result.error
assert "Delivered: synapse" in result.output
assert emitted[0]["priority"] == "normal"
assert [c[0] for c in conn.calls] == ["fetchrow"]