Files
notify-bridge/packages/server/tests/test_dispatch_summary.py
T
alexei.dolgolyov 6a8f374678 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.
2026-05-28 15:19:31 +03:00

358 lines
12 KiB
Python

"""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}