Newer
Older
navi-1 / tests / unit / tools / test_notify.py
"""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"]