feat: observability, per-receiver Telegram options, oversized-video fallback

Operability:
- Correlation IDs end-to-end: shared dispatch_id between log lines and
  EventLog rows (event/watcher/scheduled/deferred/action/HA/command paths)
  and a new X-Request-Id middleware that normalizes inbound ids and binds
  request_id into log context.
- dispatch_summary block merged into EventLog.details: per-target
  success/failure counts plus Telegram media delivered/skipped/failed and
  truncated error lists, so partial outcomes surface in the UI.
- Diagnostic mode: admin can flip one module to DEBUG for a bounded
  window with auto-revert (in-memory only; setup_logging() resets on
  boot, lifespan reverts on shutdown). New /diagnostic-mode endpoints
  plus DiagnosticsCassette UI on the settings page.

Telegram:
- Per-receiver options: disable_notification (silent send) and
  message_thread_id (forum-topic routing), wired through the dispatcher
  via a ContextVar so all four send sites (sendMessage / sendPhoto-Video-
  Document / sendMediaGroup / cache-hit POST) pick them up.
- send_large_videos_as_documents target setting: bypass the 50 MB
  sendVideo cap by falling back to sendDocument for oversized videos.
- sendMediaGroup byte-budget enforcement (TELEGRAM_MAX_GROUP_TOTAL_BYTES,
  45 MB) with per-item fallback on chunk failure so a stale file_id no
  longer silently drops a cached asset.

Tests:
- New: diagnostic_mode, dispatch_summary, request_correlation,
  telegram_media_group_partial, telegram_per_send_options.

Docs:
- .claude/reviews/: six-axis production-readiness review of v0.8.1.
- .claude/docs/functional-review-2026-05-28.md: focused review of
  Telegram/Immich/logging subsystems.
