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;
}
}