mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
Fix notifications not being sent asynchronously (#811)
This commit is contained in:
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;
|
||||
});
|
||||
|
||||
+87
-515
@@ -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>());
|
||||
}
|
||||
}
|
||||
+111
-396
@@ -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
|
||||
|
||||
+6
-105
@@ -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);
|
||||
}
|
||||
-14
@@ -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; }
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record CategoryChangedNotification : Notification
|
||||
{
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record DownloadCleanedNotification : Notification
|
||||
{
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record DownloadStoppedNotification : Notification
|
||||
{
|
||||
}
|
||||
-5
@@ -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; }
|
||||
}
|
||||
-8
@@ -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; }
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public enum NotificationLevel
|
||||
{
|
||||
Test,
|
||||
Information,
|
||||
Warning,
|
||||
Important
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record NotificationMessage(NotificationEventType EventType, NotificationContext Context);
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record QueueItemDeletedNotification : ArrNotification
|
||||
{
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record SlowSpeedStrikeNotification : ArrNotification
|
||||
{
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record SlowTimeStrikeNotification : ArrNotification
|
||||
{
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
|
||||
public sealed record StalledStrikeNotification : ArrNotification
|
||||
{
|
||||
}
|
||||
+5
-29
@@ -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>
|
||||
|
||||
+7
-7
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}` },
|
||||
|
||||
Reference in new issue
Block a user