"""Per-send Telegram options (`disable_notification`, `message_thread_id`). Verifies the ContextVar-based plumbing inside ``TelegramClient`` so the two new flags actually land in the request payloads at all four send paths (sendMessage, single-asset send, media-group, cache-hit POST) and that concurrent ``asyncio.gather`` fan-outs in the dispatcher don't leak options between tasks. """ from __future__ import annotations import asyncio import json from typing import Any import pytest from aiohttp import FormData def test_telegram_receiver_factory_reads_new_fields() -> None: """The receiver factory turns config-dict keys into typed fields.""" from notify_bridge_core.notifications.receiver import ( TelegramReceiver, build_receiver, ) recv = build_receiver( "telegram", { "chat_id": "12345", "disable_notification": True, "message_thread_id": "7", # string form, common from JSON UI }, ) assert isinstance(recv, TelegramReceiver) assert recv.chat_id == "12345" assert recv.disable_notification is True assert recv.message_thread_id == 7 def test_telegram_receiver_factory_defaults_when_missing() -> None: """Missing keys default to off / general topic.""" from notify_bridge_core.notifications.receiver import ( TelegramReceiver, build_receiver, ) recv = build_receiver("telegram", {"chat_id": "12345"}) assert isinstance(recv, TelegramReceiver) assert recv.disable_notification is False assert recv.message_thread_id is None @pytest.mark.parametrize( "raw_thread, expected", [ (None, None), ("", None), ("not-a-number", None), ("42", 42), (42, 42), # ``0`` is Telegram's "general topic" sentinel — collapse to None # so the Bot API just omits the field, matching the frontend's # ``<= 0 → unset`` behaviour. ("0", None), (0, None), (-5, None), # bool would otherwise pass through as int(True)==1 / int(False)==0 # and silently route into topic #1; reject explicitly. (True, None), (False, None), ], ) def test_telegram_receiver_thread_id_coercion(raw_thread: Any, expected: Any) -> None: from notify_bridge_core.notifications.receiver import build_receiver recv = build_receiver( "telegram", {"chat_id": "1", "message_thread_id": raw_thread}, ) assert recv.message_thread_id == expected # type: ignore[attr-defined] def test_apply_send_opts_to_payload_merges_when_bound() -> None: """Inside ``_bind_send_options``, payload helper writes the two keys.""" from notify_bridge_core.notifications.telegram.client import ( _SendOptions, _apply_send_opts_to_payload, _bind_send_options, ) payload: dict[str, Any] = {"chat_id": "1"} with _bind_send_options(_SendOptions(disable_notification=True, message_thread_id=7)): _apply_send_opts_to_payload(payload) assert payload["disable_notification"] is True assert payload["message_thread_id"] == 7 def test_apply_send_opts_to_payload_omits_when_default() -> None: """No bind = no flags written (Bot API treats omission as default).""" from notify_bridge_core.notifications.telegram.client import ( _apply_send_opts_to_payload, ) payload: dict[str, Any] = {"chat_id": "1"} _apply_send_opts_to_payload(payload) assert "disable_notification" not in payload assert "message_thread_id" not in payload def test_apply_send_opts_to_form_merges_when_bound() -> None: """Multipart payload helper writes the two fields when bound.""" from notify_bridge_core.notifications.telegram.client import ( _SendOptions, _apply_send_opts_to_form, _bind_send_options, ) form = FormData() with _bind_send_options(_SendOptions(disable_notification=True, message_thread_id=42)): _apply_send_opts_to_form(form) # aiohttp.FormData stores fields as ``(MultiDict{name, ...}, headers, value)``. name_to_value = {} for type_opts, _headers, value in form._fields: # type: ignore[attr-defined] name_to_value[type_opts.get("name")] = value assert name_to_value.get("disable_notification") == "true" assert name_to_value.get("message_thread_id") == "42" def test_bind_send_options_resets_on_exit() -> None: """Token-reset semantics: the var is restored even after a raise.""" from notify_bridge_core.notifications.telegram.client import ( _SendOptions, _bind_send_options, _send_options_var, ) default = _send_options_var.get() try: with _bind_send_options(_SendOptions(disable_notification=True)): raise RuntimeError("boom") except RuntimeError: pass assert _send_options_var.get() == default @pytest.mark.asyncio async def test_concurrent_binds_do_not_leak_between_tasks() -> None: """Two ``asyncio.gather`` tasks see only their own bound options. This is the load-bearing invariant for the dispatcher's per-receiver fan-out: one chat with ``disable_notification=True`` must not silence a peer chat in the same dispatch. """ from notify_bridge_core.notifications.telegram.client import ( _SendOptions, _apply_send_opts_to_payload, _bind_send_options, ) results: list[dict[str, Any]] = [] async def run_with(opts: _SendOptions, label: str) -> None: payload: dict[str, Any] = {"label": label} with _bind_send_options(opts): # Yield to the loop to interleave with the sibling task. await asyncio.sleep(0) _apply_send_opts_to_payload(payload) results.append(payload) await asyncio.gather( run_with(_SendOptions(disable_notification=True, message_thread_id=1), "silent"), run_with(_SendOptions(disable_notification=False, message_thread_id=2), "loud"), ) by_label = {r["label"]: r for r in results} assert by_label["silent"].get("disable_notification") is True assert by_label["silent"].get("message_thread_id") == 1 assert "disable_notification" not in by_label["loud"] # False → omitted assert by_label["loud"].get("message_thread_id") == 2 @pytest.mark.asyncio async def test_send_message_passes_options_into_payload(monkeypatch) -> None: """``send_message(disable_notification=True, message_thread_id=N)`` surfaces both keys in the JSON request body.""" from notify_bridge_core.notifications.telegram.client import TelegramClient captured: dict[str, Any] = {} class _FakeResp: status = 200 async def json(self) -> dict[str, Any]: return {"ok": True, "result": {"message_id": 99}} async def __aenter__(self) -> "_FakeResp": return self async def __aexit__(self, *args: Any) -> None: return None class _FakeSession: def post(self, url: str, *, json: dict[str, Any] | None = None, **_kw: Any) -> _FakeResp: captured["url"] = url captured["json"] = json return _FakeResp() client = TelegramClient(_FakeSession(), "TEST:token") # type: ignore[arg-type] result = await client.send_message( chat_id="123", text="hello", disable_notification=True, message_thread_id=5, ) assert result["success"] is True payload = captured["json"] assert payload["disable_notification"] is True assert payload["message_thread_id"] == 5 @pytest.mark.asyncio async def test_send_message_without_options_omits_keys(monkeypatch) -> None: """Default kwargs leave the payload Bot-API-clean.""" from notify_bridge_core.notifications.telegram.client import TelegramClient captured: dict[str, Any] = {} class _FakeResp: status = 200 async def json(self) -> dict[str, Any]: return {"ok": True, "result": {"message_id": 1}} async def __aenter__(self) -> "_FakeResp": return self async def __aexit__(self, *args: Any) -> None: return None class _FakeSession: def post(self, url: str, *, json: dict[str, Any] | None = None, **_kw: Any) -> _FakeResp: captured["json"] = json return _FakeResp() client = TelegramClient(_FakeSession(), "TEST:token") # type: ignore[arg-type] await client.send_message(chat_id="123", text="hello") payload = captured["json"] assert "disable_notification" not in payload assert "message_thread_id" not in payload