From 42de5ce326b406c8e4cad217adbc11789c1a7b67 Mon Sep 17 00:00:00 2001 From: dny238 Date: Sun, 12 Jul 2026 07:29:04 -0600 Subject: [PATCH] Fix Discord/NTFY notifications double-posting via generic sender fall-through The Discord and NTFY handlers in SendNotificationAsync did not return after sending, so execution fell through to the generic webhook sender and every Discord/NTFY notification was posted a second time. For Discord the duplicate is built by CreateDiscordPayload; when the configured base URL is not an absolute http(s) URL (e.g. the default UrlBase "/"), its embed thumbnail is a relative path, which the Discord API rejects with 400 {"embeds": ["0"]}. The log then shows a delivery failure even though the first (Discord-specific) send succeeded. When the embed is valid, the channel simply receives two copies. Adds regression tests asserting exactly one HTTP request is made per Discord/NTFY notification (verified failing before the fix). Co-Authored-By: Claude Fable 5 --- .../Delivery/NotificationService.Webhooks.cs | 4 + .../NotificationServiceSingleDispatchTests.cs | 111 ++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 tests/Features/Infrastructure/Notifications/Delivery/NotificationServiceSingleDispatchTests.cs diff --git a/listenarr.infrastructure/Notifications/Delivery/NotificationService.Webhooks.cs b/listenarr.infrastructure/Notifications/Delivery/NotificationService.Webhooks.cs index dc9e40a2b..4e232ccd1 100644 --- a/listenarr.infrastructure/Notifications/Delivery/NotificationService.Webhooks.cs +++ b/listenarr.infrastructure/Notifications/Delivery/NotificationService.Webhooks.cs @@ -81,6 +81,8 @@ public async Task SendNotificationAsync(string trigger, object data, string webh } #pragma warning restore CA1031 + // Discord handled; do not fall through to the generic sender (would double-post). + return; } // NTFY-specific handling (https://docs.ntfy.sh/publish/) @@ -139,6 +141,8 @@ public async Task SendNotificationAsync(string trigger, object data, string webh _logger.LogError(ex, "Invalid operation while sending NTFY notification to {WebhookUrl}", LogRedaction.RedactText(webhookUrl, LogRedaction.GetSensitiveValuesFromEnvironment())); } + // NTFY handled; do not fall through to the generic sender (would double-post). + return; } // Pushover (https://pushover.net/api) diff --git a/tests/Features/Infrastructure/Notifications/Delivery/NotificationServiceSingleDispatchTests.cs b/tests/Features/Infrastructure/Notifications/Delivery/NotificationServiceSingleDispatchTests.cs new file mode 100644 index 000000000..cf4695b79 --- /dev/null +++ b/tests/Features/Infrastructure/Notifications/Delivery/NotificationServiceSingleDispatchTests.cs @@ -0,0 +1,111 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System.Net; + +namespace Listenarr.Tests.Features.Infrastructure.Notifications.Delivery +{ + // Regression tests: the Discord and NTFY handlers must not fall through to the + // generic webhook sender after handling the notification. The fall-through posted a + // second copy of every Discord/NTFY notification; for Discord the duplicate used a + // payload Discord rejects with 400 {"embeds": ["0"]} whenever the embed contained a + // relative thumbnail URL, making delivery look broken even though the first send + // had succeeded. + public partial class NotificationServiceTests + { + private static (NotificationService Service, Func GetRequestCount) CreateServiceWithCountingHandler() + { + var requestCount = 0; + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .Callback((_, _) => Interlocked.Increment(ref requestCount)) + .ReturnsAsync(() => new HttpResponseMessage(HttpStatusCode.OK)); + + var httpClient = new HttpClient(mockHandler.Object); + + var mockConfigService = new Mock(); + mockConfigService + .Setup(x => x.GetStartupConfigAsync()) + .ReturnsAsync(new StartupConfig { UrlBase = "https://listenarr.example.com" }); + + var services = new ServiceCollection(); + services.AddSingleton(); + var provider = services.BuildServiceProvider(); + var payloadBuilder = provider.GetRequiredService(); + + var service = new NotificationService( + httpClient, + Mock.Of>(), + mockConfigService.Object, + payloadBuilder, + Mock.Of() + ); + + return (service, () => requestCount); + } + + [Fact] + public async Task SendNotificationAsync_DiscordWebhook_PostsExactlyOnce() + { + var (service, getRequestCount) = CreateServiceWithCountingHandler(); + var trigger = "book-added"; + var data = new + { + id = 1, + title = "Test Book", + authors = new[] { "Jane Doe" }, + asin = "B00TEST" + }; + + await service.SendNotificationAsync( + trigger, + data, + "https://discord.com/api/webhooks/123456/token", + new List { trigger }); + + Assert.Equal(1, getRequestCount()); + } + + [Fact] + public async Task SendNotificationAsync_NtfyWebhook_PostsExactlyOnce() + { + var (service, getRequestCount) = CreateServiceWithCountingHandler(); + var trigger = "book-added"; + var data = new + { + id = 1, + title = "Test Book", + authors = new[] { "Jane Doe" }, + asin = "B00TEST" + }; + + await service.SendNotificationAsync( + trigger, + data, + "https://ntfy.sh/listenarr-test", + new List { trigger }); + + Assert.Equal(1, getRequestCount()); + } + } +}