"""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": [, ...], # ← 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}