6a8f374678
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.
358 lines
12 KiB
Python
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}
|