Fix notifications not being sent asynchronously (#811)

This commit is contained in:
Flaminel authored and GitHub committed 2026-09-25 00:37:22 +03:00
1 parent edc47401b7
commit 7f8ddcfb4e
21 files changed
+291 -1153

No files matched your search

@@ -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<DownloadRemoverConsumer>();
config.AddConsumer<NotificationConsumer<FailedImportStrikeNotification>>();
config.AddConsumer<NotificationConsumer<StalledStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>();
config.AddConsumer<NotificationConsumer<SlowTimeStrikeNotification>>();
config.AddConsumer<NotificationConsumer<QueueItemDeletedNotification>>();
config.AddConsumer<NotificationConsumer<DownloadCleanedNotification>>();
config.AddConsumer<NotificationConsumer<DownloadStoppedNotification>>();
config.AddConsumer<NotificationConsumer<CategoryChangedNotification>>();
config.AddConsumer<NotificationConsumer>();
config.UsingInMemory((context, cfg) =>
{
@@ -55,14 +47,7 @@ public static class MainDI
cfg.ReceiveEndpoint("notification-queue", e =>
{
e.ConfigureConsumer<NotificationConsumer<FailedImportStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<StalledStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowSpeedStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<SlowTimeStrikeNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<QueueItemDeletedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<DownloadCleanedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<DownloadStoppedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer<CategoryChangedNotification>>(context);
e.ConfigureConsumer<NotificationConsumer>(context);
e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1;
});
@@ -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<NotificationService> _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<ILogger<NotificationService>>();
_configurationService = Substitute.For<INotificationConfigurationService>();
_providerFactory = Substitute.For<INotificationProviderFactory>();
_timeProvider = new FakeTimeProvider();
_notificationService = new NotificationService(
_serviceLogger,
NotificationService notificationService = new(
Substitute.For<ILogger<NotificationService>>(),
_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<FailedImportStrikeNotification>();
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<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationService
.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto> { providerDto })
.AndDoes(ci => capturedEventType = ci.ArgAt<NotificationEventType>(0));
_providerFactory
.CreateProvider(Arg.Any<NotificationProviderDto>())
.Returns(provider);
provider.SendNotificationAsync(Arg.Any<NotificationContext>()).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<StalledStrikeNotification>();
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<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationService
.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto> { providerDto })
.AndDoes(ci => capturedEventType = ci.ArgAt<NotificationEventType>(0));
_providerFactory
.CreateProvider(Arg.Any<NotificationProviderDto>())
.Returns(provider);
provider.SendNotificationAsync(Arg.Any<NotificationContext>()).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<NotificationMessage> CreateConsumeContext(NotificationMessage message)
{
// Arrange
var consumer = CreateConsumer<SlowSpeedStrikeNotification>();
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<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationService
.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto> { providerDto })
.AndDoes(ci => capturedEventType = ci.ArgAt<NotificationEventType>(0));
_providerFactory
.CreateProvider(Arg.Any<NotificationProviderDto>())
.Returns(provider);
provider.SendNotificationAsync(Arg.Any<NotificationContext>()).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<SlowTimeStrikeNotification>();
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<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationService
.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto> { providerDto })
.AndDoes(ci => capturedEventType = ci.ArgAt<NotificationEventType>(0));
_providerFactory
.CreateProvider(Arg.Any<NotificationProviderDto>())
.Returns(provider);
provider.SendNotificationAsync(Arg.Any<NotificationContext>()).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<QueueItemDeletedNotification>();
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<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationService
.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto> { providerDto })
.AndDoes(ci => capturedEventType = ci.ArgAt<NotificationEventType>(0));
_providerFactory
.CreateProvider(Arg.Any<NotificationProviderDto>())
.Returns(provider);
provider.SendNotificationAsync(Arg.Any<NotificationContext>()).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<DownloadCleanedNotification>();
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<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationService
.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto> { providerDto })
.AndDoes(ci => capturedEventType = ci.ArgAt<NotificationEventType>(0));
_providerFactory
.CreateProvider(Arg.Any<NotificationProviderDto>())
.Returns(provider);
provider.SendNotificationAsync(Arg.Any<NotificationContext>()).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<CategoryChangedNotification>();
var notification = new CategoryChangedNotification
{
Title = "Category Changed",
Description = "Category updated",
Level = NotificationLevel.Information
};
var context = CreateConsumeContext(notification);
NotificationEventType? capturedEventType = null;
var provider = Substitute.For<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationService
.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto> { providerDto })
.AndDoes(ci => capturedEventType = ci.ArgAt<NotificationEventType>(0));
_providerFactory
.CreateProvider(Arg.Any<NotificationProviderDto>())
.Returns(provider);
provider.SendNotificationAsync(Arg.Any<NotificationContext>()).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<FailedImportStrikeNotification>();
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<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationService
.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory
.CreateProvider(Arg.Any<NotificationProviderDto>())
.Returns(provider);
provider
.SendNotificationAsync(Arg.Any<NotificationContext>())
.Returns(Task.CompletedTask)
.AndDoes(ci => capturedContext = ci.ArgAt<NotificationContext>(0));
// Act
await ConsumeWithTimeAdvance(consumer, context);
// Assert
capturedContext.ShouldNotBeNull();
capturedContext.Severity.ShouldBe(expectedSeverity);
}
[Fact]
public async Task Consume_ArrNotification_IncludesArrDataInContext()
{
// Arrange
var consumer = CreateConsumer<FailedImportStrikeNotification>();
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<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationService
.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory
.CreateProvider(Arg.Any<NotificationProviderDto>())
.Returns(provider);
provider
.SendNotificationAsync(Arg.Any<NotificationContext>())
.Returns(Task.CompletedTask)
.AndDoes(ci => capturedContext = ci.ArgAt<NotificationContext>(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<FailedImportStrikeNotification>();
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<NotificationField>
{
new() { Key = "CustomKey1", Value = "CustomValue1" },
new() { Key = "CustomKey2", Value = "CustomValue2" }
}
};
var context = CreateConsumeContext(notification);
NotificationContext? capturedContext = null;
var provider = Substitute.For<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationService
.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory
.CreateProvider(Arg.Any<NotificationProviderDto>())
.Returns(provider);
provider
.SendNotificationAsync(Arg.Any<NotificationContext>())
.Returns(Task.CompletedTask)
.AndDoes(ci => capturedContext = ci.ArgAt<NotificationContext>(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<DownloadCleanedNotification>();
var notification = new DownloadCleanedNotification
{
Title = "Download Cleaned",
Description = "Test",
Level = NotificationLevel.Information
};
var context = CreateConsumeContext(notification);
NotificationContext? capturedContext = null;
var provider = Substitute.For<INotificationProvider>();
var providerDto = new NotificationProviderDto { Id = Guid.NewGuid(), Name = "Test Provider", Type = NotificationProviderType.Apprise };
_configurationService
.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory
.CreateProvider(Arg.Any<NotificationProviderDto>())
.Returns(provider);
provider
.SendNotificationAsync(Arg.Any<NotificationContext>())
.Returns(Task.CompletedTask)
.AndDoes(ci => capturedContext = ci.ArgAt<NotificationContext>(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<FailedImportStrikeNotification>();
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<NotificationEventType>())
.Returns(new List<NotificationProviderDto>());
// Act
await ConsumeWithTimeAdvance(consumer, context);
// Assert
_providerFactory.DidNotReceive().CreateProvider(Arg.Any<NotificationProviderDto>());
}
#endregion
#region Helper Methods
private NotificationConsumer<T> CreateConsumer<T>() where T : Notification
{
var logger = Substitute.For<ILogger<NotificationConsumer<T>>>();
return new NotificationConsumer<T>(logger, _notificationService, _timeProvider);
}
private static ConsumeContext<T> CreateConsumeContext<T>(T message) where T : class
{
var context = Substitute.For<ConsumeContext<T>>();
ConsumeContext<NotificationMessage> context = Substitute.For<ConsumeContext<NotificationMessage>>();
context.Message.Returns(message);
return context;
}
/// <summary>
/// Executes the consumer and advances time past the 1-second spam prevention delay
/// </summary>
private async Task ConsumeWithTimeAdvance<T>(NotificationConsumer<T> consumer, ConsumeContext<T> 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>();
INotificationProvider provider2 = Substitute.For<INotificationProvider>();
NotificationMessage message = new(NotificationEventType.FailedImportStrike, CreateContext(NotificationEventType.FailedImportStrike));
_configurationService.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike)
.Returns(new List<NotificationProviderDto> { 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>();
INotificationProvider provider2 = Substitute.For<INotificationProvider>();
provider1.SendNotificationAsync(Arg.Any<NotificationContext>())
.ThrowsAsync(new Exception("Provider1 failed"));
NotificationMessage message = new(NotificationEventType.StalledStrike, CreateContext(NotificationEventType.StalledStrike));
_configurationService.GetProvidersForEventAsync(NotificationEventType.StalledStrike)
.Returns(new List<NotificationProviderDto> { 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<NotificationProviderDto>());
await _consumer.Consume(CreateConsumeContext(message));
_providerFactory.DidNotReceive().CreateProvider(Arg.Any<NotificationProviderDto>());
}
[Fact]
public async Task Consume_WhenProviderLookupThrows_DoesNotThrow()
{
NotificationMessage message = new(NotificationEventType.QueueItemDeleted, CreateContext(NotificationEventType.QueueItemDeleted));
_configurationService.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.ThrowsAsync(new InvalidOperationException("db locked"));
ConsumeContext<NotificationMessage> ctx = CreateConsumeContext(message);
await Should.NotThrowAsync(() => _consumer.Consume(ctx));
_providerFactory.DidNotReceive().CreateProvider(Arg.Any<NotificationProviderDto>());
}
}
@@ -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<NotificationPublisher> _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<ILogger<NotificationPublisher>>();
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
_configService = Substitute.For<INotificationConfigurationService>();
_providerFactory = Substitute.For<INotificationProviderFactory>();
_messageBus = Substitute.For<IBus>();
_dryRunInterceptor.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.ReturnsForAnyArgs(ci => ci.ArgAt<Func<Task>>(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<QueueRule>(rule);
var providerDto = CreateProviderDto();
var provider = Substitute.For<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.StalledStrike)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto)
.Returns(provider);
// Act
await _publisher.NotifyStrike(StrikeType.Stalled, 1);
// Assert
await provider.Received(1).SendNotificationAsync(Arg.Is<NotificationContext>(
c => c.EventType == NotificationEventType.StalledStrike &&
c.Data.ContainsKey("Strike type") &&
c.Data["Strike type"] == "Stalled"));
await _messageBus.Received(1).Publish(Arg.Is<NotificationMessage>(
m => m.EventType == NotificationEventType.StalledStrike &&
m.Context.Data.ContainsKey("Strike type") &&
m.Context.Data["Strike type"] == "Stalled"), Arg.Any<CancellationToken>());
}
[Fact]
@@ -109,21 +98,13 @@ public class NotificationPublisherTests
// Arrange
SetupContext();
var providerDto = CreateProviderDto();
var provider = Substitute.For<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto)
.Returns(provider);
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 2);
// Assert
await provider.Received(1).SendNotificationAsync(Arg.Is<NotificationContext>(
c => c.EventType == NotificationEventType.FailedImportStrike &&
c.Data["Strike count"] == "2"));
await _messageBus.Received(1).Publish(Arg.Is<NotificationMessage>(
m => m.EventType == NotificationEventType.FailedImportStrike &&
m.Context.Data["Strike count"] == "2"), Arg.Any<CancellationToken>());
}
[Theory]
@@ -142,19 +123,12 @@ public class NotificationPublisherTests
ContextProvider.Set<QueueRule>(rule);
}
var providerDto = CreateProviderDto();
var provider = Substitute.For<INotificationProvider>();
_configService.GetProvidersForEventAsync(expectedEventType)
.Returns(new List<NotificationProviderDto> { 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<NotificationMessage>(
m => m.EventType == expectedEventType), Arg.Any<CancellationToken>());
}
[Fact]
@@ -163,42 +137,7 @@ public class NotificationPublisherTests
// Act & Assert
await _publisher.NotifyStrike(StrikeType.DeadTorrent, 3);
await _configService.DidNotReceive().GetProvidersForEventAsync(Arg.Any<NotificationEventType>());
}
[Fact]
public async Task NotifyStrike_WhenNoProviders_DoesNotThrow()
{
// Arrange
SetupContext();
_configService.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto>());
// 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<INotificationProvider>();
provider.SendNotificationAsync(Arg.Any<NotificationContext>())
.ThrowsAsync(new Exception("Provider failed"));
_configService.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto> { 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<NotificationMessage>(), Arg.Any<CancellationToken>());
}
[Fact]
@@ -207,20 +146,12 @@ public class NotificationPublisherTests
// Arrange
SetupContext();
var providerDto = CreateProviderDto();
var provider = Substitute.For<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto)
.Returns(provider);
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
// Assert
await provider.Received(1).SendNotificationAsync(Arg.Is<NotificationContext>(
c => c.Data["Url"] == "http://sonarr.local/"));
await _messageBus.Received(1).Publish(Arg.Is<NotificationMessage>(
m => m.Context.Data["Url"] == "http://sonarr.local/"), Arg.Any<CancellationToken>());
}
#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<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto)
.Returns(provider);
// Act
await _publisher.NotifyQueueItemDeleted(true, DeleteReason.Stalled);
// Assert
await provider.Received(1).SendNotificationAsync(Arg.Is<NotificationContext>(
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<NotificationMessage>(
m => m.EventType == NotificationEventType.QueueItemDeleted &&
m.Context.Data["Reason"] == "Stalled" &&
m.Context.Data["Removed from client?"] == "True" &&
m.Context.Severity == EventSeverity.Important), Arg.Any<CancellationToken>());
}
[Fact]
@@ -258,21 +181,13 @@ public class NotificationPublisherTests
// Arrange
SetupContext();
var providerDto = CreateProviderDto();
var provider = Substitute.For<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto)
.Returns(provider);
// Act
await _publisher.NotifyQueueItemDeleted(false, DeleteReason.AllFilesBlocked);
// Assert
await provider.Received(1).SendNotificationAsync(Arg.Is<NotificationContext>(
c => c.Data["Removed from client?"] == "False" &&
c.Data["Reason"] == "AllFilesBlocked"));
await _messageBus.Received(1).Publish(Arg.Is<NotificationMessage>(
m => m.Context.Data["Removed from client?"] == "False" &&
m.Context.Data["Reason"] == "AllFilesBlocked"), Arg.Any<CancellationToken>());
}
#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<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned)
.Returns(new List<NotificationProviderDto> { 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<NotificationContext>(
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<NotificationMessage>(
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<CancellationToken>());
}
[Fact]
@@ -310,25 +217,16 @@ public class NotificationPublisherTests
{
// Arrange
SetupDownloadCleanerContext();
var providerDto = CreateProviderDto();
var provider = Substitute.For<INotificationProvider>();
NotificationContext? capturedContext = null;
_configService.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto)
.Returns(provider);
provider.SendNotificationAsync(Arg.Any<NotificationContext>())
.Returns(Task.CompletedTask)
.AndDoes(ci => capturedContext = ci.ArgAt<NotificationContext>(0));
NotificationMessage? captured = null;
_messageBus.Publish(Arg.Do<NotificationMessage>(m => captured = m), Arg.Any<CancellationToken>())
.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<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.DownloadCleaned)
.Returns(new List<NotificationProviderDto> { 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<NotificationContext>(
c => c.Data.ContainsKey("Url") &&
c.Data["Url"] == "https://qbit.external.com/"));
await _messageBus.Received(1).Publish(Arg.Is<NotificationMessage>(
m => m.Context.Data.ContainsKey("Url") &&
m.Context.Data["Url"] == "https://qbit.external.com/"), Arg.Any<CancellationToken>());
}
#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<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.DownloadStopped)
.Returns(new List<NotificationProviderDto> { 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<NotificationContext>(
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<NotificationMessage>(
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<CancellationToken>());
}
[Fact]
@@ -390,25 +272,16 @@ public class NotificationPublisherTests
{
// Arrange
SetupDownloadCleanerContext();
NotificationProviderDto providerDto = CreateProviderDto();
INotificationProvider provider = Substitute.For<INotificationProvider>();
NotificationContext? capturedContext = null;
_configService.GetProvidersForEventAsync(NotificationEventType.DownloadStopped)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto)
.Returns(provider);
provider.SendNotificationAsync(Arg.Any<NotificationContext>())
.Returns(Task.CompletedTask)
.AndDoes(ci => capturedContext = ci.ArgAt<NotificationContext>(0));
NotificationMessage? captured = null;
_messageBus.Publish(Arg.Do<NotificationMessage>(m => captured = m), Arg.Any<CancellationToken>())
.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<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.DownloadStopped)
.Returns(new List<NotificationProviderDto> { 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<NotificationContext>(
c => c.Data.ContainsKey("Url") &&
c.Data["Url"] == "https://qbit.external.com/"));
await _messageBus.Received(1).Publish(Arg.Is<NotificationMessage>(
m => m.Context.Data.ContainsKey("Url") &&
m.Context.Data["Url"] == "https://qbit.external.com/"), Arg.Any<CancellationToken>());
}
#endregion
@@ -445,23 +310,15 @@ public class NotificationPublisherTests
// Arrange
SetupDownloadCleanerContext();
var providerDto = CreateProviderDto();
var provider = Substitute.For<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.CategoryChanged)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto)
.Returns(provider);
// Act
await _publisher.NotifyCategoryChanged("tv-sonarr", "seeding", false);
// Assert
await provider.Received(1).SendNotificationAsync(Arg.Is<NotificationContext>(
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<NotificationMessage>(
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<CancellationToken>());
}
[Fact]
@@ -469,29 +326,20 @@ public class NotificationPublisherTests
{
// Arrange
SetupDownloadCleanerContext();
var providerDto = CreateProviderDto();
var provider = Substitute.For<INotificationProvider>();
NotificationContext? capturedContext = null;
_configService.GetProvidersForEventAsync(NotificationEventType.CategoryChanged)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto)
.Returns(provider);
provider.SendNotificationAsync(Arg.Any<NotificationContext>())
.Returns(Task.CompletedTask)
.AndDoes(ci => capturedContext = ci.ArgAt<NotificationContext>(0));
NotificationMessage? captured = null;
_messageBus.Publish(Arg.Do<NotificationMessage>(m => captured = m), Arg.Any<CancellationToken>())
.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<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.CategoryChanged)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto)
.Returns(provider);
// Act
await _publisher.NotifyCategoryChanged("old", "new", false);
// Assert
await provider.Received(1).SendNotificationAsync(Arg.Is<NotificationContext>(
c => c.Severity == EventSeverity.Information));
await _messageBus.Received(1).Publish(Arg.Is<NotificationMessage>(
m => m.Context.Severity == EventSeverity.Information), Arg.Any<CancellationToken>());
}
#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<INotificationProvider>();
var provider2 = Substitute.For<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike)
.Returns(new List<NotificationProviderDto> { 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<NotificationContext>());
await provider2.Received(1).SendNotificationAsync(Arg.Any<NotificationContext>());
}
[Fact]
public async Task SendNotificationAsync_WhenOneProviderFails_OthersStillSend()
{
// Arrange
SetupContext();
var providerDto1 = CreateProviderDto("Provider1");
var providerDto2 = CreateProviderDto("Provider2");
var provider1 = Substitute.For<INotificationProvider>();
var provider2 = Substitute.For<INotificationProvider>();
provider1.SendNotificationAsync(Arg.Any<NotificationContext>())
.ThrowsAsync(new Exception("Failed"));
_configService.GetProvidersForEventAsync(NotificationEventType.FailedImportStrike)
.Returns(new List<NotificationProviderDto> { 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<NotificationContext>());
}
[Fact]
public async Task SendNotificationAsync_UsesDryRunInterceptor()
{
// Arrange
SetupContext();
_configService.GetProvidersForEventAsync(Arg.Any<NotificationEventType>())
.Returns(new List<NotificationProviderDto>());
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
@@ -591,6 +375,21 @@ public class NotificationPublisherTests
Arg.Any<string?>());
}
[Fact]
public async Task SendNotificationAsync_WhenDryRun_DoesNotPublish()
{
// Arrange
SetupContext();
_dryRunInterceptor.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.Returns(Task.CompletedTask);
// Act
await _publisher.NotifyStrike(StrikeType.FailedImport, 1);
// Assert
await _messageBus.DidNotReceive().Publish(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>());
}
#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<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.SearchItemGrabbed)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto)
.Returns(provider);
var grabbedItems = new List<string> { "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<NotificationContext>(
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<NotificationProviderDto>());
// 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<NotificationMessage>(
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<CancellationToken>());
}
[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<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.ForceImported)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto)
.Returns(provider);
// Act
await _publisher.NotifyForceImported();
// Assert
await provider.Received(1).SendNotificationAsync(Arg.Is<NotificationContext>(
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<NotificationProviderDto>());
// Act
await _publisher.NotifyForceImported();
// Assert
await _configService.Received(1).GetProvidersForEventAsync(NotificationEventType.ForceImported);
await _messageBus.Received(1).Publish(Arg.Is<NotificationMessage>(
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<CancellationToken>());
}
[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<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.QueueItemDeleted)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto).Returns(provider);
// Act
await _publisher.NotifyQueueItemDeleted(true, DeleteReason.Stalled);
// Assert
await provider.Received(1).SendNotificationAsync(Arg.Is<NotificationContext>(
c => c.Description == "Frankenstein" && c.Data["Hash"] == "bookhash1"));
await _messageBus.Received(1).Publish(Arg.Is<NotificationMessage>(
m => m.Context.Description == "Frankenstein" && m.Context.Data["Hash"] == "bookhash1"), Arg.Any<CancellationToken>());
}
[Fact]
@@ -866,19 +588,12 @@ public class NotificationPublisherTests
SetupLazyLibrarianContext();
ContextProvider.Set<QueueRule>(new StallRule { Name = "Test Rule" });
var providerDto = CreateProviderDto();
var provider = Substitute.For<INotificationProvider>();
_configService.GetProvidersForEventAsync(NotificationEventType.StalledStrike)
.Returns(new List<NotificationProviderDto> { providerDto });
_providerFactory.CreateProvider(providerDto).Returns(provider);
// Act
await _publisher.NotifyStrike(StrikeType.Stalled, 1);
// Assert
await provider.Received(1).SendNotificationAsync(Arg.Is<NotificationContext>(
c => c.Description == "Frankenstein" && c.Data["Hash"] == "bookhash1"));
await _messageBus.Received(1).Publish(Arg.Is<NotificationMessage>(
m => m.Context.Description == "Frankenstein" && m.Context.Data["Hash"] == "bookhash1"), Arg.Any<CancellationToken>());
}
#endregion
@@ -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<T> : IConsumer<T> where T : Notification
public sealed class NotificationConsumer : IConsumer<NotificationMessage>
{
private readonly ILogger<NotificationConsumer<T>> _logger;
private readonly NotificationService _notificationService;
private readonly TimeProvider _timeProvider;
public NotificationConsumer(ILogger<NotificationConsumer<T>> logger, NotificationService notificationService, TimeProvider timeProvider)
public NotificationConsumer(NotificationService notificationService)
{
_logger = logger;
_notificationService = notificationService;
_timeProvider = timeProvider;
}
public async Task Consume(ConsumeContext<T> 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<string, string>();
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
};
}
}
public Task Consume(ConsumeContext<NotificationMessage> context) =>
_notificationService.SendNotificationAsync(context.Message.EventType, context.Message.Context);
}
@@ -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; }
}
@@ -1,5 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record CategoryChangedNotification : Notification
{
}
@@ -1,5 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record DownloadCleanedNotification : Notification
{
}
@@ -1,5 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record DownloadStoppedNotification : Notification
{
}
@@ -1,5 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record FailedImportStrikeNotification : ArrNotification
{
}
@@ -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<NotificationField>? Fields { get; init; }
public NotificationLevel Level { get; init; }
}
@@ -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; }
}
@@ -1,9 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public enum NotificationLevel
{
Test,
Information,
Warning,
Important
}
@@ -0,0 +1,5 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record NotificationMessage(NotificationEventType EventType, NotificationContext Context);
@@ -1,5 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record QueueItemDeletedNotification : ArrNotification
{
}
@@ -1,5 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record SlowSpeedStrikeNotification : ArrNotification
{
}
@@ -1,5 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record SlowTimeStrikeNotification : ArrNotification
{
}
@@ -1,5 +0,0 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
public sealed record StalledStrikeNotification : ArrNotification
{
}
@@ -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<NotificationPublisher> _logger;
private readonly IDryRunInterceptor _dryRunInterceptor;
private readonly INotificationConfigurationService _configurationService;
private readonly INotificationProviderFactory _providerFactory;
private readonly IBus _messageBus;
public NotificationPublisher(
ILogger<NotificationPublisher> 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));
}
/// <summary>
@@ -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;
}
}
@@ -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<string[]> {
* `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');
}
});
});
+22
View File
@@ -245,6 +245,28 @@ export async function deleteSeedingRule(accessToken: string, ruleId: string): Pr
});
}
export async function createNotificationProvider(
accessToken: string,
type: string,
provider: Record<string, unknown>,
): Promise<Response> {
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<Response> {
return fetch(`${API}/api/configuration/notification_providers/${providerId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
}
export async function getUnlinkedConfig(accessToken: string, downloadClientId: string): Promise<Response> {
return fetch(`${API}/api/unlinked-config/${downloadClientId}`, {
headers: { Authorization: `Bearer ${accessToken}` },