From 7f8ddcfb4e83fb404a18060b9332e5a9693fd0e4 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Fri, 25 Sep 2026 00:37:22 +0300 Subject: [PATCH] Fix notifications not being sent asynchronously (#811) --- .../DependencyInjection/MainDI.cs | 19 +- .../NotificationConsumerTests.cs | 602 +++--------------- .../NotificationPublisherTests.cs | 507 ++++----------- .../Consumers/NotificationConsumer.cs | 111 +--- .../Notifications/Models/ArrNotification.cs | 14 - .../Models/CategoryChangedNotification.cs | 5 - .../Models/DownloadCleanedNotification.cs | 5 - .../Models/DownloadStoppedNotification.cs | 5 - .../Models/FailedImportStrikeNotification.cs | 5 - .../Notifications/Models/Notification.cs | 12 - .../Notifications/Models/NotificationField.cs | 8 - .../Notifications/Models/NotificationLevel.cs | 9 - .../Models/NotificationMessage.cs | 5 + .../Models/QueueItemDeletedNotification.cs | 5 - .../Models/SlowSpeedStrikeNotification.cs | 5 - .../Models/SlowTimeStrikeNotification.cs | 5 - .../Models/StalledStrikeNotification.cs | 5 - .../Notifications/NotificationPublisher.cs | 34 +- .../Notifications/NotificationService.cs | 14 +- .../deluge-seeding-rule-cleanup.spec.ts | 47 +- e2e/tests/helpers/app-api.ts | 22 + 21 files changed, 291 insertions(+), 1153 deletions(-) delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/ArrNotification.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/CategoryChangedNotification.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/DownloadCleanedNotification.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/DownloadStoppedNotification.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/FailedImportStrikeNotification.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/Notification.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationField.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationLevel.cs create mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationMessage.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/QueueItemDeletedNotification.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/SlowSpeedStrikeNotification.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/SlowTimeStrikeNotification.cs delete mode 100644 code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/StalledStrikeNotification.cs diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs index 3f368c65..9f9f0c2b 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs @@ -2,7 +2,6 @@ using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers; using Cleanuparr.Infrastructure.Features.Notifications.Consumers; -using Cleanuparr.Infrastructure.Features.Notifications.Models; using Cleanuparr.Infrastructure.Health; using Cleanuparr.Infrastructure.Http; using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; @@ -28,14 +27,7 @@ public static class MainDI config.DisableUsageTelemetry(); config.AddConsumer(); - config.AddConsumer>(); - config.AddConsumer>(); - config.AddConsumer>(); - config.AddConsumer>(); - config.AddConsumer>(); - config.AddConsumer>(); - config.AddConsumer>(); - config.AddConsumer>(); + config.AddConsumer(); config.UsingInMemory((context, cfg) => { @@ -55,14 +47,7 @@ public static class MainDI cfg.ReceiveEndpoint("notification-queue", e => { - e.ConfigureConsumer>(context); - e.ConfigureConsumer>(context); - e.ConfigureConsumer>(context); - e.ConfigureConsumer>(context); - e.ConfigureConsumer>(context); - e.ConfigureConsumer>(context); - e.ConfigureConsumer>(context); - e.ConfigureConsumer>(context); + e.ConfigureConsumer(context); e.ConcurrentMessageLimit = 1; e.PrefetchCount = 1; }); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConsumerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConsumerTests.cs index 62b391a7..c898e1c7 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConsumerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConsumerTests.cs @@ -4,7 +4,6 @@ using Cleanuparr.Infrastructure.Features.Notifications.Consumers; using Cleanuparr.Infrastructure.Features.Notifications.Models; using MassTransit; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Time.Testing; using NSubstitute; using NSubstitute.ExceptionExtensions; using Shouldly; @@ -14,549 +13,122 @@ namespace Cleanuparr.Infrastructure.Tests.Features.Notifications; public class NotificationConsumerTests { - private readonly ILogger _serviceLogger; private readonly INotificationConfigurationService _configurationService; private readonly INotificationProviderFactory _providerFactory; - private readonly NotificationService _notificationService; - private readonly FakeTimeProvider _timeProvider; + private readonly NotificationConsumer _consumer; public NotificationConsumerTests() { - _serviceLogger = Substitute.For>(); _configurationService = Substitute.For(); _providerFactory = Substitute.For(); - _timeProvider = new FakeTimeProvider(); - _notificationService = new NotificationService( - _serviceLogger, + NotificationService notificationService = new( + Substitute.For>(), _configurationService, _providerFactory, TimeProvider.System); + _consumer = new NotificationConsumer(notificationService); } - #region Consume Tests - FailedImportStrikeNotification - - [Fact] - public async Task Consume_FailedImportStrikeNotification_SendsCorrectEventType() + private static NotificationContext CreateContext(NotificationEventType eventType) { - // Arrange - var consumer = CreateConsumer(); - var notification = new FailedImportStrikeNotification + return new NotificationContext { - Title = "Test Failed Import", - Description = "Test Description", - Level = NotificationLevel.Warning, - InstanceType = InstanceType.Radarr, - InstanceUrl = new Uri("http://radarr.local"), - Hash = "TEST123" + EventType = eventType, + Title = "Test title", + Description = "Test description", }; - var context = CreateConsumeContext(notification); - NotificationEventType? capturedEventType = null; - - var provider = Substitute.For(); - var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; - - _configurationService - .GetProvidersForEventAsync(Arg.Any()) - .Returns(new List { providerDto }) - .AndDoes(ci => capturedEventType = ci.ArgAt(0)); - - _providerFactory - .CreateProvider(Arg.Any()) - .Returns(provider); - - provider.SendNotificationAsync(Arg.Any()).Returns(Task.CompletedTask); - - // Act - await ConsumeWithTimeAdvance(consumer, context); - - // Assert - capturedEventType.ShouldBe(NotificationEventType.FailedImportStrike); } - #endregion - - #region Consume Tests - StalledStrikeNotification - - [Fact] - public async Task Consume_StalledStrikeNotification_SendsCorrectEventType() + private static NotificationProviderDto CreateProviderDto(string name = "TestProvider") { - // Arrange - var consumer = CreateConsumer(); - var notification = new StalledStrikeNotification + return new NotificationProviderDto { - Title = "Test Stalled", - Description = "Stalled Description", - Level = NotificationLevel.Important, - InstanceType = InstanceType.Sonarr, - InstanceUrl = new Uri("http://sonarr.local"), - Hash = "STALL123" + Id = Guid.NewGuid(), + Name = name, + Type = NotificationProviderType.Notifiarr, + IsEnabled = true, + Events = new NotificationEventFlags(), + Configuration = new { ApiKey = "test" } }; - var context = CreateConsumeContext(notification); - NotificationEventType? capturedEventType = null; - - var provider = Substitute.For(); - var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; - - _configurationService - .GetProvidersForEventAsync(Arg.Any()) - .Returns(new List { providerDto }) - .AndDoes(ci => capturedEventType = ci.ArgAt(0)); - - _providerFactory - .CreateProvider(Arg.Any()) - .Returns(provider); - - provider.SendNotificationAsync(Arg.Any()).Returns(Task.CompletedTask); - - // Act - await ConsumeWithTimeAdvance(consumer, context); - - // Assert - capturedEventType.ShouldBe(NotificationEventType.StalledStrike); } - #endregion - - #region Consume Tests - SlowSpeedStrikeNotification - - [Fact] - public async Task Consume_SlowSpeedStrikeNotification_SendsCorrectEventType() + private static ConsumeContext CreateConsumeContext(NotificationMessage message) { - // Arrange - var consumer = CreateConsumer(); - var notification = new SlowSpeedStrikeNotification - { - Title = "Slow Speed", - Description = "Download too slow", - Level = NotificationLevel.Warning, - InstanceType = InstanceType.Radarr, - InstanceUrl = new Uri("http://radarr.local"), - Hash = "SLOW123" - }; - var context = CreateConsumeContext(notification); - NotificationEventType? capturedEventType = null; - - var provider = Substitute.For(); - var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; - - _configurationService - .GetProvidersForEventAsync(Arg.Any()) - .Returns(new List { providerDto }) - .AndDoes(ci => capturedEventType = ci.ArgAt(0)); - - _providerFactory - .CreateProvider(Arg.Any()) - .Returns(provider); - - provider.SendNotificationAsync(Arg.Any()).Returns(Task.CompletedTask); - - // Act - await ConsumeWithTimeAdvance(consumer, context); - - // Assert - capturedEventType.ShouldBe(NotificationEventType.SlowSpeedStrike); - } - - #endregion - - #region Consume Tests - SlowTimeStrikeNotification - - [Fact] - public async Task Consume_SlowTimeStrikeNotification_SendsCorrectEventType() - { - // Arrange - var consumer = CreateConsumer(); - var notification = new SlowTimeStrikeNotification - { - Title = "Slow Time", - Description = "Download taking too long", - Level = NotificationLevel.Warning, - InstanceType = InstanceType.Radarr, - InstanceUrl = new Uri("http://radarr.local"), - Hash = "TIME123" - }; - var context = CreateConsumeContext(notification); - NotificationEventType? capturedEventType = null; - - var provider = Substitute.For(); - var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; - - _configurationService - .GetProvidersForEventAsync(Arg.Any()) - .Returns(new List { providerDto }) - .AndDoes(ci => capturedEventType = ci.ArgAt(0)); - - _providerFactory - .CreateProvider(Arg.Any()) - .Returns(provider); - - provider.SendNotificationAsync(Arg.Any()).Returns(Task.CompletedTask); - - // Act - await ConsumeWithTimeAdvance(consumer, context); - - // Assert - capturedEventType.ShouldBe(NotificationEventType.SlowTimeStrike); - } - - #endregion - - #region Consume Tests - QueueItemDeletedNotification - - [Fact] - public async Task Consume_QueueItemDeletedNotification_SendsCorrectEventType() - { - // Arrange - var consumer = CreateConsumer(); - var notification = new QueueItemDeletedNotification - { - Title = "Item Deleted", - Description = "Queue item removed", - Level = NotificationLevel.Important, - InstanceType = InstanceType.Lidarr, - InstanceUrl = new Uri("http://lidarr.local"), - Hash = "DEL123" - }; - var context = CreateConsumeContext(notification); - NotificationEventType? capturedEventType = null; - - var provider = Substitute.For(); - var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; - - _configurationService - .GetProvidersForEventAsync(Arg.Any()) - .Returns(new List { providerDto }) - .AndDoes(ci => capturedEventType = ci.ArgAt(0)); - - _providerFactory - .CreateProvider(Arg.Any()) - .Returns(provider); - - provider.SendNotificationAsync(Arg.Any()).Returns(Task.CompletedTask); - - // Act - await ConsumeWithTimeAdvance(consumer, context); - - // Assert - capturedEventType.ShouldBe(NotificationEventType.QueueItemDeleted); - } - - #endregion - - #region Consume Tests - DownloadCleanedNotification - - [Fact] - public async Task Consume_DownloadCleanedNotification_SendsCorrectEventType() - { - // Arrange - var consumer = CreateConsumer(); - var notification = new DownloadCleanedNotification - { - Title = "Download Cleaned", - Description = "Old download removed", - Level = NotificationLevel.Information - }; - var context = CreateConsumeContext(notification); - NotificationEventType? capturedEventType = null; - - var provider = Substitute.For(); - var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; - - _configurationService - .GetProvidersForEventAsync(Arg.Any()) - .Returns(new List { providerDto }) - .AndDoes(ci => capturedEventType = ci.ArgAt(0)); - - _providerFactory - .CreateProvider(Arg.Any()) - .Returns(provider); - - provider.SendNotificationAsync(Arg.Any()).Returns(Task.CompletedTask); - - // Act - await ConsumeWithTimeAdvance(consumer, context); - - // Assert - capturedEventType.ShouldBe(NotificationEventType.DownloadCleaned); - } - - #endregion - - #region Consume Tests - CategoryChangedNotification - - [Fact] - public async Task Consume_CategoryChangedNotification_SendsCorrectEventType() - { - // Arrange - var consumer = CreateConsumer(); - var notification = new CategoryChangedNotification - { - Title = "Category Changed", - Description = "Category updated", - Level = NotificationLevel.Information - }; - var context = CreateConsumeContext(notification); - NotificationEventType? capturedEventType = null; - - var provider = Substitute.For(); - var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; - - _configurationService - .GetProvidersForEventAsync(Arg.Any()) - .Returns(new List { providerDto }) - .AndDoes(ci => capturedEventType = ci.ArgAt(0)); - - _providerFactory - .CreateProvider(Arg.Any()) - .Returns(provider); - - provider.SendNotificationAsync(Arg.Any()).Returns(Task.CompletedTask); - - // Act - await ConsumeWithTimeAdvance(consumer, context); - - // Assert - capturedEventType.ShouldBe(NotificationEventType.CategoryChanged); - } - - #endregion - - #region NotificationContext Conversion Tests - - [Theory] - [InlineData(NotificationLevel.Information, EventSeverity.Information)] - [InlineData(NotificationLevel.Warning, EventSeverity.Warning)] - [InlineData(NotificationLevel.Important, EventSeverity.Important)] - public async Task Consume_MapsNotificationLevelToSeverity(NotificationLevel level, EventSeverity expectedSeverity) - { - // Arrange - var consumer = CreateConsumer(); - var notification = new FailedImportStrikeNotification - { - Title = "Test", - Description = "Test", - Level = level, - InstanceType = InstanceType.Radarr, - InstanceUrl = new Uri("http://radarr.local"), - Hash = "LEVEL123" - }; - var context = CreateConsumeContext(notification); - NotificationContext? capturedContext = null; - - var provider = Substitute.For(); - var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; - - _configurationService - .GetProvidersForEventAsync(Arg.Any()) - .Returns(new List { providerDto }); - - _providerFactory - .CreateProvider(Arg.Any()) - .Returns(provider); - - provider - .SendNotificationAsync(Arg.Any()) - .Returns(Task.CompletedTask) - .AndDoes(ci => capturedContext = ci.ArgAt(0)); - - // Act - await ConsumeWithTimeAdvance(consumer, context); - - // Assert - capturedContext.ShouldNotBeNull(); - capturedContext.Severity.ShouldBe(expectedSeverity); - } - - [Fact] - public async Task Consume_ArrNotification_IncludesArrDataInContext() - { - // Arrange - var consumer = CreateConsumer(); - var notification = new FailedImportStrikeNotification - { - Title = "Test", - Description = "Test", - Level = NotificationLevel.Warning, - InstanceType = InstanceType.Sonarr, - InstanceUrl = new Uri("http://sonarr.local"), - Hash = "ABC123", - Image = new Uri("http://example.com/image.jpg") - }; - var context = CreateConsumeContext(notification); - NotificationContext? capturedContext = null; - - var provider = Substitute.For(); - var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; - - _configurationService - .GetProvidersForEventAsync(Arg.Any()) - .Returns(new List { providerDto }); - - _providerFactory - .CreateProvider(Arg.Any()) - .Returns(provider); - - provider - .SendNotificationAsync(Arg.Any()) - .Returns(Task.CompletedTask) - .AndDoes(ci => capturedContext = ci.ArgAt(0)); - - // Act - await ConsumeWithTimeAdvance(consumer, context); - - // Assert - capturedContext.ShouldNotBeNull(); - capturedContext.Data["Instance type"].ShouldBe("Sonarr"); - capturedContext.Data["Url"].ShouldBe("http://sonarr.local/"); - capturedContext.Data["Hash"].ShouldBe("ABC123"); - capturedContext.Image.ShouldBe(new Uri("http://example.com/image.jpg")); - } - - [Fact] - public async Task Consume_WithCustomFields_IncludesFieldsInContext() - { - // Arrange - var consumer = CreateConsumer(); - var notification = new FailedImportStrikeNotification - { - Title = "Test", - Description = "Test", - Level = NotificationLevel.Warning, - InstanceType = InstanceType.Radarr, - InstanceUrl = new Uri("http://radarr.local"), - Hash = "XYZ789", - Fields = new List - { - new() { Key = "CustomKey1", Value = "CustomValue1" }, - new() { Key = "CustomKey2", Value = "CustomValue2" } - } - }; - var context = CreateConsumeContext(notification); - NotificationContext? capturedContext = null; - - var provider = Substitute.For(); - var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; - - _configurationService - .GetProvidersForEventAsync(Arg.Any()) - .Returns(new List { providerDto }); - - _providerFactory - .CreateProvider(Arg.Any()) - .Returns(provider); - - provider - .SendNotificationAsync(Arg.Any()) - .Returns(Task.CompletedTask) - .AndDoes(ci => capturedContext = ci.ArgAt(0)); - - // Act - await ConsumeWithTimeAdvance(consumer, context); - - // Assert - capturedContext.ShouldNotBeNull(); - capturedContext.Data["CustomKey1"].ShouldBe("CustomValue1"); - capturedContext.Data["CustomKey2"].ShouldBe("CustomValue2"); - } - - [Fact] - public async Task Consume_NonArrNotification_DoesNotIncludeArrData() - { - // Arrange - var consumer = CreateConsumer(); - var notification = new DownloadCleanedNotification - { - Title = "Download Cleaned", - Description = "Test", - Level = NotificationLevel.Information - }; - var context = CreateConsumeContext(notification); - NotificationContext? capturedContext = null; - - var provider = Substitute.For(); - var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise }; - - _configurationService - .GetProvidersForEventAsync(Arg.Any()) - .Returns(new List { providerDto }); - - _providerFactory - .CreateProvider(Arg.Any()) - .Returns(provider); - - provider - .SendNotificationAsync(Arg.Any()) - .Returns(Task.CompletedTask) - .AndDoes(ci => capturedContext = ci.ArgAt(0)); - - // Act - await ConsumeWithTimeAdvance(consumer, context); - - // Assert - capturedContext.ShouldNotBeNull(); - capturedContext.Data.ContainsKey("Instance type").ShouldBeFalse(); - capturedContext.Data.ContainsKey("Url").ShouldBeFalse(); - capturedContext.Data.ContainsKey("Hash").ShouldBeFalse(); - } - - #endregion - - #region No Providers Configured Tests - - [Fact] - public async Task Consume_WhenNoProvidersConfigured_DoesNotSendNotification() - { - // Arrange - var consumer = CreateConsumer(); - var notification = new FailedImportStrikeNotification - { - Title = "Test", - Description = "Test", - Level = NotificationLevel.Warning, - InstanceType = InstanceType.Radarr, - InstanceUrl = new Uri("http://radarr.local"), - Hash = "NOPROV123" - }; - var context = CreateConsumeContext(notification); - - _configurationService - .GetProvidersForEventAsync(Arg.Any()) - .Returns(new List()); - - // Act - await ConsumeWithTimeAdvance(consumer, context); - - // Assert - _providerFactory.DidNotReceive().CreateProvider(Arg.Any()); - } - - #endregion - - #region Helper Methods - - private NotificationConsumer CreateConsumer() where T : Notification - { - var logger = Substitute.For>>(); - return new NotificationConsumer(logger, _notificationService, _timeProvider); - } - - private static ConsumeContext CreateConsumeContext(T message) where T : class - { - var context = Substitute.For>(); + ConsumeContext context = Substitute.For>(); context.Message.Returns(message); return context; } - /// - /// Executes the consumer and advances time past the 1-second spam prevention delay - /// - private async Task ConsumeWithTimeAdvance(NotificationConsumer consumer, ConsumeContext context) where T : Notification + [Fact] + public async Task Consume_WithConfiguredProviders_SendsToAll() { - var task = consumer.Consume(context); - _timeProvider.Advance(TimeSpan.FromSeconds(1)); - await task; + NotificationProviderDto providerDto1 = CreateProviderDto("Provider1"); + NotificationProviderDto providerDto2 = CreateProviderDto("Provider2"); + INotificationProvider provider1 = Substitute.For(); + INotificationProvider provider2 = Substitute.For(); + + NotificationMessage message = new(NotificationEventType.FailedImportStrike, CreateContext(NotificationEventType.FailedImportStrike)); + + _configurationService.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike) + .Returns(new List { providerDto1, providerDto2 }); + _providerFactory.CreateProvider(providerDto1).Returns(provider1); + _providerFactory.CreateProvider(providerDto2).Returns(provider2); + + await _consumer.Consume(CreateConsumeContext(message)); + + await provider1.Received(1).SendNotificationAsync(message.Context); + await provider2.Received(1).SendNotificationAsync(message.Context); } - #endregion + [Fact] + public async Task Consume_WhenOneProviderThrows_OthersStillReceiveNotification() + { + NotificationProviderDto providerDto1 = CreateProviderDto("Provider1"); + NotificationProviderDto providerDto2 = CreateProviderDto("Provider2"); + INotificationProvider provider1 = Substitute.For(); + INotificationProvider provider2 = Substitute.For(); + + provider1.SendNotificationAsync(Arg.Any()) + .ThrowsAsync(new Exception("Provider1 failed")); + + NotificationMessage message = new(NotificationEventType.StalledStrike, CreateContext(NotificationEventType.StalledStrike)); + + _configurationService.GetProvidersForEventAsync(NotificationEventType.StalledStrike) + .Returns(new List { providerDto1, providerDto2 }); + _providerFactory.CreateProvider(providerDto1).Returns(provider1); + _providerFactory.CreateProvider(providerDto2).Returns(provider2); + + await _consumer.Consume(CreateConsumeContext(message)); + + await provider2.Received(1).SendNotificationAsync(message.Context); + } + + [Fact] + public async Task Consume_WhenNoProvidersConfigured_DoesNotCreateAnyProvider() + { + NotificationMessage message = new(NotificationEventType.QueueItemDeleted, CreateContext(NotificationEventType.QueueItemDeleted)); + + _configurationService.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted) + .Returns(new List()); + + await _consumer.Consume(CreateConsumeContext(message)); + + _providerFactory.DidNotReceive().CreateProvider(Arg.Any()); + } + + [Fact] + public async Task Consume_WhenProviderLookupThrows_DoesNotThrow() + { + NotificationMessage message = new(NotificationEventType.QueueItemDeleted, CreateContext(NotificationEventType.QueueItemDeleted)); + + _configurationService.GetProvidersForEventAsync(Arg.Any()) + .ThrowsAsync(new InvalidOperationException("db locked")); + + ConsumeContext ctx = CreateConsumeContext(message); + + await Should.NotThrowAsync(() => _consumer.Consume(ctx)); + + _providerFactory.DidNotReceive().CreateProvider(Arg.Any()); + } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs index 108de6ea..013cf833 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationPublisherTests.cs @@ -1,12 +1,12 @@ -using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.Notifications; using Cleanuparr.Infrastructure.Features.Notifications.Models; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Infrastructure.Tests.TestHelpers; -using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using MassTransit; using Microsoft.Extensions.Logging; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -19,16 +19,14 @@ public class NotificationPublisherTests { private readonly ILogger _logger; private readonly IDryRunInterceptor _dryRunInterceptor; - private readonly INotificationConfigurationService _configService; - private readonly INotificationProviderFactory _providerFactory; + private readonly IBus _messageBus; private readonly NotificationPublisher _publisher; public NotificationPublisherTests() { _logger = Substitute.For>(); _dryRunInterceptor = Substitute.For(); - _configService = Substitute.For(); - _providerFactory = Substitute.For(); + _messageBus = Substitute.For(); _dryRunInterceptor.InterceptAsync(Arg.Any>(), Arg.Any()) .ReturnsForAnyArgs(ci => ci.ArgAt>(0).Invoke()); @@ -36,8 +34,7 @@ public class NotificationPublisherTests _publisher = new NotificationPublisher( _logger, _dryRunInterceptor, - _configService, - _providerFactory); + _messageBus); } private void SetupContext(InstanceType instanceType = InstanceType.Sonarr) @@ -78,29 +75,21 @@ public class NotificationPublisherTests #region NotifyStrike Tests [Fact] - public async Task NotifyStrike_WithStalledStrike_SendsNotification() + public async Task NotifyStrike_WithStalledStrike_PublishesNotification() { // Arrange SetupContext(); var rule = new StallRule { Name = "Test Rule" }; ContextProvider.Set(rule); - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.StalledStrike) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - // Act await _publisher.NotifyStrike(StrikeType.Stalled, 1); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.EventType == NotificationEventType.StalledStrike && - c.Data.ContainsKey("Strike type") && - c.Data["Strike type"] == "Stalled")); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.EventType == NotificationEventType.StalledStrike && + m.Context.Data.ContainsKey("Strike type") && + m.Context.Data["Strike type"] == "Stalled"), Arg.Any()); } [Fact] @@ -109,21 +98,13 @@ public class NotificationPublisherTests // Arrange SetupContext(); - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - // Act await _publisher.NotifyStrike(StrikeType.FailedImport, 2); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.EventType == NotificationEventType.FailedImportStrike && - c.Data["Strike count"] == "2")); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.EventType == NotificationEventType.FailedImportStrike && + m.Context.Data["Strike count"] == "2"), Arg.Any()); } [Theory] @@ -142,19 +123,12 @@ public class NotificationPublisherTests ContextProvider.Set(rule); } - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(expectedEventType) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - // Act await _publisher.NotifyStrike(strikeType, 1); // Assert - await _configService.Received(1).GetProvidersForEventAsync(expectedEventType); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.EventType == expectedEventType), Arg.Any()); } [Fact] @@ -163,42 +137,7 @@ public class NotificationPublisherTests // Act & Assert await _publisher.NotifyStrike(StrikeType.DeadTorrent, 3); - await _configService.DidNotReceive().GetProvidersForEventAsync(Arg.Any()); - } - - [Fact] - public async Task NotifyStrike_WhenNoProviders_DoesNotThrow() - { - // Arrange - SetupContext(); - _configService.GetProvidersForEventAsync(Arg.Any()) - .Returns(new List()); - - // Act & Assert - Should not throw - await _publisher.NotifyStrike(StrikeType.FailedImport, 1); - } - - [Fact] - public async Task NotifyStrike_WhenProviderThrows_LogsWarningAndContinues() - { - // Arrange - SetupContext(); - - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - provider.SendNotificationAsync(Arg.Any()) - .ThrowsAsync(new Exception("Provider failed")); - - _configService.GetProvidersForEventAsync(Arg.Any()) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - - // Act - Should not throw - await _publisher.NotifyStrike(StrikeType.FailedImport, 1); - - // Assert - _logger.HasLogContaining(LogLevel.Warning, "Failed to send notification").ShouldBeTrue(); + await _messageBus.DidNotReceive().Publish(Arg.Any(), Arg.Any()); } [Fact] @@ -207,20 +146,12 @@ public class NotificationPublisherTests // Arrange SetupContext(); - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - // Act await _publisher.NotifyStrike(StrikeType.FailedImport, 1); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.Data["Url"] == "http://sonarr.local/")); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.Context.Data["Url"] == "http://sonarr.local/"), Arg.Any()); } #endregion @@ -228,28 +159,20 @@ public class NotificationPublisherTests #region NotifyQueueItemDeleted Tests [Fact] - public async Task NotifyQueueItemDeleted_SendsNotificationWithCorrectContext() + public async Task NotifyQueueItemDeleted_PublishesNotificationWithCorrectContext() { // Arrange SetupContext(); - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - // Act await _publisher.NotifyQueueItemDeleted(true, DeleteReason.Stalled); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.EventType == NotificationEventType.QueueItemDeleted && - c.Data["Reason"] == "Stalled" && - c.Data["Removed from client?"] == "True" && - c.Severity == EventSeverity.Important)); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.EventType == NotificationEventType.QueueItemDeleted && + m.Context.Data["Reason"] == "Stalled" && + m.Context.Data["Removed from client?"] == "True" && + m.Context.Severity == EventSeverity.Important), Arg.Any()); } [Fact] @@ -258,21 +181,13 @@ public class NotificationPublisherTests // Arrange SetupContext(); - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - // Act await _publisher.NotifyQueueItemDeleted(false, DeleteReason.AllFilesBlocked); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.Data["Removed from client?"] == "False" && - c.Data["Reason"] == "AllFilesBlocked")); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.Context.Data["Removed from client?"] == "False" && + m.Context.Data["Reason"] == "AllFilesBlocked"), Arg.Any()); } #endregion @@ -280,29 +195,21 @@ public class NotificationPublisherTests #region NotifyDownloadCleaned Tests [Fact] - public async Task NotifyDownloadCleaned_SendsNotificationWithCorrectContext() + public async Task NotifyDownloadCleaned_PublishesNotificationWithCorrectContext() { // Arrange SetupDownloadCleanerContext(); - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - // Act await _publisher.NotifyDownloadCleaned(2.5, TimeSpan.FromHours(48), "movies", CleanReason.MaxRatioReached); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.EventType == NotificationEventType.DownloadCleaned && - c.Description == "Test Download" && - c.Data["Category"] == "movies" && - c.Data["Ratio"] == "2.5" && - c.Data["Seeding hours"] == "48")); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.EventType == NotificationEventType.DownloadCleaned && + m.Context.Description == "Test Download" && + m.Context.Data["Category"] == "movies" && + m.Context.Data["Ratio"] == "2.5" && + m.Context.Data["Seeding hours"] == "48"), Arg.Any()); } [Fact] @@ -310,25 +217,16 @@ public class NotificationPublisherTests { // Arrange SetupDownloadCleanerContext(); - - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - NotificationContext? capturedContext = null; - - _configService.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - provider.SendNotificationAsync(Arg.Any()) - .Returns(Task.CompletedTask) - .AndDoes(ci => capturedContext = ci.ArgAt(0)); + NotificationMessage? captured = null; + _messageBus.Publish(Arg.Do(m => captured = m), Arg.Any()) + .Returns(Task.CompletedTask); // Act await _publisher.NotifyDownloadCleaned(1.0, TimeSpan.FromHours(24.7), "tv", CleanReason.MaxSeedTimeReached); // Assert - capturedContext.ShouldNotBeNull(); - capturedContext.Data["Seeding hours"].ShouldBe("25"); + captured.ShouldNotBeNull(); + captured.Context.Data["Seeding hours"].ShouldBe("25"); } [Fact] @@ -338,21 +236,13 @@ public class NotificationPublisherTests SetupDownloadCleanerContext(); ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, new Uri("https://qbit.external.com")); - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - // Act await _publisher.NotifyDownloadCleaned(2.5, TimeSpan.FromHours(48), "movies", CleanReason.MaxRatioReached); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.Data.ContainsKey("Url") && - c.Data["Url"] == "https://qbit.external.com/")); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.Context.Data.ContainsKey("Url") && + m.Context.Data["Url"] == "https://qbit.external.com/"), Arg.Any()); } #endregion @@ -360,29 +250,21 @@ public class NotificationPublisherTests #region NotifyDownloadStopped Tests [Fact] - public async Task NotifyDownloadStopped_SendsNotificationWithCorrectContext() + public async Task NotifyDownloadStopped_PublishesNotificationWithCorrectContext() { // Arrange SetupDownloadCleanerContext(); - NotificationProviderDto providerDto = CreateProviderDto(); - INotificationProvider provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.DownloadStopped) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - // Act await _publisher.NotifyDownloadStopped(2.5, TimeSpan.FromHours(48), "movies", CleanReason.MaxRatioReached); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.EventType == NotificationEventType.DownloadStopped && - c.Description == "Test Download is no longer seeding. It stays in the download client and its files stay on disk." && - c.Data["Category"] == "movies" && - c.Data["Ratio"] == "2.5" && - c.Data["Seeding hours"] == "48")); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.EventType == NotificationEventType.DownloadStopped && + m.Context.Description == "Test Download is no longer seeding. It stays in the download client and its files stay on disk." && + m.Context.Data["Category"] == "movies" && + m.Context.Data["Ratio"] == "2.5" && + m.Context.Data["Seeding hours"] == "48"), Arg.Any()); } [Fact] @@ -390,25 +272,16 @@ public class NotificationPublisherTests { // Arrange SetupDownloadCleanerContext(); - - NotificationProviderDto providerDto = CreateProviderDto(); - INotificationProvider provider = Substitute.For(); - NotificationContext? capturedContext = null; - - _configService.GetProvidersForEventAsync(NotificationEventType.DownloadStopped) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - provider.SendNotificationAsync(Arg.Any()) - .Returns(Task.CompletedTask) - .AndDoes(ci => capturedContext = ci.ArgAt(0)); + NotificationMessage? captured = null; + _messageBus.Publish(Arg.Do(m => captured = m), Arg.Any()) + .Returns(Task.CompletedTask); // Act await _publisher.NotifyDownloadStopped(1.0, TimeSpan.FromHours(24.7), "tv", CleanReason.MaxSeedTimeReached); // Assert - capturedContext.ShouldNotBeNull(); - capturedContext.Data["Seeding hours"].ShouldBe("25"); // Rounds to 25 + captured.ShouldNotBeNull(); + captured.Context.Data["Seeding hours"].ShouldBe("25"); // Rounds to 25 } [Fact] @@ -418,21 +291,13 @@ public class NotificationPublisherTests SetupDownloadCleanerContext(); ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, new Uri("https://qbit.external.com")); - NotificationProviderDto providerDto = CreateProviderDto(); - INotificationProvider provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.DownloadStopped) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - // Act await _publisher.NotifyDownloadStopped(2.5, TimeSpan.FromHours(48), "movies", CleanReason.MaxRatioReached); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.Data.ContainsKey("Url") && - c.Data["Url"] == "https://qbit.external.com/")); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.Context.Data.ContainsKey("Url") && + m.Context.Data["Url"] == "https://qbit.external.com/"), Arg.Any()); } #endregion @@ -445,23 +310,15 @@ public class NotificationPublisherTests // Arrange SetupDownloadCleanerContext(); - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.CategoryChanged) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - // Act await _publisher.NotifyCategoryChanged("tv-sonarr", "seeding", false); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.EventType == NotificationEventType.CategoryChanged && - c.Title == "Category changed" && - c.Data["Old category"] == "tv-sonarr" && - c.Data["New category"] == "seeding")); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.EventType == NotificationEventType.CategoryChanged && + m.Context.Title == "Category changed" && + m.Context.Data["Old category"] == "tv-sonarr" && + m.Context.Data["New category"] == "seeding"), Arg.Any()); } [Fact] @@ -469,29 +326,20 @@ public class NotificationPublisherTests { // Arrange SetupDownloadCleanerContext(); - - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - NotificationContext? capturedContext = null; - - _configService.GetProvidersForEventAsync(NotificationEventType.CategoryChanged) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - provider.SendNotificationAsync(Arg.Any()) - .Returns(Task.CompletedTask) - .AndDoes(ci => capturedContext = ci.ArgAt(0)); + NotificationMessage? captured = null; + _messageBus.Publish(Arg.Do(m => captured = m), Arg.Any()) + .Returns(Task.CompletedTask); // Act await _publisher.NotifyCategoryChanged("", "seeded", true); // Assert - capturedContext.ShouldNotBeNull(); - capturedContext.Title.ShouldBe("Tag added"); - capturedContext.Data.ContainsKey("Tag").ShouldBeTrue(); - capturedContext.Data["Tag"].ShouldBe("seeded"); - capturedContext.Data.ContainsKey("Old category").ShouldBeFalse(); - capturedContext.Data.ContainsKey("New category").ShouldBeFalse(); + captured.ShouldNotBeNull(); + captured.Context.Title.ShouldBe("Tag added"); + captured.Context.Data.ContainsKey("Tag").ShouldBeTrue(); + captured.Context.Data["Tag"].ShouldBe("seeded"); + captured.Context.Data.ContainsKey("Old category").ShouldBeFalse(); + captured.Context.Data.ContainsKey("New category").ShouldBeFalse(); } [Fact] @@ -500,87 +348,23 @@ public class NotificationPublisherTests // Arrange SetupDownloadCleanerContext(); - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.CategoryChanged) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - // Act await _publisher.NotifyCategoryChanged("old", "new", false); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.Severity == EventSeverity.Information)); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.Context.Severity == EventSeverity.Information), Arg.Any()); } #endregion #region SendNotificationAsync Tests (through notify methods) - [Fact] - public async Task SendNotificationAsync_WhenMultipleProviders_SendsToAll() - { - // Arrange - SetupContext(); - - var providerDto1 = CreateProviderDto("Provider1"); - var providerDto2 = CreateProviderDto("Provider2"); - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike) - .Returns(new List { providerDto1, providerDto2 }); - _providerFactory.CreateProvider(providerDto1) - .Returns(provider1); - _providerFactory.CreateProvider(providerDto2) - .Returns(provider2); - - // Act - await _publisher.NotifyStrike(StrikeType.FailedImport, 1); - - // Assert - await provider1.Received(1).SendNotificationAsync(Arg.Any()); - await provider2.Received(1).SendNotificationAsync(Arg.Any()); - } - - [Fact] - public async Task SendNotificationAsync_WhenOneProviderFails_OthersStillSend() - { - // Arrange - SetupContext(); - - var providerDto1 = CreateProviderDto("Provider1"); - var providerDto2 = CreateProviderDto("Provider2"); - var provider1 = Substitute.For(); - var provider2 = Substitute.For(); - - provider1.SendNotificationAsync(Arg.Any()) - .ThrowsAsync(new Exception("Failed")); - - _configService.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike) - .Returns(new List { providerDto1, providerDto2 }); - _providerFactory.CreateProvider(providerDto1) - .Returns(provider1); - _providerFactory.CreateProvider(providerDto2) - .Returns(provider2); - - // Act - await _publisher.NotifyStrike(StrikeType.FailedImport, 1); - - // Assert - Provider2 should still be called - await provider2.Received(1).SendNotificationAsync(Arg.Any()); - } - [Fact] public async Task SendNotificationAsync_UsesDryRunInterceptor() { // Arrange SetupContext(); - _configService.GetProvidersForEventAsync(Arg.Any()) - .Returns(new List()); // Act await _publisher.NotifyStrike(StrikeType.FailedImport, 1); @@ -591,6 +375,21 @@ public class NotificationPublisherTests Arg.Any()); } + [Fact] + public async Task SendNotificationAsync_WhenDryRun_DoesNotPublish() + { + // Arrange + SetupContext(); + _dryRunInterceptor.InterceptAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + await _publisher.NotifyStrike(StrikeType.FailedImport, 1); + + // Assert + await _messageBus.DidNotReceive().Publish(Arg.Any(), Arg.Any()); + } + #endregion #region Error Handling Tests @@ -681,43 +480,24 @@ public class NotificationPublisherTests #region NotifySearchItemGrabbed Tests [Fact] - public async Task NotifySearchItemGrabbed_SendsNotificationWithCorrectContext() + public async Task NotifySearchItemGrabbed_PublishesNotificationWithCorrectContext() { // Arrange - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.SearchItemGrabbed) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); - var grabbedItems = new List { "Movie.A.2024.1080p", "Movie.A.2024.720p" }; // Act await _publisher.NotifySearchItemGrabbed("Movie A", grabbedItems, InstanceType.Radarr, "http://radarr.local:7878"); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.EventType == NotificationEventType.SearchItemGrabbed && - c.Title == "Download grabbed" && - c.Description == "Movie A" && - c.Severity == EventSeverity.Information && - c.Data["Item"] == "Movie A" && - c.Data["Grabbed"] == "Movie.A.2024.1080p, Movie.A.2024.720p" && - c.Data["Instance type"] == "Radarr" && - c.Data["Url"] == "http://radarr.local:7878")); - } - - [Fact] - public async Task NotifySearchItemGrabbed_WhenNoProviders_DoesNotThrow() - { - // Arrange - _configService.GetProvidersForEventAsync(NotificationEventType.SearchItemGrabbed) - .Returns(new List()); - - // Act & Assert - Should not throw - await _publisher.NotifySearchItemGrabbed("Movie A", ["Movie.A.2024"], InstanceType.Radarr, "http://localhost:7878"); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.EventType == NotificationEventType.SearchItemGrabbed && + m.Context.Title == "Download grabbed" && + m.Context.Description == "Movie A" && + m.Context.Severity == EventSeverity.Information && + m.Context.Data["Item"] == "Movie A" && + m.Context.Data["Grabbed"] == "Movie.A.2024.1080p, Movie.A.2024.720p" && + m.Context.Data["Instance type"] == "Radarr" && + m.Context.Data["Url"] == "http://radarr.local:7878"), Arg.Any()); } [Fact] @@ -739,45 +519,23 @@ public class NotificationPublisherTests #region NotifyForceImported Tests [Fact] - public async Task NotifyForceImported_SendsNotificationWithCorrectContext() + public async Task NotifyForceImported_PublishesNotificationWithCorrectContext() { // Arrange SetupContext(); - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.ForceImported) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto) - .Returns(provider); // Act await _publisher.NotifyForceImported(); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.EventType == NotificationEventType.ForceImported && - c.Title == "Imported a download the arr had blocked" && - c.Description == "Test Show" && - c.Severity == EventSeverity.Important && - c.Data["Hash"] == "abcd1234" && - c.Data["Instance type"] == "Sonarr" && - c.Data["Url"] == "http://sonarr.local/")); - } - - [Fact] - public async Task NotifyForceImported_WhenNoProviders_DoesNotThrow() - { - // Arrange - SetupContext(); - _configService.GetProvidersForEventAsync(NotificationEventType.ForceImported) - .Returns(new List()); - - // Act - await _publisher.NotifyForceImported(); - - // Assert - await _configService.Received(1).GetProvidersForEventAsync(NotificationEventType.ForceImported); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.EventType == NotificationEventType.ForceImported && + m.Context.Title == "Imported a download the arr had blocked" && + m.Context.Description == "Test Show" && + m.Context.Severity == EventSeverity.Important && + m.Context.Data["Hash"] == "abcd1234" && + m.Context.Data["Instance type"] == "Sonarr" && + m.Context.Data["Url"] == "http://sonarr.local/"), Arg.Any()); } [Fact] @@ -797,35 +555,6 @@ public class NotificationPublisherTests #endregion - #region Helper Methods - - private static NotificationProviderDto CreateProviderDto(string name = "TestProvider") - { - return new NotificationProviderDto - { - Id = Guid.NewGuid(), - Name = name, - Type = NotificationProviderType.Notifiarr, - IsEnabled = true, - Events = new NotificationEventFlags - { - OnFailedImportStrike = true, - OnStalledStrike = true, - OnSlowStrike = true, - OnQueueItemDeleted = true, - OnDownloadCleaned = true, - OnDownloadStopped = true, - OnCategoryChanged = true, - OnSearchTriggered = true, - OnSearchItemGrabbed = true, - OnForceImported = true - }, - Configuration = new { ApiKey = "test", ChannelId = "123" } - }; - } - - #endregion - #region LazyLibrarian // LazyLibrarian carries no QueueRecord, so the publisher reads the item from context. @@ -844,19 +573,12 @@ public class NotificationPublisherTests // Arrange SetupLazyLibrarianContext(); - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto).Returns(provider); - // Act await _publisher.NotifyQueueItemDeleted(true, DeleteReason.Stalled); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.Description == "Frankenstein" && c.Data["Hash"] == "bookhash1")); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.Context.Description == "Frankenstein" && m.Context.Data["Hash"] == "bookhash1"), Arg.Any()); } [Fact] @@ -866,19 +588,12 @@ public class NotificationPublisherTests SetupLazyLibrarianContext(); ContextProvider.Set(new StallRule { Name = "Test Rule" }); - var providerDto = CreateProviderDto(); - var provider = Substitute.For(); - - _configService.GetProvidersForEventAsync(NotificationEventType.StalledStrike) - .Returns(new List { providerDto }); - _providerFactory.CreateProvider(providerDto).Returns(provider); - // Act await _publisher.NotifyStrike(StrikeType.Stalled, 1); // Assert - await provider.Received(1).SendNotificationAsync(Arg.Is( - c => c.Description == "Frankenstein" && c.Data["Hash"] == "bookhash1")); + await _messageBus.Received(1).Publish(Arg.Is( + m => m.Context.Description == "Frankenstein" && m.Context.Data["Hash"] == "bookhash1"), Arg.Any()); } #endregion diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Consumers/NotificationConsumer.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Consumers/NotificationConsumer.cs index 586f5cb8..20dc1367 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Consumers/NotificationConsumer.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Consumers/NotificationConsumer.cs @@ -1,116 +1,17 @@ -using Cleanuparr.Infrastructure.Features.Notifications.Models; +using Cleanuparr.Infrastructure.Features.Notifications.Models; using MassTransit; -using Microsoft.Extensions.Logging; -using Cleanuparr.Domain.Enums; namespace Cleanuparr.Infrastructure.Features.Notifications.Consumers; -public sealed class NotificationConsumer : IConsumer where T : Notification +public sealed class NotificationConsumer : IConsumer { - private readonly ILogger> _logger; private readonly NotificationService _notificationService; - private readonly TimeProvider _timeProvider; - public NotificationConsumer(ILogger> logger, NotificationService notificationService, TimeProvider timeProvider) + public NotificationConsumer(NotificationService notificationService) { - _logger = logger; _notificationService = notificationService; - _timeProvider = timeProvider; } - public async Task Consume(ConsumeContext context) - { - try - { - switch (context.Message) - { - case FailedImportStrikeNotification failedMessage: - await _notificationService.SendNotificationAsync( - NotificationEventType.FailedImportStrike, - ConvertToNotificationContext(failedMessage, NotificationEventType.FailedImportStrike)); - break; - case StalledStrikeNotification stalledMessage: - await _notificationService.SendNotificationAsync( - NotificationEventType.StalledStrike, - ConvertToNotificationContext(stalledMessage, NotificationEventType.StalledStrike)); - break; - case SlowSpeedStrikeNotification slowMessage: - await _notificationService.SendNotificationAsync( - NotificationEventType.SlowSpeedStrike, - ConvertToNotificationContext(slowMessage, NotificationEventType.SlowSpeedStrike)); - break; - case SlowTimeStrikeNotification slowTimeMessage: - await _notificationService.SendNotificationAsync( - NotificationEventType.SlowTimeStrike, - ConvertToNotificationContext(slowTimeMessage, NotificationEventType.SlowTimeStrike)); - break; - case QueueItemDeletedNotification queueItemDeleteMessage: - await _notificationService.SendNotificationAsync( - NotificationEventType.QueueItemDeleted, - ConvertToNotificationContext(queueItemDeleteMessage, NotificationEventType.QueueItemDeleted)); - break; - case DownloadCleanedNotification downloadCleanedNotification: - await _notificationService.SendNotificationAsync( - NotificationEventType.DownloadCleaned, - ConvertToNotificationContext(downloadCleanedNotification, NotificationEventType.DownloadCleaned)); - break; - case DownloadStoppedNotification downloadStoppedNotification: - await _notificationService.SendNotificationAsync( - NotificationEventType.DownloadStopped, - ConvertToNotificationContext(downloadStoppedNotification, NotificationEventType.DownloadStopped)); - break; - case CategoryChangedNotification categoryChangedNotification: - await _notificationService.SendNotificationAsync( - NotificationEventType.CategoryChanged, - ConvertToNotificationContext(categoryChangedNotification, NotificationEventType.CategoryChanged)); - break; - default: - throw new NotImplementedException(); - } - - // prevent spamming - await Task.Delay(TimeSpan.FromSeconds(1), _timeProvider); - } - catch (Exception exception) - { - _logger.LogError(exception, "Error while processing notifications"); - } - } - - private static NotificationContext ConvertToNotificationContext(Notification notification, NotificationEventType eventType) - { - var severity = notification.Level switch - { - NotificationLevel.Important => EventSeverity.Important, - NotificationLevel.Warning => EventSeverity.Warning, - _ => EventSeverity.Information - }; - - var data = new Dictionary(); - Uri? image = null; - - if (notification is ArrNotification arrNotification) - { - data.Add("Instance type", arrNotification.InstanceType.ToString()); - data.Add("Url", arrNotification.InstanceUrl.ToString()); - data.Add("Hash", arrNotification.Hash); - - image = arrNotification.Image; - } - - foreach (var field in notification.Fields ?? []) - { - data[field.Key] = field.Value; - } - - return new NotificationContext - { - EventType = eventType, - Title = notification.Title, - Description = notification.Description, - Severity = severity, - Data = data, - Image = image - }; - } -} \ No newline at end of file + public Task Consume(ConsumeContext context) => + _notificationService.SendNotificationAsync(context.Message.EventType, context.Message.Context); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/ArrNotification.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/ArrNotification.cs deleted file mode 100644 index a345ddf2..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/ArrNotification.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Cleanuparr.Domain.Enums; - -namespace Cleanuparr.Infrastructure.Features.Notifications.Models; - -public record ArrNotification : Notification -{ - public required InstanceType InstanceType { get; init; } - - public required Uri InstanceUrl { get; init; } - - public required string Hash { get; init; } - - public Uri? Image { get; init; } -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/CategoryChangedNotification.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/CategoryChangedNotification.cs deleted file mode 100644 index bd976006..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/CategoryChangedNotification.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Cleanuparr.Infrastructure.Features.Notifications.Models; - -public sealed record CategoryChangedNotification : Notification -{ -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/DownloadCleanedNotification.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/DownloadCleanedNotification.cs deleted file mode 100644 index d67823a2..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/DownloadCleanedNotification.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Cleanuparr.Infrastructure.Features.Notifications.Models; - -public sealed record DownloadCleanedNotification : Notification -{ -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/DownloadStoppedNotification.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/DownloadStoppedNotification.cs deleted file mode 100644 index a7378756..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/DownloadStoppedNotification.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Cleanuparr.Infrastructure.Features.Notifications.Models; - -public sealed record DownloadStoppedNotification : Notification -{ -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/FailedImportStrikeNotification.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/FailedImportStrikeNotification.cs deleted file mode 100644 index 6be11b90..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/FailedImportStrikeNotification.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Cleanuparr.Infrastructure.Features.Notifications.Models; - -public sealed record FailedImportStrikeNotification : ArrNotification -{ -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/Notification.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/Notification.cs deleted file mode 100644 index bf77c612..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/Notification.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Cleanuparr.Infrastructure.Features.Notifications.Models; - -public abstract record Notification -{ - public required string Title { get; init; } - - public required string Description { get; init; } - - public List? Fields { get; init; } - - public NotificationLevel Level { get; init; } -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationField.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationField.cs deleted file mode 100644 index 31c8af3e..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationField.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Cleanuparr.Infrastructure.Features.Notifications.Models; - -public sealed record NotificationField -{ - public required string Key { get; init; } - - public required string Value { get; init; } -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationLevel.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationLevel.cs deleted file mode 100644 index 2caed5ec..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationLevel.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Cleanuparr.Infrastructure.Features.Notifications.Models; - -public enum NotificationLevel -{ - Test, - Information, - Warning, - Important -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationMessage.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationMessage.cs new file mode 100644 index 00000000..9caa48ba --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/NotificationMessage.cs @@ -0,0 +1,5 @@ +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Models; + +public sealed record NotificationMessage(NotificationEventType EventType, NotificationContext Context); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/QueueItemDeletedNotification.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/QueueItemDeletedNotification.cs deleted file mode 100644 index db96975f..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/QueueItemDeletedNotification.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Cleanuparr.Infrastructure.Features.Notifications.Models; - -public sealed record QueueItemDeletedNotification : ArrNotification -{ -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/SlowSpeedStrikeNotification.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/SlowSpeedStrikeNotification.cs deleted file mode 100644 index 44f8c1f8..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/SlowSpeedStrikeNotification.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Cleanuparr.Infrastructure.Features.Notifications.Models; - -public sealed record SlowSpeedStrikeNotification : ArrNotification -{ -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/SlowTimeStrikeNotification.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/SlowTimeStrikeNotification.cs deleted file mode 100644 index ec7fd73a..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/SlowTimeStrikeNotification.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Cleanuparr.Infrastructure.Features.Notifications.Models; - -public sealed record SlowTimeStrikeNotification : ArrNotification -{ -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/StalledStrikeNotification.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/StalledStrikeNotification.cs deleted file mode 100644 index 6e67e3a9..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Models/StalledStrikeNotification.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Cleanuparr.Infrastructure.Features.Notifications.Models; - -public sealed record StalledStrikeNotification : ArrNotification -{ -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs index f8bfa9f8..44524b32 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationPublisher.cs @@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.Notifications.Models; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using MassTransit; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Features.Notifications; @@ -13,19 +14,16 @@ public class NotificationPublisher : INotificationPublisher { private readonly ILogger _logger; private readonly IDryRunInterceptor _dryRunInterceptor; - private readonly INotificationConfigurationService _configurationService; - private readonly INotificationProviderFactory _providerFactory; + private readonly IBus _messageBus; public NotificationPublisher( ILogger logger, IDryRunInterceptor dryRunInterceptor, - INotificationConfigurationService configurationService, - INotificationProviderFactory providerFactory) + IBus messageBus) { _logger = logger; _dryRunInterceptor = dryRunInterceptor; - _configurationService = configurationService; - _providerFactory = providerFactory; + _messageBus = messageBus; } public virtual async Task NotifyStrike(StrikeType strikeType, int strikeCount) @@ -149,29 +147,7 @@ public class NotificationPublisher : INotificationPublisher private async Task SendNotificationInternalAsync((NotificationEventType eventType, NotificationContext context) parameters) { var (eventType, context) = parameters; - var providers = await _configurationService.GetProvidersForEventAsync(eventType); - - if (!providers.Any()) - { - _logger.LogDebug("No providers configured for event type {eventType}", eventType); - return; - } - - var tasks = providers.Select(async providerConfig => - { - try - { - var provider = _providerFactory.CreateProvider(providerConfig); - await provider.SendNotificationAsync(context); - _logger.LogDebug("Notification sent successfully via {providerName}", provider.Name); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to send notification via provider {providerName}", providerConfig.Name); - } - }); - - await Task.WhenAll(tasks); + await _messageBus.Publish(new NotificationMessage(eventType, context)); } /// diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationService.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationService.cs index 7f06e331..1ea09f04 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationService.cs @@ -32,7 +32,7 @@ public sealed class NotificationService if (!providers.Any()) { - _logger.LogDebug("No providers configured for event type {eventType}", eventType); + _logger.LogDebug("No providers configured for event type {EventType}", eventType); return; } @@ -42,20 +42,20 @@ public sealed class NotificationService { var provider = _providerFactory.CreateProvider(providerConfig); await provider.SendNotificationAsync(context); - _logger.LogDebug("Notification sent successfully via {providerName}", provider.Name); + _logger.LogDebug("Notification sent successfully via {ProviderName}", provider.Name); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to send notification via provider {providerName}", providerConfig.Name); + _logger.LogWarning(ex, "Failed to send notification via provider {ProviderName}", providerConfig.Name); } }); await Task.WhenAll(tasks); - _logger.LogTrace("Notification sent to {count} providers for event {eventType}", providers.Count, eventType); + _logger.LogTrace("Notification sent to {Count} providers for event {EventType}", providers.Count, eventType); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send notifications for event type {eventType}", eventType); + _logger.LogError(ex, "Failed to send notifications for event type {EventType}", eventType); } } @@ -81,11 +81,11 @@ public sealed class NotificationService { var provider = _providerFactory.CreateProvider(providerConfig); await provider.SendNotificationAsync(testContext); - _logger.LogInformation("Test notification sent successfully via {providerName}", providerConfig.Name); + _logger.LogInformation("Test notification sent successfully via {ProviderName}", providerConfig.Name); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send test notification via {providerName}", providerConfig.Name); + _logger.LogError(ex, "Failed to send test notification via {ProviderName}", providerConfig.Name); throw; } } diff --git a/e2e/tests/download-cleaner/deluge-seeding-rule-cleanup.spec.ts b/e2e/tests/download-cleaner/deluge-seeding-rule-cleanup.spec.ts index 4968f3ab..790e4881 100644 --- a/e2e/tests/download-cleaner/deluge-seeding-rule-cleanup.spec.ts +++ b/e2e/tests/download-cleaner/deluge-seeding-rule-cleanup.spec.ts @@ -10,10 +10,15 @@ import { getDownloadCleanerConfig, createSeedingRule, triggerJob, + createNotificationProvider, + deleteNotificationProvider, } from '../helpers/app-api'; import { DelugeDriver } from '../helpers/torrent-clients/deluge'; import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from '../helpers/torrent-fixtures'; import { mkdirShared } from '../helpers/shared-volume'; +import { WireMockClient } from '../helpers/mocks/wiremock-client'; +import { ntfyTopicStub } from '../helpers/mocks/notification-stubs'; +import { TEST_CONFIG } from '../helpers/test-config'; const HOST_DOWNLOADS = resolve(__dirname, '..', '..', 'test-data', 'downloads'); const DELUGE_DOWNLOADS = join(HOST_DOWNLOADS, 'deluge'); @@ -59,12 +64,19 @@ async function stillPresent(infoHashes: string[]): Promise { * `CleanDownloadsAsync`. Each torrent that remains stays in the client, and no * `DownloadCleaned` event happens. Two torrents and one cleanup cycle show this * behavior. + * + * A DownloadCleaned removal also publishes a notification to the message bus + * (NotificationPublisher -> MassTransit -> NotificationConsumer), so the same + * cycle doubles as e2e coverage for that async dispatch: a provider subscribed + * to OnDownloadCleaned must receive a real delivery when the rule fires. */ test.describe.serial('Deluge seeding rule cleanup', () => { let token: string; let clientId: string; + let notificationProviderId: string; const hashes: string[] = []; const contentPaths: string[] = []; + const notify = new WireMockClient(TEST_CONFIG.mocks.notifyAdminUrl); test.beforeAll(async () => { test.setTimeout(120_000); @@ -86,6 +98,20 @@ test.describe.serial('Deluge seeding rule cleanup', () => { resetDirectory(DELUGE_DOWNLOADS); await deluge.ready(); await deluge.clearAllTorrents(); + + await notify.resetAll(); + await notify.stub(ntfyTopicStub()); + const providerRes = await createNotificationProvider(token, 'ntfy', { + name: 'ntfy-seeding-rule-cleanup-e2e', + isEnabled: true, + onDownloadCleaned: true, + serverUrl: TEST_CONFIG.mocks.notifyUrl, + topics: ['cleanuparr-e2e-download-cleaned'], + authenticationType: 'None', + priority: 'Default', + }); + expect(providerRes.status).toBe(201); + notificationProviderId = (await providerRes.json()).id; }); test.afterAll(async () => { @@ -93,6 +119,9 @@ test.describe.serial('Deluge seeding rule cleanup', () => { if (clientId) { await deleteDownloadClient(token, clientId).catch(() => {}); } + if (notificationProviderId) { + await deleteNotificationProvider(token, notificationProviderId).catch(() => {}); + } }); test('sets up two seeding torrents and a max-seed-time rule', async () => { @@ -148,7 +177,7 @@ test.describe.serial('Deluge seeding rule cleanup', () => { expect(await stillPresent(hashes)).toHaveLength(2); }); - test('removes every matching torrent and its files in a single cycle', async () => { + test('removes every matching torrent and its files in a single cycle, and notifies', async () => { test.setTimeout(180_000); const trig = await triggerJob(token, 'DownloadCleaner'); @@ -166,5 +195,21 @@ test.describe.serial('Deluge seeding rule cleanup', () => { for (const path of contentPaths) { expect(existsSync(path), `torrent data was not deleted: ${path}`).toBe(false); } + + // Delivery happens off the message bus, after the removal response returns. + await expect + .poll( + async () => { + const bodies = (await notify.findRequests({ method: 'POST' })).map((r) => r.body ?? ''); + return hashes.map((hash) => bodies.filter((body) => body.includes(`Hash: ${hash}`)).length); + }, + { message: 'expected one download-cleaned notification per torrent', timeout: 30_000, intervals: [500] }, + ) + .toEqual(hashes.map(() => 1)); + const posts = await notify.findRequests({ method: 'POST' }); + expect(posts).toHaveLength(hashes.length); + for (const post of posts) { + expect(post.body ?? '').toContain('cleanuparr-e2e-download-cleaned'); + } }); }); diff --git a/e2e/tests/helpers/app-api.ts b/e2e/tests/helpers/app-api.ts index 1dce0cee..8529d60c 100644 --- a/e2e/tests/helpers/app-api.ts +++ b/e2e/tests/helpers/app-api.ts @@ -245,6 +245,28 @@ export async function deleteSeedingRule(accessToken: string, ruleId: string): Pr }); } +export async function createNotificationProvider( + accessToken: string, + type: string, + provider: Record, +): Promise { + return fetch(`${API}/api/configuration/notification_providers/${type}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(provider), + }); +} + +export async function deleteNotificationProvider(accessToken: string, providerId: string): Promise { + return fetch(`${API}/api/configuration/notification_providers/${providerId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${accessToken}` }, + }); +} + export async function getUnlinkedConfig(accessToken: string, downloadClientId: string): Promise { return fetch(`${API}/api/unlinked-config/${downloadClientId}`, { headers: { Authorization: `Bearer ${accessToken}` },