This commit is contained in:
2026-05-28 15:19:31 +03:00
parent 85a8f1e71c
commit 6a8f374678
39 changed files with 7239 additions and 142 deletions
@@ -0,0 +1,372 @@
"""Temporary per-module DEBUG overrides with auto-revert.
Covers the in-memory service module + a smoke pass over the API layer
using ``dependency_overrides`` to bypass auth. The APScheduler glue is
exercised via the fallback asyncio-timer path since tests run without a
running scheduler.
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
import pytest
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------
def _reset_state() -> None:
"""Clear the module-level ``_active`` dict between tests so prior
activations don't bleed across cases."""
from notify_bridge_server.services import diagnostic_mode as svc
svc._active.clear()
@pytest.fixture(autouse=True)
def _stub_db_read(monkeypatch):
"""Default every test to a fixed empty ``log_levels`` snapshot.
A test that wants to exercise DB-override precedence overrides this
fixture by re-patching the function explicitly.
"""
async def fake() -> str:
return ""
from notify_bridge_server.services import diagnostic_mode as svc
monkeypatch.setattr(svc, "_read_db_log_levels", fake)
def _patch_db_read(monkeypatch, value: str) -> None:
"""Override the auto-applied fixture for a single test that needs a
non-empty ``log_levels`` value."""
async def fake() -> str:
return value
from notify_bridge_server.services import diagnostic_mode as svc
monkeypatch.setattr(svc, "_read_db_log_levels", fake)
# ---------------------------------------------------------------------------
# Unit tests — service module
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_set_diagnostic_applies_debug_immediately(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
_reset_state()
module = "notify_bridge_core.notifications.telegram.client"
entry = await set_diagnostic(module, duration_minutes=30)
assert entry["module"] == module
assert entry["current_level"] == "DEBUG"
assert entry["remaining_seconds"] > 60 * 29
assert logging.getLogger(module).level == logging.DEBUG
@pytest.mark.asyncio
async def test_set_diagnostic_rejects_unlisted_module(tmp_data_dir) -> None: # noqa: ARG001
"""Only the documented namespaces should be flippable from the UI."""
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
_reset_state()
with pytest.raises(ValueError, match="allowlist"):
await set_diagnostic("some_random_third_party", 30)
@pytest.mark.asyncio
async def test_set_diagnostic_rejects_root_logger(tmp_data_dir) -> None: # noqa: ARG001
"""The empty string would target root — explicitly disallowed."""
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
_reset_state()
with pytest.raises(ValueError, match="allowlist"):
await set_diagnostic("", 30)
@pytest.mark.asyncio
async def test_set_diagnostic_rejects_unreasonable_durations(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
_reset_state()
with pytest.raises(ValueError, match="duration_minutes"):
await set_diagnostic("notify_bridge_core", 0)
with pytest.raises(ValueError, match="duration_minutes"):
await set_diagnostic("notify_bridge_core", 9999)
@pytest.mark.asyncio
async def test_baseline_from_db_override(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
"""``log_levels`` setting wins over the noisy-library default."""
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
_reset_state()
_patch_db_read(monkeypatch, "sqlalchemy.engine=ERROR")
entry = await set_diagnostic("sqlalchemy.engine", duration_minutes=15)
assert entry["baseline_level"] == "ERROR"
@pytest.mark.asyncio
async def test_baseline_from_noisy_default(tmp_data_dir) -> None: # noqa: ARG001
"""No DB override falls through to the curated noisy-lib quiet list."""
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
_reset_state()
entry = await set_diagnostic("sqlalchemy.engine", duration_minutes=15)
assert entry["baseline_level"] == "WARNING"
@pytest.mark.asyncio
async def test_baseline_prefix_walks_for_submodule(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
"""A sub-logger like ``sqlalchemy.engine.Engine`` inherits its parent's
noisy-default level (WARNING), not the root INFO."""
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
_reset_state()
entry = await set_diagnostic(
"sqlalchemy.engine.Engine", duration_minutes=15,
)
assert entry["baseline_level"] == "WARNING"
@pytest.mark.asyncio
async def test_baseline_prefix_walks_for_db_override(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
"""An explicit ``log_levels`` entry covers all sub-loggers below it."""
from notify_bridge_server.services.diagnostic_mode import set_diagnostic
_reset_state()
_patch_db_read(
monkeypatch, "notify_bridge_core.notifications=ERROR",
)
entry = await set_diagnostic(
"notify_bridge_core.notifications.telegram.client",
duration_minutes=15,
)
assert entry["baseline_level"] == "ERROR"
@pytest.mark.asyncio
async def test_set_diagnostic_twice_replaces_schedule(tmp_data_dir) -> None: # noqa: ARG001
"""Clicking the button twice extends, doesn't stack."""
from notify_bridge_server.services.diagnostic_mode import (
list_active, set_diagnostic,
)
_reset_state()
module = "notify_bridge_core"
await set_diagnostic(module, 5)
first_active = list_active()
assert len(first_active) == 1
first_expires = first_active[0]["expires_at"]
# Sleep just long enough to make the timestamps distinct, then re-set.
await asyncio.sleep(0.05)
await set_diagnostic(module, 60)
second_active = list_active()
assert len(second_active) == 1
assert second_active[0]["expires_at"] != first_expires
assert second_active[0]["remaining_seconds"] > 30 * 60
@pytest.mark.asyncio
async def test_manual_revert_restores_baseline(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.services.diagnostic_mode import (
revert_diagnostic, set_diagnostic,
)
_reset_state()
module = "sqlalchemy.engine"
await set_diagnostic(module, 30)
assert logging.getLogger(module).level == logging.DEBUG
reverted = await revert_diagnostic(module)
assert reverted is True
# noisy-library default is WARNING (30)
assert logging.getLogger(module).level == logging.WARNING
@pytest.mark.asyncio
async def test_revert_reads_db_at_revert_time(tmp_data_dir, monkeypatch) -> None: # noqa: ARG001
"""Editing ``log_levels`` while the override is active is honored when
the revert fires — not the snapshot taken at activation time."""
from notify_bridge_server.services.diagnostic_mode import (
revert_diagnostic, set_diagnostic,
)
_reset_state()
module = "sqlalchemy.engine"
_patch_db_read(monkeypatch, "")
await set_diagnostic(module, 30)
# Operator edits the setting mid-window — bump to ERROR.
_patch_db_read(monkeypatch, "sqlalchemy.engine=ERROR")
assert await revert_diagnostic(module) is True
assert logging.getLogger(module).level == logging.ERROR
@pytest.mark.asyncio
async def test_manual_revert_no_active_returns_false(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.services.diagnostic_mode import revert_diagnostic
_reset_state()
assert await revert_diagnostic("notify_bridge_core") is False
@pytest.mark.asyncio
async def test_auto_revert_after_window_elapses(tmp_data_dir) -> None: # noqa: ARG001
"""The asyncio-timer fallback fires near ``expires_at`` and restores
the baseline. Uses a sub-second window so the test stays fast.
Bypasses ``set_diagnostic`` (which clamps to minutes) by populating the
``_active`` dict and calling ``_schedule_revert`` directly.
"""
from notify_bridge_server.services import diagnostic_mode as svc
_reset_state()
module = "sqlalchemy.engine"
baseline = svc._baseline_for(module, db_log_levels="")
now = datetime.now(timezone.utc)
expires = now + timedelta(seconds=0.3)
logging.getLogger(module).setLevel("DEBUG")
svc._active[module] = svc._Override(
module=module,
baseline_level=baseline,
activated_at=now,
expires_at=expires,
)
svc._schedule_revert(module, expires)
await asyncio.sleep(0.5)
assert module not in svc._active
assert logging.getLogger(module).level == logging.WARNING
@pytest.mark.asyncio
async def test_fallback_task_retained_until_fire(tmp_data_dir) -> None: # noqa: ARG001
"""The asyncio fallback path must keep a strong reference to its task
so CPython doesn't GC it before the timer fires."""
from notify_bridge_server.services import diagnostic_mode as svc
_reset_state()
when = datetime.now(timezone.utc) + timedelta(seconds=10)
svc._schedule_revert("notify_bridge_core", when)
# The retainer set should hold exactly the task we just queued.
assert len(svc._bg_tasks) == 1
# Cancel it to clean up; the done-callback will drop it.
for task in list(svc._bg_tasks):
task.cancel()
await asyncio.sleep(0)
def test_list_active_omits_and_sweeps_expired(tmp_data_dir) -> None: # noqa: ARG001
"""Expired entries are filtered AND removed so a delayed scheduler
fire doesn't leave ghost rows in ``_active`` forever."""
from notify_bridge_server.services import diagnostic_mode as svc
_reset_state()
past = datetime.now(timezone.utc) - timedelta(minutes=1)
svc._active["sqlalchemy.engine"] = svc._Override(
module="sqlalchemy.engine",
baseline_level="WARNING",
activated_at=past - timedelta(minutes=30),
expires_at=past,
)
assert svc.list_active() == []
assert "sqlalchemy.engine" not in svc._active
@pytest.mark.asyncio
async def test_revert_all_clears_every_override(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.services.diagnostic_mode import (
list_active, revert_all, set_diagnostic,
)
_reset_state()
await set_diagnostic("notify_bridge_core", 30)
await set_diagnostic("sqlalchemy.engine", 30)
assert len(list_active()) == 2
count = await revert_all()
assert count == 2
assert list_active() == []
# ---------------------------------------------------------------------------
# API smoke — bypasses auth via dependency_overrides
# ---------------------------------------------------------------------------
@pytest.fixture
def _admin_client(tmp_data_dir): # noqa: ARG001
"""Yield a TestClient with ``require_admin`` short-circuited.
Keeps the auth-flow's SQLAlchemy/greenlet issues out of the picture
while still exercising the FastAPI router, path converters, and the
``HTTPException`` paths.
"""
_reset_state()
from notify_bridge_server.auth.dependencies import require_admin
from notify_bridge_server.database.models import User
from notify_bridge_server.main import app
fake = User(
id=1, username="admin",
password_hash="x", role="admin", token_version=0,
)
app.dependency_overrides[require_admin] = lambda: fake
with TestClient(app) as client:
yield client
app.dependency_overrides.pop(require_admin, None)
_reset_state()
def test_api_post_rejects_unlisted_module_with_400(_admin_client: TestClient) -> None:
resp = _admin_client.post(
"/api/settings/diagnostic-mode",
json={"module": "evil.namespace", "duration_minutes": 15},
)
assert resp.status_code == 400
assert "allowlist" in resp.json().get("detail", "")
def test_api_post_rejects_huge_duration_with_400(_admin_client: TestClient) -> None:
resp = _admin_client.post(
"/api/settings/diagnostic-mode",
json={"module": "notify_bridge_core", "duration_minutes": 99999},
)
assert resp.status_code == 400
def test_api_delete_unknown_returns_404(_admin_client: TestClient) -> None:
resp = _admin_client.delete(
"/api/settings/diagnostic-mode/notify_bridge_core",
)
assert resp.status_code == 404
def test_api_delete_handles_dotted_module_path(_admin_client: TestClient) -> None:
"""``{module:path}`` lets dotted names survive URL routing intact."""
target = "notify_bridge_core.notifications.telegram.client"
_admin_client.post(
"/api/settings/diagnostic-mode",
json={"module": target, "duration_minutes": 15},
)
resp = _admin_client.delete(f"/api/settings/diagnostic-mode/{target}")
assert resp.status_code == 200, resp.text
assert resp.json()["reverted"] == target
@@ -0,0 +1,357 @@
"""Aggregation of per-target dispatch results into ``EventLog.details``.
Covers ``summarize_dispatch_results`` and ``attach_summary_in_place``.
The async ``record_dispatch_summary_async`` is exercised through the
in-process update path; the watcher-style flow is covered indirectly via
the full server tests.
"""
from __future__ import annotations
from typing import Any
import pytest
def test_summarize_empty_returns_empty(tmp_data_dir) -> None: # noqa: ARG001
"""Empty results = nothing to summarize. Callers can short-circuit
on the falsy return so a row with zero dispatches doesn't get a
misleading zero-counts block."""
from notify_bridge_server.services.dispatch_summary import (
summarize_dispatch_results,
)
assert summarize_dispatch_results([]) == {}
def test_summarize_all_success_no_errors_block(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.services.dispatch_summary import (
summarize_dispatch_results,
)
results = [
{"success": True, "message_id": 1},
{"success": True, "message_id": 2},
]
summary = summarize_dispatch_results(results)
assert summary["targets_attempted"] == 2
assert summary["targets_succeeded"] == 2
assert summary["targets_failed"] == 0
assert "errors" not in summary
assert "media" not in summary
def test_summarize_mixed_records_only_failures(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.services.dispatch_summary import (
summarize_dispatch_results,
)
results = [
{"success": True},
{"success": False, "error": "Bad Request: chat not found"},
{"success": False, "error": "timeout"},
]
summary = summarize_dispatch_results(results)
assert summary["targets_succeeded"] == 1
assert summary["targets_failed"] == 2
assert summary["errors"] == [
{"index": 1, "error": "Bad Request: chat not found"},
{"index": 2, "error": "timeout"},
]
def test_summarize_media_counts_aggregate(tmp_data_dir) -> None: # noqa: ARG001
"""Media counts from a Telegram media-group success are merged."""
from notify_bridge_server.services.dispatch_summary import (
summarize_dispatch_results,
)
results = [
{
"success": True,
"delivered_count": 5,
"skipped_count": 1,
"failed_count": 0,
},
{
"success": True,
"delivered_count": 3,
"skipped_count": 0,
"failed_count": 0,
},
]
summary = summarize_dispatch_results(results)
assert summary["media"] == {"delivered": 8, "skipped": 1, "failed": 0}
def test_summarize_sub_errors_carry_target_index(tmp_data_dir) -> None: # noqa: ARG001
"""Per-chunk/per-item failures from a partial media-group send are flattened."""
from notify_bridge_server.services.dispatch_summary import (
summarize_dispatch_results,
)
results = [
{"success": True, "delivered_count": 1, "skipped_count": 0, "failed_count": 0},
{
"success": True, # group landed but with partial failure
"delivered_count": 2,
"skipped_count": 0,
"failed_count": 1,
"errors": [
{"kind": "chunk", "chunk": 1, "error": "Bad Request: ..."},
{"kind": "item", "chunk": 1, "item_index": 2, "error": "media not found"},
],
},
]
summary = summarize_dispatch_results(results)
assert summary["media_errors"] == [
{"target_index": 1, "kind": "chunk", "chunk": 1, "error": "Bad Request: ..."},
{
"target_index": 1,
"kind": "item",
"chunk": 1,
"item_index": 2,
"error": "media not found",
},
]
def test_summarize_caps_errors_and_reports_truncation(tmp_data_dir) -> None: # noqa: ARG001
from notify_bridge_server.services.dispatch_summary import (
summarize_dispatch_results,
)
results: list[dict[str, Any]] = [
{"success": False, "error": f"err {i}"} for i in range(25)
]
summary = summarize_dispatch_results(results)
assert len(summary["errors"]) == 20
assert summary["errors_truncated"] == 5
def test_summarize_trims_long_error_messages(tmp_data_dir) -> None: # noqa: ARG001
"""A pathological multi-KB error string is bounded so the row stays small."""
from notify_bridge_server.services.dispatch_summary import (
summarize_dispatch_results,
)
long_err = "x" * 2000
results = [{"success": False, "error": long_err}]
summary = summarize_dispatch_results(results)
persisted = summary["errors"][0]["error"]
assert persisted.endswith("…[truncated]")
# 500 char body + the explicit "…[truncated]" marker.
assert len(persisted) == 500 + len("…[truncated]")
@pytest.mark.asyncio
async def test_attach_summary_in_place_mutates_details_dict(tmp_data_dir) -> None: # noqa: ARG001
"""In-session call merges the summary without losing original keys."""
from notify_bridge_server.database.models import EventLog
from notify_bridge_server.services.dispatch_summary import (
attach_summary_in_place,
)
row = EventLog(
event_type="assets_added",
collection_id="abc",
collection_name="Album",
details={"provider_type": "immich", "added_count": 3},
)
attach_summary_in_place(row, [{"success": True}, {"success": False, "error": "x"}])
assert row.details["provider_type"] == "immich"
assert row.details["added_count"] == 3
assert row.details["dispatch_summary"] == {
"targets_attempted": 2,
"targets_succeeded": 1,
"targets_failed": 1,
"errors": [{"index": 1, "error": "x"}],
}
@pytest.mark.asyncio
async def test_attach_summary_in_place_with_no_results_is_noop(tmp_data_dir) -> None: # noqa: ARG001
"""Empty results → no ``dispatch_summary`` key written. Original
details survive untouched."""
from notify_bridge_server.database.models import EventLog
from notify_bridge_server.services.dispatch_summary import (
attach_summary_in_place,
)
row = EventLog(
event_type="assets_added",
collection_id="abc",
collection_name="Album",
details={"k": "v"},
)
attach_summary_in_place(row, [])
assert row.details == {"k": "v"}
assert "dispatch_summary" not in row.details
def test_summarize_handles_malformed_sub_errors(tmp_data_dir) -> None: # noqa: ARG001
"""A non-dict sub-error entry is silently skipped, not crashed on."""
from notify_bridge_server.services.dispatch_summary import (
summarize_dispatch_results,
)
results = [
{
"success": True,
"delivered_count": 1,
"errors": ["not a dict", {"kind": "item", "error": "real"}],
},
]
summary = summarize_dispatch_results(results)
assert summary["media_errors"] == [
{"target_index": 0, "kind": "item", "error": "real"}
]
# ---------------------------------------------------------------------------
# Integration: real dispatcher output shape from ``_aggregate_results``
# ---------------------------------------------------------------------------
#
# The dispatcher wraps each Telegram fan-out in a per-target envelope:
#
# {
# "success": True,
# "receivers": 2,
# "successes": 2,
# "failures": 0,
# "results": [<per-receiver dict>, ...], # ← media counts live HERE
# }
#
# These tests use that exact shape so a future refactor of the dispatcher
# doesn't silently zero out the dashboard's ``dispatch_summary.media``
# block. Earlier versions of this file passed leaf dicts directly, which
# masked the wrong-shape read in production.
def test_summarize_drills_into_aggregated_per_receiver_dicts(tmp_data_dir) -> None: # noqa: ARG001
"""Media counts on per-receiver leaves are summed across receivers."""
from notify_bridge_server.services.dispatch_summary import (
summarize_dispatch_results,
)
# Two targets, each with two Telegram receivers.
results = [
{
"success": True,
"receivers": 2,
"successes": 2,
"failures": 0,
"results": [
{
"success": True,
"message_id": 100,
"media_delivered_count": 5,
"media_skipped_count": 1,
"media_failed_count": 0,
},
{
"success": True,
"message_id": 101,
"media_delivered_count": 3,
"media_skipped_count": 0,
"media_failed_count": 0,
},
],
},
]
summary = summarize_dispatch_results(results)
assert summary["media"] == {"delivered": 8, "skipped": 1, "failed": 0}
def test_summarize_collects_aggregated_media_errors_with_receiver_index(
tmp_data_dir, # noqa: ARG001
) -> None:
"""Per-chunk / per-item media errors carry both target AND receiver index."""
from notify_bridge_server.services.dispatch_summary import (
summarize_dispatch_results,
)
results = [
{
"success": True,
"receivers": 1,
"successes": 1,
"failures": 0,
"results": [
{
"success": True,
"message_id": 200,
"media_delivered_count": 2,
"media_failed_count": 1,
"media_errors": [
{"kind": "chunk", "chunk": 1, "error": "Bad Request"},
{"kind": "item", "chunk": 1, "item_index": 2,
"error": "media not found"},
],
},
],
},
]
summary = summarize_dispatch_results(results)
assert summary["media_errors"] == [
{"target_index": 0, "receiver_index": 0, "kind": "chunk",
"chunk": 1, "error": "Bad Request"},
{"target_index": 0, "receiver_index": 0, "kind": "item",
"chunk": 1, "item_index": 2, "error": "media not found"},
]
def test_summarize_aggregated_target_errors_list_is_safely_ignored(
tmp_data_dir, # noqa: ARG001
) -> None:
"""``_aggregate_results`` stamps a flat ``errors: [str, ...]`` at the
target level on failure. The summarizer must not try to treat the
strings as structured sub-errors."""
from notify_bridge_server.services.dispatch_summary import (
summarize_dispatch_results,
)
results = [
{
"success": False,
"receivers": 2,
"successes": 0,
"failures": 2,
"error": "All receivers failed",
"errors": ["chat_not_found", "blocked_by_user"],
"results": [
{"success": False, "error": "chat_not_found"},
{"success": False, "error": "blocked_by_user"},
],
},
]
summary = summarize_dispatch_results(results)
assert summary["targets_failed"] == 1
assert summary["errors"] == [
{"index": 0, "error": "All receivers failed"},
]
# The string list at the target level is ignored — the per-receiver
# errors are already represented by the target-level error message.
assert "media_errors" not in summary
assert "media" not in summary
@pytest.mark.asyncio
async def test_attach_summary_in_place_skips_when_already_set(
tmp_data_dir, # noqa: ARG001
) -> None:
"""Caller-set ``dispatch_summary`` wins — the same "caller pins"
rule that ``enrich_details_with_correlation`` follows."""
from notify_bridge_server.database.models import EventLog
from notify_bridge_server.services.dispatch_summary import (
attach_summary_in_place,
)
row = EventLog(
event_type="assets_added",
collection_id="abc",
collection_name="Album",
details={"dispatch_summary": {"pinned": True}},
)
attach_summary_in_place(row, [{"success": True}])
assert row.details["dispatch_summary"] == {"pinned": True}
@@ -0,0 +1,158 @@
"""Request-ID middleware + EventLog dispatch_id correlation.
Covers two halves of the same correlation story:
* ``RequestContextMiddleware`` generates / accepts an inbound request id,
binds it onto the log-context ContextVar for the duration of the request,
and echoes it back as the ``X-Request-Id`` response header.
* ``enrich_details_with_correlation`` merges the active ``dispatch_id`` and
``request_id`` into an ``EventLog.details`` dict so the persisted row can
be cross-referenced with the stderr log lines emitted during the same
dispatch.
"""
from __future__ import annotations
import re
import pytest
from fastapi.testclient import TestClient
_REQ_ID_PATTERN = re.compile(r"^req:[0-9a-f]{12}$")
def test_response_carries_generated_request_id(tmp_data_dir) -> None: # noqa: ARG001
"""No inbound header → server generates ``req:<12 hex>`` and echoes it."""
from notify_bridge_server.main import app
with TestClient(app) as client:
resp = client.get("/api/health")
assert resp.status_code == 200
req_id = resp.headers.get("X-Request-Id")
assert req_id is not None
assert _REQ_ID_PATTERN.match(req_id), (
f"generated id {req_id!r} should match req:<12 hex>"
)
def test_response_echoes_safe_inbound_request_id(tmp_data_dir) -> None: # noqa: ARG001
"""A well-formed inbound ``X-Request-Id`` is preserved unchanged."""
from notify_bridge_server.main import app
inbound = "abc-123_XYZ_trace"
with TestClient(app) as client:
resp = client.get("/api/health", headers={"X-Request-Id": inbound})
assert resp.status_code == 200
assert resp.headers.get("X-Request-Id") == inbound
def test_colon_prefixed_inbound_id_is_replaced(tmp_data_dir) -> None: # noqa: ARG001
"""``:`` is reserved for server-minted ids — a colon in the inbound value
must trigger replacement so a client can't masquerade as ``disp:...``."""
from notify_bridge_server.main import app
with TestClient(app) as client:
resp = client.get(
"/api/health", headers={"X-Request-Id": "disp:fake12345678"},
)
assert resp.status_code == 200
echoed = resp.headers.get("X-Request-Id", "")
assert echoed != "disp:fake12345678"
assert _REQ_ID_PATTERN.match(echoed)
@pytest.mark.parametrize(
"bad_value",
[
# CRLF injection attempt — would split log lines / inject headers.
"abc\r\ninjected: yes",
# Way too long.
"x" * 256,
# Disallowed characters.
"<script>alert(1)</script>",
# Empty after stripping.
" ",
],
)
def test_unsafe_inbound_request_id_is_replaced(
tmp_data_dir, bad_value: str, # noqa: ARG001
) -> None:
"""An attacker-controlled id must not flow into logs verbatim."""
from notify_bridge_server.main import app
with TestClient(app) as client:
resp = client.get("/api/health", headers={"X-Request-Id": bad_value})
assert resp.status_code == 200
echoed = resp.headers.get("X-Request-Id", "")
assert echoed != bad_value, "unsafe id was passed through unchanged"
assert _REQ_ID_PATTERN.match(echoed), (
f"replacement id {echoed!r} should match req:<12 hex>"
)
def test_enrich_details_merges_active_correlation_ids() -> None:
"""Within a ``bind_log_context`` block, the helper copies the active ids."""
from notify_bridge_core.log_context import (
bind_log_context,
enrich_details_with_correlation,
)
with bind_log_context(
dispatch_id="disp:deadbeef0001",
request_id="req:cafecafe0002",
):
result = enrich_details_with_correlation({"existing": "value"})
assert result == {
"existing": "value",
"dispatch_id": "disp:deadbeef0001",
"request_id": "req:cafecafe0002",
}
def test_enrich_details_does_not_overwrite_explicit_keys() -> None:
"""If the caller pre-set a correlation key, the helper leaves it alone."""
from notify_bridge_core.log_context import (
bind_log_context,
enrich_details_with_correlation,
)
with bind_log_context(dispatch_id="disp:newvalue00001"):
result = enrich_details_with_correlation({"dispatch_id": "disp:pinned"})
assert result["dispatch_id"] == "disp:pinned"
def test_enrich_details_no_context_returns_copy() -> None:
"""Outside any binding, the helper returns the dict unchanged but copied."""
from notify_bridge_core.log_context import enrich_details_with_correlation
original = {"key": "value"}
result = enrich_details_with_correlation(original)
assert result == original
# Mutating the result must not leak into the caller's dict.
result["extra"] = "added"
assert "extra" not in original
def test_enrich_details_handles_none() -> None:
"""``None`` is accepted (callers may build details lazily)."""
from notify_bridge_core.log_context import enrich_details_with_correlation
assert enrich_details_with_correlation(None) == {}
def test_ensure_dispatch_id_generates_or_reuses() -> None:
"""Fresh call produces a new id; inside a bind it returns the bound one."""
from notify_bridge_core.log_context import (
bind_log_context,
ensure_dispatch_id,
)
fresh = ensure_dispatch_id()
assert fresh.startswith("disp:")
assert len(fresh) == len("disp:") + 12
with bind_log_context(dispatch_id="disp:bound00000001"):
assert ensure_dispatch_id() == "disp:bound00000001"
@@ -0,0 +1,511 @@
"""Tests for partial-delivery resilience in TelegramClient._send_media_group.
Covers the three independent failure modes that previously aborted the
whole send:
1. **Per-item oversize** — one item over ``max_asset_data_size`` is
silently dropped; siblings still deliver. ``skipped_count`` reflects
the drop.
2. **Combined chunk over Telegram's byte envelope** — pre-flight splits
into byte-budgeted sub-chunks, avoiding the 413 entirely.
3. **Telegram-side chunk rejection after pre-flight** — fall back to
sending each item individually so partial delivery still happens.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import patch
import aiohttp
import pytest
from aioresponses import aioresponses
from notify_bridge_core.notifications.telegram.client import (
TelegramClient,
_MediaItem,
)
from notify_bridge_core.notifications.telegram.media import (
TELEGRAM_MAX_GROUP_TOTAL_BYTES,
)
BOT_TOKEN = "TEST_TOKEN"
TG = f"https://api.telegram.org/bot{BOT_TOKEN}"
CHAT_ID = "-1001234567890"
# ---------------------------------------------------------------------------
# Pure unit tests for the new helpers
# ---------------------------------------------------------------------------
def _item(upload_bytes: int, media_type: str = "photo") -> _MediaItem:
"""Build a synthetic _MediaItem with the given upload byte cost."""
if upload_bytes == 0:
return _MediaItem(
media_json={"type": media_type, "media": "file_id_cached"},
cache_info=None,
attachment=None,
)
return _MediaItem(
media_json={"type": media_type, "media": "attach://x"},
cache_info=("ck", media_type, None, upload_bytes),
attachment=("x", b"\x00" * upload_bytes, "f.jpg", "image/jpeg"),
)
def test_split_empty_returns_empty() -> None:
assert TelegramClient._split_items_by_byte_budget([], 1000) == []
def test_split_fits_in_single_group() -> None:
items = [_item(10), _item(20), _item(30)]
groups = TelegramClient._split_items_by_byte_budget(items, 100)
assert len(groups) == 1
assert sum(it.upload_bytes for it in groups[0]) == 60
def test_split_packs_greedily_across_budget() -> None:
# Three items @ 40 each, budget 100 → groups of [40,40] and [40].
items = [_item(40), _item(40), _item(40)]
groups = TelegramClient._split_items_by_byte_budget(items, 100)
assert [len(g) for g in groups] == [2, 1]
assert sum(it.upload_bytes for it in groups[0]) == 80
assert sum(it.upload_bytes for it in groups[1]) == 40
def test_split_oversized_single_item_kept_alone() -> None:
# An item that exceeds the budget on its own goes alone — Telegram
# gets to return a precise per-item error instead of silently
# dropping it client-side.
items = [_item(200)]
groups = TelegramClient._split_items_by_byte_budget(items, 100)
assert len(groups) == 1
assert groups[0][0].upload_bytes == 200
def test_split_cached_items_are_free() -> None:
# Cached items contribute 0 bytes — they never force a split.
items = [_item(0), _item(0), _item(0)]
groups = TelegramClient._split_items_by_byte_budget(items, 10)
assert len(groups) == 1
assert len(groups[0]) == 3
def test_split_mixes_cached_and_fresh_correctly() -> None:
# Cached items piggyback freely into whatever group they land in.
items = [_item(40), _item(0), _item(40), _item(0), _item(40)]
groups = TelegramClient._split_items_by_byte_budget(items, 100)
# [40, 0, 40] = 80 bytes (fits), next 0 fits, next 40 starts new.
assert [len(g) for g in groups] == [4, 1]
def test_attach_caption_to_first_idempotent() -> None:
items = [_item(10), _item(10)]
TelegramClient._attach_caption_to_first(items, "Hello", "HTML")
assert items[0].media_json["caption"] == "Hello"
assert items[0].media_json["parse_mode"] == "HTML"
assert "caption" not in items[1].media_json
# Re-attaching overwrites in-place, doesn't duplicate.
TelegramClient._attach_caption_to_first(items, "Bye", "MarkdownV2")
assert items[0].media_json["caption"] == "Bye"
assert items[0].media_json["parse_mode"] == "MarkdownV2"
def test_attach_caption_truncates_to_telegram_limit() -> None:
from notify_bridge_core.notifications.telegram.media import (
TELEGRAM_MAX_CAPTION_LENGTH,
)
items = [_item(10)]
long_caption = "A" * (TELEGRAM_MAX_CAPTION_LENGTH + 500)
TelegramClient._attach_caption_to_first(items, long_caption, "HTML")
assert len(items[0].media_json["caption"]) <= TELEGRAM_MAX_CAPTION_LENGTH
def test_attach_caption_no_items_is_noop() -> None:
TelegramClient._attach_caption_to_first([], "x", "HTML") # must not raise
# ---------------------------------------------------------------------------
# Integration tests for the full _send_media_group flow
# ---------------------------------------------------------------------------
def _png_bytes(size: int) -> bytes:
"""Minimal valid PNG header + pad bytes to reach the requested size.
Required so ``check_photo_limits`` can identify the bytes as an
image rather than rejecting them. The PIL inspection only reads the
header so padding with zeros is harmless.
"""
# 8-byte PNG signature + IHDR chunk for a 1x1 image (zero-padded
# to size). Pillow accepts this enough to read dimensions; the
# remaining bytes after IHDR are treated as trailing garbage.
sig = b"\x89PNG\r\n\x1a\n"
ihdr = bytes.fromhex(
# length=13, type=IHDR, w=1, h=1, depth=8, color=2 (RGB),
# compression=0, filter=0, interlace=0, crc=ignored
"0000000d49484452000000010000000108020000009077"
"53de"
)
base = sig + ihdr
if len(base) >= size:
return base[:size]
return base + b"\x00" * (size - len(base))
async def _build_client(session: aiohttp.ClientSession) -> TelegramClient:
return TelegramClient(session, BOT_TOKEN)
@pytest.mark.asyncio
async def test_oversized_item_skipped_others_delivered() -> None:
"""One item over max_asset_data_size is dropped; siblings still go."""
mock_url_big = "http://assets.test/big.jpg"
mock_url_a = "http://assets.test/a.jpg"
mock_url_b = "http://assets.test/b.jpg"
max_size = 1_000_000 # 1 MB cap
# We pre-load bytes via the asset dict so we don't have to mock the
# asset HTTP server. Telegram side is mocked so sendMediaGroup
# returns a clean 200 with two message IDs.
assets = [
{"type": "photo", "url": mock_url_big, "data": _png_bytes(2_000_000)},
{"type": "photo", "url": mock_url_a, "data": _png_bytes(50_000)},
{"type": "photo", "url": mock_url_b, "data": _png_bytes(50_000)},
]
with aioresponses() as mocked:
mocked.post(
f"{TG}/sendMediaGroup",
payload={
"ok": True,
"result": [
{"message_id": 100, "photo": [{"file_id": "fa"}]},
{"message_id": 101, "photo": [{"file_id": "fb"}]},
],
},
)
async with aiohttp.ClientSession() as sess:
client = await _build_client(sess)
result = await client._send_media_group(
CHAT_ID, assets, max_asset_data_size=max_size,
)
assert result["success"] is True
assert result["delivered_count"] == 2
assert result["skipped_count"] == 1
assert result["failed_count"] == 0
assert result["message_ids"] == [100, 101]
@pytest.mark.asyncio
async def test_byte_budget_splits_into_sub_chunks() -> None:
"""Three items that combined exceed the byte budget pre-split into 2 calls."""
# Sized so 2 fit (sum < budget) but 3 don't (sum > budget) →
# [2 items, 1 item] split.
per_item = TELEGRAM_MAX_GROUP_TOTAL_BYTES // 3 + 1
# Use generated PNGs so check_photo_limits doesn't reject them as
# malformed; the size doesn't matter for the photo dimension check
# since the PNG header advertises 1x1.
assets = [
{"type": "photo", "url": f"http://t/{i}.jpg", "data": _png_bytes(per_item)}
for i in range(3)
]
calls: list[int] = []
def _ok_response_for_n(n: int) -> dict[str, Any]:
return {
"ok": True,
"result": [
{"message_id": 200 + i, "photo": [{"file_id": f"x{i}"}]}
for i in range(n)
],
}
with aioresponses() as mocked:
# We don't know item count per call up front, so respond with
# 10-item payloads (Telegram ignores trailing IDs we don't use).
mocked.post(
f"{TG}/sendMediaGroup",
payload=_ok_response_for_n(10),
repeat=True,
)
async with aiohttp.ClientSession() as sess:
client = await _build_client(sess)
# Disable photo limits — large PNG bodies trip dimension
# checks since we pad past the IHDR.
with patch(
"notify_bridge_core.notifications.telegram.client.check_photo_limits",
return_value=(False, None, None, None),
):
result = await client._send_media_group(CHAT_ID, assets)
# Count outbound sendMediaGroup calls via the mock registry.
req_log = mocked.requests
send_calls = [
k for k in req_log if k[1].path.endswith("/sendMediaGroup")
]
assert len(send_calls) >= 1
# At least one call → multiple requests recorded.
for k in send_calls:
calls.append(len(req_log[k]))
assert result["success"] is True
# Pre-split avoided 413 entirely.
assert result["failed_count"] == 0
# The 3 items went out across 2 sub-chunks (2+1).
assert sum(calls) == 2
@pytest.mark.asyncio
async def test_chunk_413_falls_back_to_per_item() -> None:
"""If Telegram 413s a chunk anyway, retry each item individually."""
assets = [
{"type": "photo", "url": f"http://t/{i}.jpg", "data": _png_bytes(50_000)}
for i in range(2)
]
with aioresponses() as mocked:
# The group send fails hard (Telegram-side rejection).
mocked.post(
f"{TG}/sendMediaGroup",
status=413,
payload={"ok": False, "error_code": 413, "description": "Request Entity Too Large"},
)
# Per-item fallback: two sendPhoto calls succeed.
mocked.post(
f"{TG}/sendPhoto",
payload={"ok": True, "result": {"message_id": 300, "photo": [{"file_id": "z0"}]}},
)
mocked.post(
f"{TG}/sendPhoto",
payload={"ok": True, "result": {"message_id": 301, "photo": [{"file_id": "z1"}]}},
)
async with aiohttp.ClientSession() as sess:
client = await _build_client(sess)
with patch(
"notify_bridge_core.notifications.telegram.client.check_photo_limits",
return_value=(False, None, None, None),
):
result = await client._send_media_group(CHAT_ID, assets)
assert result["success"] is True
assert result["delivered_count"] == 2
assert result["failed_count"] == 0
# We still record the original chunk-level error for diagnostics,
# tagged with kind="chunk" so operators can distinguish cause from
# per-item consequences.
assert result["errors"] is not None
chunk_errors = [e for e in result["errors"] if e.get("kind") == "chunk"]
assert len(chunk_errors) == 1
assert "Request Entity Too Large" in str(chunk_errors[0]["error"])
@pytest.mark.asyncio
async def test_chunk_failure_with_per_item_partial_failure() -> None:
"""Per-item fallback can itself partially fail; we report both."""
assets = [
{"type": "photo", "url": f"http://t/{i}.jpg", "data": _png_bytes(50_000)}
for i in range(2)
]
with aioresponses() as mocked:
mocked.post(
f"{TG}/sendMediaGroup",
status=400,
payload={"ok": False, "error_code": 400, "description": "Bad Request"},
)
# First per-item OK, second fails.
mocked.post(
f"{TG}/sendPhoto",
payload={"ok": True, "result": {"message_id": 400, "photo": [{"file_id": "p0"}]}},
)
mocked.post(
f"{TG}/sendPhoto",
status=400,
payload={"ok": False, "error_code": 400, "description": "PHOTO_INVALID_DIMENSIONS"},
)
async with aiohttp.ClientSession() as sess:
client = await _build_client(sess)
with patch(
"notify_bridge_core.notifications.telegram.client.check_photo_limits",
return_value=(False, None, None, None),
):
result = await client._send_media_group(CHAT_ID, assets)
# At least one item delivered → overall success.
assert result["success"] is True
assert result["delivered_count"] == 1
assert result["failed_count"] == 1
assert result["message_ids"] == [400]
# The failed item carries its index so operators can correlate
# with the original asset list.
item_errors = [e for e in result["errors"] if e.get("kind") == "item"]
assert len(item_errors) == 1
assert item_errors[0]["item_index"] == 1
@pytest.mark.asyncio
async def test_document_chunk_failure_falls_back_to_sendDocument() -> None:
"""Document items must hit /sendDocument in fallback, not /sendVideo.
Regression guard: an earlier draft routed any non-photo through
_VIDEO_KIND, silently misrouting documents to the video endpoint
where Telegram would reject them with a confusing error.
"""
assets = [
{"type": "document", "url": f"http://t/f{i}.bin", "data": b"\x00" * 50_000}
for i in range(2)
]
with aioresponses() as mocked:
mocked.post(
f"{TG}/sendMediaGroup",
status=400,
payload={"ok": False, "error_code": 400, "description": "Bad Request"},
)
mocked.post(
f"{TG}/sendDocument",
payload={"ok": True, "result": {"message_id": 500, "document": {"file_id": "d0"}}},
)
mocked.post(
f"{TG}/sendDocument",
payload={"ok": True, "result": {"message_id": 501, "document": {"file_id": "d1"}}},
)
async with aiohttp.ClientSession() as sess:
client = await _build_client(sess)
result = await client._send_media_group(CHAT_ID, assets)
# No /sendVideo or /sendPhoto calls should have been made.
for key in mocked.requests:
assert "/sendVideo" not in key[1].path
assert "/sendPhoto" not in key[1].path
assert result["success"] is True
assert result["delivered_count"] == 2
assert result["message_ids"] == [500, 501]
@pytest.mark.asyncio
async def test_oversized_video_deferred_as_document_when_opted_in() -> None:
"""Oversized videos are sent as documents post-chunk when the flag is set.
Telegram caps sendVideo at 50 MB but accepts up to 2 GB via
sendDocument. With ``send_large_videos_as_documents=True``, an
oversized video should be deferred out of the media group, then
delivered as its own document send instead of being silently
dropped. Other items in the same group must ride through the
normal sendMediaGroup path unaffected.
"""
# 60 MB exceeds the 50 MB sendVideo cap but is under document's 2 GB cap.
oversized_video = b"\x00" * (60 * 1024 * 1024)
assets = [
{"type": "video", "url": "http://t/big.mp4", "data": oversized_video,
"content_type": "video/mp4"},
{"type": "photo", "url": "http://t/a.jpg", "data": _png_bytes(50_000)},
{"type": "photo", "url": "http://t/b.jpg", "data": _png_bytes(50_000)},
]
with aioresponses() as mocked:
# The 2 photos ride out in sendMediaGroup together.
mocked.post(
f"{TG}/sendMediaGroup",
payload={
"ok": True,
"result": [
{"message_id": 700, "photo": [{"file_id": "p0"}]},
{"message_id": 701, "photo": [{"file_id": "p1"}]},
],
},
)
# The deferred video lands as a document after the chunk.
mocked.post(
f"{TG}/sendDocument",
payload={"ok": True, "result": {"message_id": 702, "document": {"file_id": "d0"}}},
)
async with aiohttp.ClientSession() as sess:
client = await _build_client(sess)
with patch(
"notify_bridge_core.notifications.telegram.client.check_photo_limits",
return_value=(False, None, None, None),
):
result = await client._send_media_group(
CHAT_ID, assets,
send_large_videos_as_documents=True,
)
# sendVideo must NOT have been called — the oversized video
# bypasses sendVideo entirely and goes straight to sendDocument.
for key in mocked.requests:
assert "/sendVideo" not in key[1].path
assert result["success"] is True
assert result["delivered_count"] == 3
assert result["skipped_count"] == 0
assert result["failed_count"] == 0
assert sorted(result["message_ids"]) == [700, 701, 702]
@pytest.mark.asyncio
async def test_oversized_video_skipped_when_flag_off() -> None:
"""Without the opt-in flag, oversized videos are dropped (legacy behavior)."""
oversized_video = b"\x00" * (60 * 1024 * 1024)
assets = [
{"type": "video", "url": "http://t/big.mp4", "data": oversized_video,
"content_type": "video/mp4"},
{"type": "photo", "url": "http://t/a.jpg", "data": _png_bytes(50_000)},
]
with aioresponses() as mocked:
mocked.post(
f"{TG}/sendMediaGroup",
payload={
"ok": True,
"result": [{"message_id": 800, "photo": [{"file_id": "p0"}]}],
},
)
async with aiohttp.ClientSession() as sess:
client = await _build_client(sess)
with patch(
"notify_bridge_core.notifications.telegram.client.check_photo_limits",
return_value=(False, None, None, None),
):
result = await client._send_media_group(CHAT_ID, assets)
# No sendDocument call either — video is simply dropped.
for key in mocked.requests:
assert "/sendDocument" not in key[1].path
assert result["success"] is True
assert result["delivered_count"] == 1
assert result["skipped_count"] == 1
@pytest.mark.asyncio
async def test_all_items_oversized_returns_failure() -> None:
"""When every asset is filtered before send, success is False."""
assets = [
{"type": "photo", "url": "http://t/big.jpg", "data": _png_bytes(5_000_000)}
for _ in range(2)
]
async with aiohttp.ClientSession() as sess:
client = await _build_client(sess)
# No HTTP mock needed — nothing should reach Telegram.
result = await client._send_media_group(
CHAT_ID, assets, max_asset_data_size=1_000_000,
)
assert result["success"] is False
assert result["delivered_count"] == 0
assert result["skipped_count"] == 2
assert result["failed_count"] == 0
assert "filtered" in result["error"]
@@ -0,0 +1,249 @@
"""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