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:
@@ -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
|
||||
Reference in New Issue
Block a user