"""Tests for the bridge self-monitoring provider. Covers: 1. ``build_event`` parses a well-formed payload and rejects malformed ones. 2. The threshold-crossing helpers in ``services.bridge_self`` only emit on the actual crossing, not on every increment afterwards (anti-spam). 3. ``ensure_bridge_self_provider_for_user`` creates exactly one provider per user and is idempotent on re-run. 4. The capability registry exposes the new event/slot definitions. """ from __future__ import annotations from datetime import datetime, timezone import pytest from sqlmodel import SQLModel, select from sqlmodel.ext.asyncio.session import AsyncSession from sqlalchemy.ext.asyncio import create_async_engine # --------------------------------------------------------------------------- # Event parser # --------------------------------------------------------------------------- def test_build_event_well_formed_payload() -> None: from notify_bridge_core.providers.bridge_self.event_parser import build_event from notify_bridge_core.models.events import EventType from notify_bridge_core.providers.base import ServiceProviderType payload = { "failure_type": "poll_failures", "subject_id": 7, "subject_name": "My Tracker", "count": 3, "threshold": 3, "last_error": "Timeout", "details": {"tracker_id": 7}, } when = datetime(2026, 5, 16, 10, 0, tzinfo=timezone.utc) event = build_event(payload, timestamp=when) assert event is not None assert event.event_type == EventType.BRIDGE_SELF_POLL_FAILURES assert event.provider_type == ServiceProviderType.BRIDGE_SELF assert event.collection_id == "7" assert event.collection_name == "My Tracker" assert event.timestamp == when assert event.extra["count"] == 3 assert event.extra["threshold"] == 3 assert event.extra["last_error"] == "Timeout" assert event.extra["failure_type"] == "poll_failures" assert event.extra["details"] == {"tracker_id": 7} def test_build_event_unknown_failure_type_returns_none() -> None: from notify_bridge_core.providers.bridge_self.event_parser import build_event assert build_event({"failure_type": "rocket_launch"}) is None def test_build_event_non_dict_payload_returns_none() -> None: from notify_bridge_core.providers.bridge_self.event_parser import build_event assert build_event("not a dict") is None # type: ignore[arg-type] assert build_event(None) is None # type: ignore[arg-type] def test_build_event_clamps_long_error_messages() -> None: from notify_bridge_core.providers.bridge_self.event_parser import ( build_event, _MAX_ERROR_LEN, ) huge = "X" * (_MAX_ERROR_LEN * 5) event = build_event({ "failure_type": "target_failures", "subject_id": 1, "subject_name": "t", "count": 5, "threshold": 5, "last_error": huge, }) assert event is not None assert len(event.extra["last_error"]) <= _MAX_ERROR_LEN # --------------------------------------------------------------------------- # Threshold-crossing counters # --------------------------------------------------------------------------- def test_record_poll_failure_increments_then_success_resets() -> None: from notify_bridge_server.services import bridge_self as bs # Use a tracker_id we know is unique to this test to avoid pollution # across tests sharing the module-level dicts. tid = 9_001 bs.reset_poll_counter(tid) assert bs.record_poll_failure(tid, "boom") == 1 assert bs.record_poll_failure(tid, "boom") == 2 assert bs.record_poll_failure(tid, "boom") == 3 assert bs.get_poll_failure_count(tid) == 3 assert bs.get_poll_last_error(tid) == "boom" bs.record_poll_success(tid) assert bs.get_poll_failure_count(tid) == 0 assert bs.get_poll_last_error(tid) == "" def test_record_target_failure_increments_then_success_resets() -> None: from notify_bridge_server.services import bridge_self as bs tid = 9_101 bs.reset_target_counter(tid) assert bs.record_target_failure(tid, "503") == 1 assert bs.record_target_failure(tid, "503") == 2 assert bs.get_target_failure_count(tid) == 2 bs.record_target_success(tid) assert bs.get_target_failure_count(tid) == 0 def test_backlog_state_only_emits_on_crossing() -> None: """Only the False -> True transition should report a crossing. A sustained backlog must not re-fire on every scan, and a recovered backlog re-arms the latch so the next crossing is reported again. """ from notify_bridge_server.services import bridge_self as bs user_id = 9_201 # Reset latch by going through a False reading first. bs._backlog_above_threshold.pop(user_id, None) # Initial above-threshold reading IS a crossing (None -> True latch). assert bs.record_backlog_state(user_id, True) is True # Sustained above — no second alert. assert bs.record_backlog_state(user_id, True) is False assert bs.record_backlog_state(user_id, True) is False # Drop below — no alert (we don't notify on recovery). assert bs.record_backlog_state(user_id, False) is False # Cross again — alert. assert bs.record_backlog_state(user_id, True) is True # --------------------------------------------------------------------------- # ensure_bridge_self_provider_for_user — DB roundtrip # --------------------------------------------------------------------------- @pytest.fixture async def session() -> AsyncSession: """Fresh in-memory DB with the SQLModel schema applied.""" engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) async with AsyncSession(engine) as session: yield session await engine.dispose() @pytest.mark.asyncio async def test_ensure_bridge_self_provider_creates_once(session: AsyncSession) -> None: from notify_bridge_server.database.models import ServiceProvider, User from notify_bridge_server.database.seeds import ( ensure_bridge_self_provider_for_user, ) # Create a real user. user = User(username="alice", hashed_password="x", role="user") session.add(user) await session.commit() await session.refresh(user) user_id = user.id p1 = await ensure_bridge_self_provider_for_user(session, user_id) assert p1 is not None p1_id = p1.id assert p1.type == "bridge_self" assert p1.user_id == user_id assert p1.config["poll_failure_threshold"] == 3 assert p1.config["deferred_backlog_threshold"] == 100 assert p1.config["target_failure_threshold"] == 5 await session.commit() # Idempotent: second call returns the same row, no duplicates. p2 = await ensure_bridge_self_provider_for_user(session, user_id) assert p2 is not None assert p2.id == p1_id await session.commit() rows = ( await session.exec( select(ServiceProvider).where( ServiceProvider.user_id == user_id, ServiceProvider.type == "bridge_self", ) ) ).all() assert len(rows) == 1 @pytest.mark.asyncio async def test_ensure_bridge_self_provider_skips_system_user(session: AsyncSession) -> None: """user_id <= 0 is the __system__ placeholder — never gets a provider.""" from notify_bridge_server.database.seeds import ( ensure_bridge_self_provider_for_user, ) result = await ensure_bridge_self_provider_for_user(session, 0) assert result is None # --------------------------------------------------------------------------- # Capability registry # --------------------------------------------------------------------------- def test_capability_registry_lists_bridge_self() -> None: from notify_bridge_core.providers.capabilities import ( get_capabilities, get_all_capabilities, ) caps = get_capabilities("bridge_self") assert caps is not None assert caps.provider_type == "bridge_self" assert caps.webhook_based is False event_names = {e["name"] for e in caps.events} assert event_names == { "bridge_self_poll_failures", "bridge_self_deferred_backlog", "bridge_self_target_failures", } slot_names = {s["name"] for s in caps.notification_slots} assert slot_names == { "message_bridge_self_poll_failures", "message_bridge_self_deferred_backlog", "message_bridge_self_target_failures", } # And it shows up in the global registry. assert "bridge_self" in get_all_capabilities() def test_default_template_loader_returns_bridge_self_slots() -> None: """All three bridge_self slots have shipped Jinja2 default templates.""" from notify_bridge_core.templates.defaults.loader import load_default_templates en = load_default_templates("en", "bridge_self") ru = load_default_templates("ru", "bridge_self") expected = { "message_bridge_self_poll_failures", "message_bridge_self_deferred_backlog", "message_bridge_self_target_failures", } assert set(en.keys()) == expected assert set(ru.keys()) == expected # Sanity: each template references at least one of the bridge_self vars. for tpl in list(en.values()) + list(ru.values()): assert "{{" in tpl