diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index de3304aa..3e2d8b76 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,7 +61,7 @@ jobs: - name: Run tests id: run-tests - run: dotnet test code/backend/cleanuparr.sln --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --results-directory ./coverage + run: dotnet test code/backend/cleanuparr.sln --configuration Release --no-build --verbosity normal --logger trx --collect:"XPlat Code Coverage" --settings code/backend/coverage.runsettings --results-directory ./coverage - name: Upload test results uses: actions/upload-artifact@v4 diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs index f839db5c..e0e3443c 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/NotificationsDI.cs @@ -2,6 +2,7 @@ using Cleanuparr.Infrastructure.Features.Notifications; using Cleanuparr.Infrastructure.Features.Notifications.Apprise; using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; using Cleanuparr.Infrastructure.Features.Notifications.Ntfy; +using Cleanuparr.Infrastructure.Features.Notifications.Pushover; namespace Cleanuparr.Api.DependencyInjection; @@ -12,6 +13,7 @@ public static class NotificationsDI .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreatePushoverProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreatePushoverProviderRequest.cs new file mode 100644 index 00000000..c069b7a1 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/CreatePushoverProviderRequest.cs @@ -0,0 +1,22 @@ +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests; + +public record CreatePushoverProviderRequest : CreateNotificationProviderRequestBase +{ + public string ApiToken { get; init; } = string.Empty; + + public string UserKey { get; init; } = string.Empty; + + public List Devices { get; init; } = []; + + public PushoverPriority Priority { get; init; } = PushoverPriority.Normal; + + public string? Sound { get; init; } + + public int? Retry { get; init; } + + public int? Expire { get; init; } + + public List Tags { get; init; } = []; +} diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestPushoverProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestPushoverProviderRequest.cs new file mode 100644 index 00000000..ea051abb --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/TestPushoverProviderRequest.cs @@ -0,0 +1,22 @@ +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests; + +public record TestPushoverProviderRequest +{ + public string ApiToken { get; init; } = string.Empty; + + public string UserKey { get; init; } = string.Empty; + + public List Devices { get; init; } = []; + + public PushoverPriority Priority { get; init; } = PushoverPriority.Normal; + + public string? Sound { get; init; } + + public int? Retry { get; init; } + + public int? Expire { get; init; } + + public List Tags { get; init; } = []; +} diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdatePushoverProviderRequest.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdatePushoverProviderRequest.cs new file mode 100644 index 00000000..c3ec38c7 --- /dev/null +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Contracts/Requests/UpdatePushoverProviderRequest.cs @@ -0,0 +1,22 @@ +using Cleanuparr.Domain.Enums; + +namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests; + +public record UpdatePushoverProviderRequest : UpdateNotificationProviderRequestBase +{ + public string ApiToken { get; init; } = string.Empty; + + public string UserKey { get; init; } = string.Empty; + + public List Devices { get; init; } = []; + + public PushoverPriority Priority { get; init; } = PushoverPriority.Normal; + + public string? Sound { get; init; } + + public int? Retry { get; init; } + + public int? Expire { get; init; } + + public List Tags { get; init; } = []; +} diff --git a/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs b/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs index cace2efa..e51cc156 100644 --- a/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs +++ b/code/backend/Cleanuparr.Api/Features/Notifications/Controllers/NotificationProvidersController.cs @@ -44,6 +44,7 @@ public sealed class NotificationProvidersController : ControllerBase .Include(p => p.NotifiarrConfiguration) .Include(p => p.AppriseConfiguration) .Include(p => p.NtfyConfiguration) + .Include(p => p.PushoverConfiguration) .AsNoTracking() .ToListAsync(); @@ -68,6 +69,7 @@ public sealed class NotificationProvidersController : ControllerBase NotificationProviderType.Notifiarr => p.NotifiarrConfiguration ?? new object(), NotificationProviderType.Apprise => p.AppriseConfiguration ?? new object(), NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(), + NotificationProviderType.Pushover => p.PushoverConfiguration ?? new object(), _ => new object() } }) @@ -524,6 +526,7 @@ public sealed class NotificationProvidersController : ControllerBase .Include(p => p.NotifiarrConfiguration) .Include(p => p.AppriseConfiguration) .Include(p => p.NtfyConfiguration) + .Include(p => p.PushoverConfiguration) .FirstOrDefaultAsync(p => p.Id == id); if (existingProvider == null) @@ -701,8 +704,207 @@ public sealed class NotificationProvidersController : ControllerBase NotificationProviderType.Notifiarr => provider.NotifiarrConfiguration ?? new object(), NotificationProviderType.Apprise => provider.AppriseConfiguration ?? new object(), NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(), + NotificationProviderType.Pushover => provider.PushoverConfiguration ?? new object(), _ => new object() } }; } + + [HttpPost("pushover")] + public async Task CreatePushoverProvider([FromBody] CreatePushoverProviderRequest newProvider) + { + await DataContext.Lock.WaitAsync(); + try + { + if (string.IsNullOrWhiteSpace(newProvider.Name)) + { + return BadRequest("Provider name is required"); + } + + var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name); + if (duplicateConfig > 0) + { + return BadRequest("A provider with this name already exists"); + } + + var pushoverConfig = new PushoverConfig + { + ApiToken = newProvider.ApiToken, + UserKey = newProvider.UserKey, + Devices = newProvider.Devices, + Priority = newProvider.Priority, + Sound = newProvider.Sound, + Retry = newProvider.Retry, + Expire = newProvider.Expire, + Tags = newProvider.Tags + }; + pushoverConfig.Validate(); + + var provider = new NotificationConfig + { + Name = newProvider.Name, + Type = NotificationProviderType.Pushover, + IsEnabled = newProvider.IsEnabled, + OnFailedImportStrike = newProvider.OnFailedImportStrike, + OnStalledStrike = newProvider.OnStalledStrike, + OnSlowStrike = newProvider.OnSlowStrike, + OnQueueItemDeleted = newProvider.OnQueueItemDeleted, + OnDownloadCleaned = newProvider.OnDownloadCleaned, + OnCategoryChanged = newProvider.OnCategoryChanged, + PushoverConfiguration = pushoverConfig + }; + + _dataContext.NotificationConfigs.Add(provider); + await _dataContext.SaveChangesAsync(); + + await _notificationConfigurationService.InvalidateCacheAsync(); + + var providerDto = MapProvider(provider); + return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto); + } + catch (ValidationException ex) + { + return BadRequest(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create Pushover provider"); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("pushover/{id:guid}")] + public async Task UpdatePushoverProvider(Guid id, [FromBody] UpdatePushoverProviderRequest updatedProvider) + { + await DataContext.Lock.WaitAsync(); + try + { + var existingProvider = await _dataContext.NotificationConfigs + .Include(p => p.PushoverConfiguration) + .FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Pushover); + + if (existingProvider == null) + { + return NotFound($"Pushover provider with ID {id} not found"); + } + + if (string.IsNullOrWhiteSpace(updatedProvider.Name)) + { + return BadRequest("Provider name is required"); + } + + var duplicateConfig = await _dataContext.NotificationConfigs + .Where(x => x.Id != id) + .Where(x => x.Name == updatedProvider.Name) + .CountAsync(); + if (duplicateConfig > 0) + { + return BadRequest("A provider with this name already exists"); + } + + var pushoverConfig = new PushoverConfig + { + ApiToken = updatedProvider.ApiToken, + UserKey = updatedProvider.UserKey, + Devices = updatedProvider.Devices, + Priority = updatedProvider.Priority, + Sound = updatedProvider.Sound, + Retry = updatedProvider.Retry, + Expire = updatedProvider.Expire, + Tags = updatedProvider.Tags + }; + + if (existingProvider.PushoverConfiguration != null) + { + pushoverConfig = pushoverConfig with { Id = existingProvider.PushoverConfiguration.Id }; + } + pushoverConfig.Validate(); + + var newProvider = existingProvider with + { + Name = updatedProvider.Name, + IsEnabled = updatedProvider.IsEnabled, + OnFailedImportStrike = updatedProvider.OnFailedImportStrike, + OnStalledStrike = updatedProvider.OnStalledStrike, + OnSlowStrike = updatedProvider.OnSlowStrike, + OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted, + OnDownloadCleaned = updatedProvider.OnDownloadCleaned, + OnCategoryChanged = updatedProvider.OnCategoryChanged, + PushoverConfiguration = pushoverConfig, + UpdatedAt = DateTime.UtcNow + }; + + _dataContext.NotificationConfigs.Remove(existingProvider); + _dataContext.NotificationConfigs.Add(newProvider); + + await _dataContext.SaveChangesAsync(); + await _notificationConfigurationService.InvalidateCacheAsync(); + + var providerDto = MapProvider(newProvider); + return Ok(providerDto); + } + catch (ValidationException ex) + { + return BadRequest(ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update Pushover provider with ID {Id}", id); + throw; + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPost("pushover/test")] + public async Task TestPushoverProvider([FromBody] TestPushoverProviderRequest testRequest) + { + try + { + var pushoverConfig = new PushoverConfig + { + ApiToken = testRequest.ApiToken, + UserKey = testRequest.UserKey, + Devices = testRequest.Devices, + Priority = testRequest.Priority, + Sound = testRequest.Sound, + Retry = testRequest.Retry, + Expire = testRequest.Expire, + Tags = testRequest.Tags + }; + pushoverConfig.Validate(); + + var providerDto = new NotificationProviderDto + { + Id = Guid.NewGuid(), + Name = "Test Provider", + Type = NotificationProviderType.Pushover, + IsEnabled = true, + Events = new NotificationEventFlags + { + OnFailedImportStrike = true, + OnStalledStrike = false, + OnSlowStrike = false, + OnQueueItemDeleted = false, + OnDownloadCleaned = false, + OnCategoryChanged = false + }, + Configuration = pushoverConfig + }; + + await _notificationService.SendTestNotificationAsync(providerDto); + return Ok(new { Message = "Test notification sent successfully", Success = true }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to test Pushover provider"); + throw; + } + } } diff --git a/code/backend/Cleanuparr.Domain/Enums/NotificationProviderType.cs b/code/backend/Cleanuparr.Domain/Enums/NotificationProviderType.cs index d6df6bca..e16cb488 100644 --- a/code/backend/Cleanuparr.Domain/Enums/NotificationProviderType.cs +++ b/code/backend/Cleanuparr.Domain/Enums/NotificationProviderType.cs @@ -4,5 +4,6 @@ public enum NotificationProviderType { Notifiarr, Apprise, - Ntfy + Ntfy, + Pushover } diff --git a/code/backend/Cleanuparr.Domain/Enums/PushoverPriority.cs b/code/backend/Cleanuparr.Domain/Enums/PushoverPriority.cs new file mode 100644 index 00000000..7346a224 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/PushoverPriority.cs @@ -0,0 +1,10 @@ +namespace Cleanuparr.Domain.Enums; + +public enum PushoverPriority +{ + Lowest = -2, + Low = -1, + Normal = 0, + High = 1, + Emergency = 2 +} diff --git a/code/backend/Cleanuparr.Domain/Enums/PushoverSound.cs b/code/backend/Cleanuparr.Domain/Enums/PushoverSound.cs new file mode 100644 index 00000000..e9904200 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/PushoverSound.cs @@ -0,0 +1,36 @@ +namespace Cleanuparr.Domain.Enums; + +public static class PushoverSounds +{ + public const string Pushover = "pushover"; + public const string Bike = "bike"; + public const string Bugle = "bugle"; + public const string Cashregister = "cashregister"; + public const string Classical = "classical"; + public const string Cosmic = "cosmic"; + public const string Falling = "falling"; + public const string Gamelan = "gamelan"; + public const string Incoming = "incoming"; + public const string Intermission = "intermission"; + public const string Magic = "magic"; + public const string Mechanical = "mechanical"; + public const string Pianobar = "pianobar"; + public const string Siren = "siren"; + public const string Spacealarm = "spacealarm"; + public const string Tugboat = "tugboat"; + public const string Alien = "alien"; + public const string Climb = "climb"; + public const string Persistent = "persistent"; + public const string Echo = "echo"; + public const string Updown = "updown"; + public const string Vibrate = "vibrate"; + public const string None = "none"; + + public static readonly string[] All = + [ + Pushover, Bike, Bugle, Cashregister, Classical, Cosmic, Falling, + Gamelan, Incoming, Intermission, Magic, Mechanical, Pianobar, + Siren, Spacealarm, Tugboat, Alien, Climb, Persistent, Echo, + Updown, Vibrate, None + ]; +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadServiceFactoryTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadServiceFactoryTests.cs index dbcbec7c..7dd832aa 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadServiceFactoryTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DownloadServiceFactoryTests.cs @@ -86,7 +86,7 @@ public class DownloadServiceFactoryTests : IDisposable // BlocklistProvider requires specific constructor arguments var scopeFactoryMock = new Mock(); - services.AddSingleton(new BlocklistProvider( + services.AddSingleton(new BlocklistProvider( Mock.Of>(), scopeFactoryMock.Object, _memoryCache)); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs index 244f3a04..4e095df3 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationConfigurationServiceTests.cs @@ -308,8 +308,119 @@ public class NotificationConfigurationServiceTests : IDisposable #endregion + #region Error Handling Tests + + [Fact] + public async Task GetProvidersForEventAsync_UnknownEventType_ThrowsArgumentOutOfRangeException() + { + // Arrange + var config = CreateNotifiarrConfig("Test", isEnabled: true); + _context.Set().Add(config); + await _context.SaveChangesAsync(); + await _service.InvalidateCacheAsync(); + + var unknownEventType = (NotificationEventType)999; + + // Act & Assert + await Assert.ThrowsAsync( + () => _service.GetProvidersForEventAsync(unknownEventType)); + } + + [Fact] + public async Task GetActiveProvidersAsync_DatabaseError_ReturnsEmptyListAndLogsError() + { + // Arrange - dispose context to simulate database error + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + var disposedContext = new DataContext(options); + var loggerMock = new Mock>(); + var service = new NotificationConfigurationService(disposedContext, loggerMock.Object); + + await disposedContext.DisposeAsync(); + + // Act + var result = await service.GetActiveProvidersAsync(); + + // Assert + Assert.Empty(result); + loggerMock.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("Failed to load notification providers")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + #endregion + + #region Provider Type Mapping Tests + + [Theory] + [InlineData(NotificationProviderType.Notifiarr)] + [InlineData(NotificationProviderType.Apprise)] + [InlineData(NotificationProviderType.Ntfy)] + [InlineData(NotificationProviderType.Pushover)] + public async Task GetActiveProvidersAsync_MapsProviderTypeCorrectly(NotificationProviderType providerType) + { + // Arrange + var config = CreateConfigForType(providerType, "Test Provider", isEnabled: true); + _context.Set().Add(config); + await _context.SaveChangesAsync(); + await _service.InvalidateCacheAsync(); + + // Act + var result = await _service.GetActiveProvidersAsync(); + + // Assert + Assert.Single(result); + Assert.Equal(providerType, result[0].Type); + Assert.Equal("Test Provider", result[0].Name); + Assert.NotNull(result[0].Configuration); + } + + [Theory] + [InlineData(NotificationProviderType.Notifiarr)] + [InlineData(NotificationProviderType.Apprise)] + [InlineData(NotificationProviderType.Ntfy)] + [InlineData(NotificationProviderType.Pushover)] + public async Task GetProvidersForEventAsync_ReturnsProviderForAllTypes(NotificationProviderType providerType) + { + // Arrange + var config = CreateConfigForType(providerType, "Test", isEnabled: true); + _context.Set().Add(config); + await _context.SaveChangesAsync(); + await _service.InvalidateCacheAsync(); + + // Act + var result = await _service.GetProvidersForEventAsync(NotificationEventType.StalledStrike); + + // Assert + Assert.Single(result); + Assert.Equal(providerType, result[0].Type); + } + + #endregion + #region Helper Methods + private static NotificationConfig CreateConfigForType( + NotificationProviderType providerType, + string name, + bool isEnabled) + { + return providerType switch + { + NotificationProviderType.Notifiarr => CreateNotifiarrConfig(name, isEnabled), + NotificationProviderType.Apprise => CreateAppriseConfig(name, isEnabled), + NotificationProviderType.Ntfy => CreateNtfyConfig(name, isEnabled), + NotificationProviderType.Pushover => CreatePushoverConfig(name, isEnabled), + _ => throw new ArgumentOutOfRangeException(nameof(providerType)) + }; + } + private static NotificationConfig CreateNotifiarrConfig( string name, bool isEnabled, @@ -341,5 +452,74 @@ public class NotificationConfigurationServiceTests : IDisposable }; } + private static NotificationConfig CreateAppriseConfig(string name, bool isEnabled) + { + return new NotificationConfig + { + Id = Guid.NewGuid(), + Name = name, + Type = NotificationProviderType.Apprise, + IsEnabled = isEnabled, + OnStalledStrike = true, + OnFailedImportStrike = true, + OnSlowStrike = true, + OnQueueItemDeleted = true, + OnDownloadCleaned = true, + OnCategoryChanged = true, + AppriseConfiguration = new AppriseConfig + { + Id = Guid.NewGuid(), + Url = "http://localhost:8000", + Key = "testkey" + } + }; + } + + private static NotificationConfig CreateNtfyConfig(string name, bool isEnabled) + { + return new NotificationConfig + { + Id = Guid.NewGuid(), + Name = name, + Type = NotificationProviderType.Ntfy, + IsEnabled = isEnabled, + OnStalledStrike = true, + OnFailedImportStrike = true, + OnSlowStrike = true, + OnQueueItemDeleted = true, + OnDownloadCleaned = true, + OnCategoryChanged = true, + NtfyConfiguration = new NtfyConfig + { + Id = Guid.NewGuid(), + ServerUrl = "https://ntfy.sh", + Topics = ["test-topic"] + } + }; + } + + private static NotificationConfig CreatePushoverConfig(string name, bool isEnabled) + { + return new NotificationConfig + { + Id = Guid.NewGuid(), + Name = name, + Type = NotificationProviderType.Pushover, + IsEnabled = isEnabled, + OnStalledStrike = true, + OnFailedImportStrike = true, + OnSlowStrike = true, + OnQueueItemDeleted = true, + OnDownloadCleaned = true, + OnCategoryChanged = true, + PushoverConfiguration = new PushoverConfig + { + Id = Guid.NewGuid(), + ApiToken = "test_api_token_1234567890abcd", + UserKey = "test_user_key_1234567890abcde" + } + }; + } + #endregion } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs index 5ffa6c04..95ac8155 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/NotificationProviderFactoryTests.cs @@ -4,6 +4,7 @@ using Cleanuparr.Infrastructure.Features.Notifications.Apprise; using Cleanuparr.Infrastructure.Features.Notifications.Models; using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; using Cleanuparr.Infrastructure.Features.Notifications.Ntfy; +using Cleanuparr.Infrastructure.Features.Notifications.Pushover; using Cleanuparr.Persistence.Models.Configuration.Notification; using Microsoft.Extensions.DependencyInjection; using Moq; @@ -16,6 +17,7 @@ public class NotificationProviderFactoryTests private readonly Mock _appriseProxyMock; private readonly Mock _ntfyProxyMock; private readonly Mock _notifiarrProxyMock; + private readonly Mock _pushoverProxyMock; private readonly IServiceProvider _serviceProvider; private readonly NotificationProviderFactory _factory; @@ -24,11 +26,13 @@ public class NotificationProviderFactoryTests _appriseProxyMock = new Mock(); _ntfyProxyMock = new Mock(); _notifiarrProxyMock = new Mock(); + _pushoverProxyMock = new Mock(); var services = new ServiceCollection(); services.AddSingleton(_appriseProxyMock.Object); services.AddSingleton(_ntfyProxyMock.Object); services.AddSingleton(_notifiarrProxyMock.Object); + services.AddSingleton(_pushoverProxyMock.Object); _serviceProvider = services.BuildServiceProvider(); _factory = new NotificationProviderFactory(_serviceProvider); @@ -122,6 +126,38 @@ public class NotificationProviderFactoryTests Assert.Equal(NotificationProviderType.Notifiarr, provider.Type); } + [Fact] + public void CreateProvider_PushoverType_CreatesPushoverProvider() + { + // Arrange + var config = new NotificationProviderDto + { + Id = Guid.NewGuid(), + Name = "TestPushover", + Type = NotificationProviderType.Pushover, + IsEnabled = true, + Configuration = new PushoverConfig + { + Id = Guid.NewGuid(), + ApiToken = "test-api-token", + UserKey = "test-user-key", + Devices = new List(), + Priority = PushoverPriority.Normal, + Sound = "", + Tags = new List() + } + }; + + // Act + var provider = _factory.CreateProvider(config); + + // Assert + Assert.NotNull(provider); + Assert.IsType(provider); + Assert.Equal("TestPushover", provider.Name); + Assert.Equal(NotificationProviderType.Pushover, provider.Type); + } + [Fact] public void CreateProvider_UnsupportedType_ThrowsNotSupportedException() { @@ -201,7 +237,8 @@ public class NotificationProviderFactoryTests { (Type: NotificationProviderType.Apprise, Config: (object)new AppriseConfig { Id = Guid.NewGuid(), Url = "http://test.com", Key = "key" }), (Type: NotificationProviderType.Ntfy, Config: (object)new NtfyConfig { Id = Guid.NewGuid(), ServerUrl = "http://test.com", Topics = new List { "t" }, AuthenticationType = NtfyAuthenticationType.None, Priority = NtfyPriority.Default }), - (Type: NotificationProviderType.Notifiarr, Config: (object)new NotifiarrConfig { Id = Guid.NewGuid(), ApiKey = "1234567890", ChannelId = "12345" }) + (Type: NotificationProviderType.Notifiarr, Config: (object)new NotifiarrConfig { Id = Guid.NewGuid(), ApiKey = "1234567890", ChannelId = "12345" }), + (Type: NotificationProviderType.Pushover, Config: (object)new PushoverConfig { Id = Guid.NewGuid(), ApiToken = "token", UserKey = "user", Devices = new List(), Priority = PushoverPriority.Normal, Sound = "", Tags = new List() }) }; foreach (var (type, configObj) in configs) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Pushover/PushoverProxyTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Pushover/PushoverProxyTests.cs new file mode 100644 index 00000000..a9690cc8 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/Pushover/PushoverProxyTests.cs @@ -0,0 +1,466 @@ +using System.Net; +using Cleanuparr.Infrastructure.Features.Notifications.Pushover; +using Cleanuparr.Shared.Helpers; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Pushover; + +public class PushoverProxyTests +{ + private readonly Mock _httpClientFactoryMock; + private readonly Mock _httpMessageHandlerMock; + + public PushoverProxyTests() + { + _httpMessageHandlerMock = new Mock(); + _httpClientFactoryMock = new Mock(); + + var httpClient = new HttpClient(_httpMessageHandlerMock.Object); + _httpClientFactoryMock + .Setup(f => f.CreateClient(Constants.HttpClientWithRetryName)) + .Returns(httpClient); + } + + private PushoverProxy CreateProxy() + { + return new PushoverProxy(_httpClientFactoryMock.Object); + } + + private static PushoverPayload CreatePayload(int priority = 0) + { + return new PushoverPayload + { + Token = "test-token", + User = "test-user", + Message = "Test message", + Title = "Test Title", + Priority = priority, + Retry = priority == 2 ? 60 : null, + Expire = priority == 2 ? 3600 : null + }; + } + + #region Constructor Tests + + [Fact] + public void Constructor_WithValidFactory_CreatesInstance() + { + // Act + var proxy = CreateProxy(); + + // Assert + Assert.NotNull(proxy); + } + + [Fact] + public void Constructor_CreatesHttpClientWithCorrectName() + { + // Act + _ = CreateProxy(); + + // Assert + _httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once); + } + + #endregion + + #region SendNotification Success Tests + + [Fact] + public async Task SendNotification_WhenSuccessful_CompletesWithoutException() + { + // Arrange + var proxy = CreateProxy(); + SetupSuccessResponse(); + + // Act & Assert - Should not throw + await proxy.SendNotification(CreatePayload()); + } + + [Fact] + public async Task SendNotification_SendsPostRequest() + { + // Arrange + var proxy = CreateProxy(); + HttpMethod? capturedMethod = null; + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => capturedMethod = req.Method) + .ReturnsAsync(CreateSuccessResponse()); + + // Act + await proxy.SendNotification(CreatePayload()); + + // Assert + Assert.Equal(HttpMethod.Post, capturedMethod); + } + + [Fact] + public async Task SendNotification_SendsToCorrectUrl() + { + // Arrange + var proxy = CreateProxy(); + Uri? capturedUri = null; + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => capturedUri = req.RequestUri) + .ReturnsAsync(CreateSuccessResponse()); + + // Act + await proxy.SendNotification(CreatePayload()); + + // Assert + Assert.NotNull(capturedUri); + Assert.Equal("https://api.pushover.net/1/messages.json", capturedUri.ToString()); + } + + [Fact] + public async Task SendNotification_UsesFormUrlEncodedContent() + { + // Arrange + var proxy = CreateProxy(); + string? capturedContentType = null; + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => + capturedContentType = req.Content?.Headers.ContentType?.MediaType) + .ReturnsAsync(CreateSuccessResponse()); + + // Act + await proxy.SendNotification(CreatePayload()); + + // Assert + Assert.Equal("application/x-www-form-urlencoded", capturedContentType); + } + + [Fact] + public async Task SendNotification_IncludesRequiredFieldsInPayload() + { + // Arrange + var proxy = CreateProxy(); + string? capturedContent = null; + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(async (req, _) => + capturedContent = await req.Content!.ReadAsStringAsync()) + .ReturnsAsync(CreateSuccessResponse()); + + var payload = CreatePayload(); + + // Act + await proxy.SendNotification(payload); + + // Assert + Assert.NotNull(capturedContent); + Assert.Contains("token=test-token", capturedContent); + Assert.Contains("user=test-user", capturedContent); + Assert.Contains("message=Test+message", capturedContent); + Assert.Contains("priority=0", capturedContent); + } + + [Fact] + public async Task SendNotification_WithEmergencyPriority_IncludesRetryAndExpire() + { + // Arrange + var proxy = CreateProxy(); + string? capturedContent = null; + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(async (req, _) => + capturedContent = await req.Content!.ReadAsStringAsync()) + .ReturnsAsync(CreateSuccessResponse()); + + var payload = CreatePayload(priority: 2); // Emergency + + // Act + await proxy.SendNotification(payload); + + // Assert + Assert.NotNull(capturedContent); + Assert.Contains("retry=60", capturedContent); + Assert.Contains("expire=3600", capturedContent); + } + + [Fact] + public async Task SendNotification_WithNonEmergencyPriority_DoesNotIncludeRetryAndExpire() + { + // Arrange + var proxy = CreateProxy(); + string? capturedContent = null; + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(async (req, _) => + capturedContent = await req.Content!.ReadAsStringAsync()) + .ReturnsAsync(CreateSuccessResponse()); + + var payload = CreatePayload(priority: 1); // High, not Emergency + + // Act + await proxy.SendNotification(payload); + + // Assert + Assert.NotNull(capturedContent); + Assert.DoesNotContain("retry=", capturedContent); + Assert.DoesNotContain("expire=", capturedContent); + } + + [Fact] + public async Task SendNotification_WithSound_IncludesSound() + { + // Arrange + var proxy = CreateProxy(); + string? capturedContent = null; + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(async (req, _) => + capturedContent = await req.Content!.ReadAsStringAsync()) + .ReturnsAsync(CreateSuccessResponse()); + + var payload = new PushoverPayload + { + Token = "test-token", + User = "test-user", + Message = "Test message", + Title = "Test Title", + Priority = 0, + Sound = "cosmic" + }; + + // Act + await proxy.SendNotification(payload); + + // Assert + Assert.NotNull(capturedContent); + Assert.Contains("sound=cosmic", capturedContent); + } + + [Fact] + public async Task SendNotification_WithDevice_IncludesDevice() + { + // Arrange + var proxy = CreateProxy(); + string? capturedContent = null; + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(async (req, _) => + capturedContent = await req.Content!.ReadAsStringAsync()) + .ReturnsAsync(CreateSuccessResponse()); + + var payload = new PushoverPayload + { + Token = "test-token", + User = "test-user", + Message = "Test message", + Title = "Test Title", + Priority = 0, + Device = "my-phone" + }; + + // Act + await proxy.SendNotification(payload); + + // Assert + Assert.NotNull(capturedContent); + Assert.Contains("device=my-phone", capturedContent); + } + + [Fact] + public async Task SendNotification_WithTags_IncludesTags() + { + // Arrange + var proxy = CreateProxy(); + string? capturedContent = null; + + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(async (req, _) => + capturedContent = await req.Content!.ReadAsStringAsync()) + .ReturnsAsync(CreateSuccessResponse()); + + var payload = new PushoverPayload + { + Token = "test-token", + User = "test-user", + Message = "Test message", + Title = "Test Title", + Priority = 0, + Tags = "tag1,tag2" + }; + + // Act + await proxy.SendNotification(payload); + + // Assert + Assert.NotNull(capturedContent); + Assert.Contains("tags=tag1%2Ctag2", capturedContent); // URL-encoded comma + } + + #endregion + + #region SendNotification Error Tests + + [Fact] + public async Task SendNotification_When400_ThrowsPushoverExceptionWithBadRequest() + { + // Arrange + var proxy = CreateProxy(); + SetupErrorResponse(HttpStatusCode.BadRequest, "{\"status\":0,\"errors\":[\"invalid token\"]}"); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + proxy.SendNotification(CreatePayload())); + Assert.Contains("Bad request", ex.Message); + } + + [Fact] + public async Task SendNotification_When401_ThrowsPushoverExceptionWithUnauthorized() + { + // Arrange + var proxy = CreateProxy(); + SetupErrorResponse(HttpStatusCode.Unauthorized, "{\"status\":0,\"errors\":[\"invalid api key\"]}"); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + proxy.SendNotification(CreatePayload())); + Assert.Contains("Invalid API token or user key", ex.Message); + } + + [Fact] + public async Task SendNotification_When429_ThrowsPushoverExceptionWithRateLimited() + { + // Arrange + var proxy = CreateProxy(); + SetupErrorResponse((HttpStatusCode)429, "{\"status\":0,\"errors\":[\"rate limit exceeded\"]}"); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + proxy.SendNotification(CreatePayload())); + Assert.Contains("Rate limit exceeded", ex.Message); + } + + [Fact] + public async Task SendNotification_WhenApiReturnsStatus0_ThrowsPushoverException() + { + // Arrange + var proxy = CreateProxy(); + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"status\":0,\"errors\":[\"user key is invalid\"]}") + }); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + proxy.SendNotification(CreatePayload())); + Assert.Contains("user key is invalid", ex.Message); + } + + [Fact] + public async Task SendNotification_WhenNetworkError_ThrowsPushoverException() + { + // Arrange + var proxy = CreateProxy(); + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new HttpRequestException("Network error")); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => + proxy.SendNotification(CreatePayload())); + Assert.Contains("Unable to connect to Pushover API", ex.Message); + } + + #endregion + + #region Helper Methods + + private void SetupSuccessResponse() + { + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(CreateSuccessResponse()); + } + + private static HttpResponseMessage CreateSuccessResponse() + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"status\":1,\"request\":\"abc123\"}") + }; + } + + private void SetupErrorResponse(HttpStatusCode statusCode, string responseBody) + { + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(statusCode) + { + Content = new StringContent(responseBody) + }); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/PushoverProviderTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/PushoverProviderTests.cs new file mode 100644 index 00000000..8512b4f7 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Notifications/PushoverProviderTests.cs @@ -0,0 +1,489 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Notifications.Models; +using Cleanuparr.Infrastructure.Features.Notifications.Pushover; +using Cleanuparr.Persistence.Models.Configuration.Notification; +using Moq; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Notifications; + +public class PushoverProviderTests +{ + private readonly Mock _proxyMock; + private readonly PushoverConfig _config; + private readonly PushoverProvider _provider; + + public PushoverProviderTests() + { + _proxyMock = new Mock(); + _config = new PushoverConfig + { + Id = Guid.NewGuid(), + ApiToken = "test-api-token", + UserKey = "test-user-key", + Devices = new List(), + Priority = PushoverPriority.Normal, + Sound = "", + Retry = null, + Expire = null, + Tags = new List() + }; + + _provider = new PushoverProvider( + "TestPushover", + NotificationProviderType.Pushover, + _config, + _proxyMock.Object); + } + + #region Constructor Tests + + [Fact] + public void Constructor_SetsNameCorrectly() + { + // Assert + Assert.Equal("TestPushover", _provider.Name); + } + + [Fact] + public void Constructor_SetsTypeCorrectly() + { + // Assert + Assert.Equal(NotificationProviderType.Pushover, _provider.Type); + } + + #endregion + + #region SendNotificationAsync Tests + + [Fact] + public async Task SendNotificationAsync_CallsProxyWithCorrectPayload() + { + // Arrange + var context = CreateTestContext(); + PushoverPayload? capturedPayload = null; + + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await _provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.Equal("test-api-token", capturedPayload.Token); + Assert.Equal("test-user-key", capturedPayload.User); + Assert.Equal(context.Title, capturedPayload.Title); + Assert.Contains(context.Description, capturedPayload.Message); + } + + [Fact] + public async Task SendNotificationAsync_IncludesDataInMessage() + { + // Arrange + var context = CreateTestContext(); + context.Data["TestKey"] = "TestValue"; + context.Data["AnotherKey"] = "AnotherValue"; + + PushoverPayload? capturedPayload = null; + + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await _provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.Contains("TestKey: TestValue", capturedPayload.Message); + Assert.Contains("AnotherKey: AnotherValue", capturedPayload.Message); + } + + [Fact] + public async Task SendNotificationAsync_UsesPriorityFromConfig() + { + // Arrange + var config = new PushoverConfig + { + Id = Guid.NewGuid(), + ApiToken = "token", + UserKey = "user", + Devices = new List(), + Priority = PushoverPriority.High, + Sound = "", + Tags = new List() + }; + + var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object); + var context = CreateTestContext(); + + PushoverPayload? capturedPayload = null; + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.Equal((int)PushoverPriority.High, capturedPayload.Priority); + } + + [Fact] + public async Task SendNotificationAsync_WithEmergencyPriority_IncludesRetryAndExpire() + { + // Arrange + var config = new PushoverConfig + { + Id = Guid.NewGuid(), + ApiToken = "token", + UserKey = "user", + Devices = new List(), + Priority = PushoverPriority.Emergency, + Sound = "", + Retry = 60, + Expire = 3600, + Tags = new List() + }; + + var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object); + var context = CreateTestContext(); + + PushoverPayload? capturedPayload = null; + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.Equal((int)PushoverPriority.Emergency, capturedPayload.Priority); + Assert.Equal(60, capturedPayload.Retry); + Assert.Equal(3600, capturedPayload.Expire); + } + + [Fact] + public async Task SendNotificationAsync_WithNonEmergencyPriority_DoesNotIncludeRetryAndExpire() + { + // Arrange + var config = new PushoverConfig + { + Id = Guid.NewGuid(), + ApiToken = "token", + UserKey = "user", + Devices = new List(), + Priority = PushoverPriority.High, // Not Emergency + Sound = "", + Retry = 60, // Should be ignored + Expire = 3600, // Should be ignored + Tags = new List() + }; + + var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object); + var context = CreateTestContext(); + + PushoverPayload? capturedPayload = null; + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.Null(capturedPayload.Retry); + Assert.Null(capturedPayload.Expire); + } + + [Fact] + public async Task SendNotificationAsync_WithDevices_JoinsDevicesAsString() + { + // Arrange + var config = new PushoverConfig + { + Id = Guid.NewGuid(), + ApiToken = "token", + UserKey = "user", + Devices = new List { "device1", "device2", "device3" }, + Priority = PushoverPriority.Normal, + Sound = "", + Tags = new List() + }; + + var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object); + var context = CreateTestContext(); + + PushoverPayload? capturedPayload = null; + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.Equal("device1,device2,device3", capturedPayload.Device); + } + + [Fact] + public async Task SendNotificationAsync_WithEmptyDevices_DeviceIsNull() + { + // Arrange + var context = CreateTestContext(); + + PushoverPayload? capturedPayload = null; + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await _provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.Null(capturedPayload.Device); + } + + [Fact] + public async Task SendNotificationAsync_WithTags_JoinsTagsAsString() + { + // Arrange + var config = new PushoverConfig + { + Id = Guid.NewGuid(), + ApiToken = "token", + UserKey = "user", + Devices = new List(), + Priority = PushoverPriority.Normal, + Sound = "", + Tags = new List { "tag1", "tag2" } + }; + + var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object); + var context = CreateTestContext(); + + PushoverPayload? capturedPayload = null; + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.Equal("tag1,tag2", capturedPayload.Tags); + } + + [Fact] + public async Task SendNotificationAsync_WithSound_IncludesSound() + { + // Arrange + var config = new PushoverConfig + { + Id = Guid.NewGuid(), + ApiToken = "token", + UserKey = "user", + Devices = new List(), + Priority = PushoverPriority.Normal, + Sound = "cosmic", + Tags = new List() + }; + + var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object); + var context = CreateTestContext(); + + PushoverPayload? capturedPayload = null; + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.Equal("cosmic", capturedPayload.Sound); + } + + [Fact] + public async Task SendNotificationAsync_TruncatesLongMessage() + { + // Arrange + var context = new NotificationContext + { + EventType = NotificationEventType.QueueItemDeleted, + Title = "Test Notification", + Description = new string('A', 2000), // Very long message + Severity = EventSeverity.Information, + Data = new Dictionary() + }; + + PushoverPayload? capturedPayload = null; + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await _provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.True(capturedPayload.Message.Length <= 1024); + Assert.EndsWith("...", capturedPayload.Message); + } + + [Fact] + public async Task SendNotificationAsync_TruncatesLongTitle() + { + // Arrange + var context = new NotificationContext + { + EventType = NotificationEventType.QueueItemDeleted, + Title = new string('B', 300), // Very long title + Description = "Test Description", + Severity = EventSeverity.Information, + Data = new Dictionary() + }; + + PushoverPayload? capturedPayload = null; + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await _provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.True(capturedPayload.Title!.Length <= 250); + Assert.EndsWith("...", capturedPayload.Title); + } + + [Fact] + public async Task SendNotificationAsync_TrimsDeviceNames() + { + // Arrange + var config = new PushoverConfig + { + Id = Guid.NewGuid(), + ApiToken = "token", + UserKey = "user", + Devices = new List { " device1 ", "device2 " }, + Priority = PushoverPriority.Normal, + Sound = "", + Tags = new List() + }; + + var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object); + var context = CreateTestContext(); + + PushoverPayload? capturedPayload = null; + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.Equal("device1,device2", capturedPayload.Device); + } + + [Fact] + public async Task SendNotificationAsync_SkipsEmptyDevices() + { + // Arrange + var config = new PushoverConfig + { + Id = Guid.NewGuid(), + ApiToken = "token", + UserKey = "user", + Devices = new List { "device1", "", " ", "device2" }, + Priority = PushoverPriority.Normal, + Sound = "", + Tags = new List() + }; + + var provider = new PushoverProvider("TestPushover", NotificationProviderType.Pushover, config, _proxyMock.Object); + var context = CreateTestContext(); + + PushoverPayload? capturedPayload = null; + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.Equal("device1,device2", capturedPayload.Device); + } + + [Fact] + public async Task SendNotificationAsync_WhenProxyThrows_PropagatesException() + { + // Arrange + var context = CreateTestContext(); + + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .ThrowsAsync(new PushoverException("Proxy error")); + + // Act & Assert + await Assert.ThrowsAsync(() => _provider.SendNotificationAsync(context)); + } + + [Fact] + public async Task SendNotificationAsync_WithEmptyData_MessageContainsOnlyDescription() + { + // Arrange + var context = new NotificationContext + { + EventType = NotificationEventType.Test, + Title = "Test Title", + Description = "Test Description Only", + Severity = EventSeverity.Information, + Data = new Dictionary() + }; + + PushoverPayload? capturedPayload = null; + + _proxyMock.Setup(p => p.SendNotification(It.IsAny())) + .Callback(payload => capturedPayload = payload) + .Returns(Task.CompletedTask); + + // Act + await _provider.SendNotificationAsync(context); + + // Assert + Assert.NotNull(capturedPayload); + Assert.Equal("Test Description Only", capturedPayload.Message); + } + + #endregion + + #region Helper Methods + + private static NotificationContext CreateTestContext() + { + return new NotificationContext + { + EventType = NotificationEventType.QueueItemDeleted, + Title = "Test Notification", + Description = "Test Description", + Severity = EventSeverity.Information, + Data = new Dictionary() + }; + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs index acdee9ab..70a3c8af 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeService.cs @@ -27,7 +27,7 @@ public partial class DelugeService : DownloadService, IDelugeService IHardLinkFileService hardLinkFileService, IDynamicHttpClientProvider httpClientProvider, IEventPublisher eventPublisher, - BlocklistProvider blocklistProvider, + IBlocklistProvider blocklistProvider, DownloadClientConfig downloadClientConfig, IRuleEvaluator ruleEvaluator, IRuleManager ruleManager @@ -51,7 +51,7 @@ public partial class DelugeService : DownloadService, IDelugeService IHardLinkFileService hardLinkFileService, IDynamicHttpClientProvider httpClientProvider, IEventPublisher eventPublisher, - BlocklistProvider blocklistProvider, + IBlocklistProvider blocklistProvider, DownloadClientConfig downloadClientConfig, IRuleEvaluator ruleEvaluator, IRuleManager ruleManager, diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs index 58ac3c7a..8487235b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs @@ -34,7 +34,7 @@ public abstract class DownloadService : IDownloadService protected readonly IDryRunInterceptor _dryRunInterceptor; protected readonly IHardLinkFileService _hardLinkFileService; protected readonly IEventPublisher _eventPublisher; - protected readonly BlocklistProvider _blocklistProvider; + protected readonly IBlocklistProvider _blocklistProvider; protected readonly HttpClient _httpClient; protected readonly DownloadClientConfig _downloadClientConfig; protected readonly IRuleEvaluator _ruleEvaluator; @@ -49,7 +49,7 @@ public abstract class DownloadService : IDownloadService IHardLinkFileService hardLinkFileService, IDynamicHttpClientProvider httpClientProvider, IEventPublisher eventPublisher, - BlocklistProvider blocklistProvider, + IBlocklistProvider blocklistProvider, DownloadClientConfig downloadClientConfig, IRuleEvaluator ruleEvaluator, IRuleManager ruleManager diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs index fb2a1d4f..780bf6a8 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs @@ -68,7 +68,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory var hardLinkFileService = _serviceProvider.GetRequiredService(); var httpClientProvider = _serviceProvider.GetRequiredService(); var eventPublisher = _serviceProvider.GetRequiredService(); - var blocklistProvider = _serviceProvider.GetRequiredService(); + var blocklistProvider = _serviceProvider.GetRequiredService(); var ruleEvaluator = _serviceProvider.GetRequiredService(); var ruleManager = _serviceProvider.GetRequiredService(); @@ -92,7 +92,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory var hardLinkFileService = _serviceProvider.GetRequiredService(); var httpClientProvider = _serviceProvider.GetRequiredService(); var eventPublisher = _serviceProvider.GetRequiredService(); - var blocklistProvider = _serviceProvider.GetRequiredService(); + var blocklistProvider = _serviceProvider.GetRequiredService(); var ruleEvaluator = _serviceProvider.GetRequiredService(); var ruleManager = _serviceProvider.GetRequiredService(); @@ -116,7 +116,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory var hardLinkFileService = _serviceProvider.GetRequiredService(); var httpClientProvider = _serviceProvider.GetRequiredService(); var eventPublisher = _serviceProvider.GetRequiredService(); - var blocklistProvider = _serviceProvider.GetRequiredService(); + var blocklistProvider = _serviceProvider.GetRequiredService(); var ruleEvaluator = _serviceProvider.GetRequiredService(); var ruleManager = _serviceProvider.GetRequiredService(); @@ -140,7 +140,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory var hardLinkFileService = _serviceProvider.GetRequiredService(); var httpClientProvider = _serviceProvider.GetRequiredService(); var eventPublisher = _serviceProvider.GetRequiredService(); - var blocklistProvider = _serviceProvider.GetRequiredService(); + var blocklistProvider = _serviceProvider.GetRequiredService(); var loggerFactory = _serviceProvider.GetRequiredService(); var ruleEvaluator = _serviceProvider.GetRequiredService(); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs index 20069ec4..e6307e12 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs @@ -28,7 +28,7 @@ public partial class QBitService : DownloadService, IQBitService IHardLinkFileService hardLinkFileService, IDynamicHttpClientProvider httpClientProvider, IEventPublisher eventPublisher, - BlocklistProvider blocklistProvider, + IBlocklistProvider blocklistProvider, DownloadClientConfig downloadClientConfig, IRuleEvaluator ruleEvaluator, IRuleManager ruleManager @@ -51,7 +51,7 @@ public partial class QBitService : DownloadService, IQBitService IHardLinkFileService hardLinkFileService, IDynamicHttpClientProvider httpClientProvider, IEventPublisher eventPublisher, - BlocklistProvider blocklistProvider, + IBlocklistProvider blocklistProvider, DownloadClientConfig downloadClientConfig, IRuleEvaluator ruleEvaluator, IRuleManager ruleManager, diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs index a3692be1..dba22f34 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionService.cs @@ -46,7 +46,7 @@ public partial class TransmissionService : DownloadService, ITransmissionService IHardLinkFileService hardLinkFileService, IDynamicHttpClientProvider httpClientProvider, IEventPublisher eventPublisher, - BlocklistProvider blocklistProvider, + IBlocklistProvider blocklistProvider, DownloadClientConfig downloadClientConfig, IRuleEvaluator ruleEvaluator, IRuleManager ruleManager @@ -77,7 +77,7 @@ public partial class TransmissionService : DownloadService, ITransmissionService IHardLinkFileService hardLinkFileService, IDynamicHttpClientProvider httpClientProvider, IEventPublisher eventPublisher, - BlocklistProvider blocklistProvider, + IBlocklistProvider blocklistProvider, DownloadClientConfig downloadClientConfig, IRuleEvaluator ruleEvaluator, IRuleManager ruleManager, diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs index a6708fd4..04fc43f3 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentService.cs @@ -29,7 +29,7 @@ public partial class UTorrentService : DownloadService, IUTorrentService IHardLinkFileService hardLinkFileService, IDynamicHttpClientProvider httpClientProvider, IEventPublisher eventPublisher, - BlocklistProvider blocklistProvider, + IBlocklistProvider blocklistProvider, DownloadClientConfig downloadClientConfig, ILoggerFactory loggerFactory, IRuleEvaluator ruleEvaluator, @@ -70,7 +70,7 @@ public partial class UTorrentService : DownloadService, IUTorrentService IHardLinkFileService hardLinkFileService, IDynamicHttpClientProvider httpClientProvider, IEventPublisher eventPublisher, - BlocklistProvider blocklistProvider, + IBlocklistProvider blocklistProvider, DownloadClientConfig downloadClientConfig, IRuleEvaluator ruleEvaluator, IRuleManager ruleManager, diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs index d0d73159..72d0ef01 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationConfigurationService.cs @@ -86,6 +86,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio .Include(p => p.NotifiarrConfiguration) .Include(p => p.AppriseConfiguration) .Include(p => p.NtfyConfiguration) + .Include(p => p.PushoverConfiguration) .AsNoTracking() .ToListAsync(); @@ -132,10 +133,11 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio var configuration = config.Type switch { - NotificationProviderType.Notifiarr => config.NotifiarrConfiguration, + NotificationProviderType.Notifiarr => config.NotifiarrConfiguration as object, NotificationProviderType.Apprise => config.AppriseConfiguration, NotificationProviderType.Ntfy => config.NtfyConfiguration, - _ => new object() + NotificationProviderType.Pushover => config.PushoverConfiguration, + _ => throw new ArgumentOutOfRangeException(nameof(config), $"Config type for provider type {config.Type.ToString()} is not registered") }; return new NotificationProviderDto @@ -160,7 +162,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio NotificationEventType.DownloadCleaned => events.OnDownloadCleaned, NotificationEventType.CategoryChanged => events.OnCategoryChanged, NotificationEventType.Test => true, - _ => false + _ => throw new ArgumentOutOfRangeException(nameof(eventType), $"Provider type {eventType} is not yet registered") }; } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs index 61122973..c4922444 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/NotificationProviderFactory.cs @@ -3,6 +3,7 @@ using Cleanuparr.Infrastructure.Features.Notifications.Apprise; using Cleanuparr.Infrastructure.Features.Notifications.Models; using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr; using Cleanuparr.Infrastructure.Features.Notifications.Ntfy; +using Cleanuparr.Infrastructure.Features.Notifications.Pushover; using Cleanuparr.Persistence.Models.Configuration.Notification; using Microsoft.Extensions.DependencyInjection; @@ -24,6 +25,7 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory NotificationProviderType.Notifiarr => CreateNotifiarrProvider(config), NotificationProviderType.Apprise => CreateAppriseProvider(config), NotificationProviderType.Ntfy => CreateNtfyProvider(config), + NotificationProviderType.Pushover => CreatePushoverProvider(config), _ => throw new NotSupportedException($"Provider type {config.Type} is not supported") }; } @@ -48,7 +50,15 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory { var ntfyConfig = (NtfyConfig)config.Configuration; var proxy = _serviceProvider.GetRequiredService(); - + return new NtfyProvider(config.Name, config.Type, ntfyConfig, proxy); } + + private INotificationProvider CreatePushoverProvider(NotificationProviderDto config) + { + var pushoverConfig = (PushoverConfig)config.Configuration; + var proxy = _serviceProvider.GetRequiredService(); + + return new PushoverProvider(config.Name, config.Type, pushoverConfig, proxy); + } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/IPushoverProxy.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/IPushoverProxy.cs new file mode 100644 index 00000000..4ca2e011 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/IPushoverProxy.cs @@ -0,0 +1,6 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Pushover; + +public interface IPushoverProxy +{ + Task SendNotification(PushoverPayload payload); +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverException.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverException.cs new file mode 100644 index 00000000..0089abfd --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverException.cs @@ -0,0 +1,12 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Pushover; + +public sealed class PushoverException : Exception +{ + public PushoverException(string message) : base(message) + { + } + + public PushoverException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverPayload.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverPayload.cs new file mode 100644 index 00000000..7f22a6cc --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverPayload.cs @@ -0,0 +1,54 @@ +namespace Cleanuparr.Infrastructure.Features.Notifications.Pushover; + +public sealed record PushoverPayload +{ + /// + /// Application API token (required) + /// + public string Token { get; init; } = string.Empty; + + /// + /// User/group key (required) + /// + public string User { get; init; } = string.Empty; + + /// + /// Message body (required, max 1024 chars) + /// + public string Message { get; init; } = string.Empty; + + /// + /// Message title (optional, max 250 chars) + /// + public string? Title { get; init; } + + /// + /// Target devices (comma-separated) + /// + public string? Device { get; init; } + + /// + /// Priority level (-2 to 2) + /// + public int Priority { get; init; } + + /// + /// Notification sound + /// + public string? Sound { get; init; } + + /// + /// Retry interval for emergency priority (min 30 seconds) + /// + public int? Retry { get; init; } + + /// + /// Expiration for emergency priority (max 10800 seconds) + /// + public int? Expire { get; init; } + + /// + /// Tags for receipt tracking + /// + public string? Tags { get; init; } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverProvider.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverProvider.cs new file mode 100644 index 00000000..efff20a0 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverProvider.cs @@ -0,0 +1,99 @@ +using System.Text; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Notifications.Models; +using Cleanuparr.Persistence.Models.Configuration.Notification; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Pushover; + +public sealed class PushoverProvider : NotificationProviderBase +{ + private readonly IPushoverProxy _proxy; + + public PushoverProvider( + string name, + NotificationProviderType type, + PushoverConfig config, + IPushoverProxy proxy + ) : base(name, type, config) + { + _proxy = proxy; + } + + public override async Task SendNotificationAsync(NotificationContext context) + { + var payload = BuildPayload(context); + await _proxy.SendNotification(payload); + } + + private PushoverPayload BuildPayload(NotificationContext context) + { + string message = BuildMessage(context); + + // Truncate message to 1024 chars if needed + if (message.Length > 1024) + { + message = message[..1021] + "..."; + } + + return new PushoverPayload + { + Token = Config.ApiToken, + User = Config.UserKey, + Message = message, + Title = TruncateTitle(context.Title), + Device = GetDevicesString(), + Priority = (int)Config.Priority, + Sound = Config.Sound, + Retry = Config.Priority == PushoverPriority.Emergency ? Config.Retry : null, + Expire = Config.Priority == PushoverPriority.Emergency ? Config.Expire : null, + Tags = GetTagsString() + }; + } + + private static string BuildMessage(NotificationContext context) + { + StringBuilder message = new(); + message.AppendLine(context.Description); + + if (context.Data.Any()) + { + message.AppendLine(); + foreach ((string key, string value) in context.Data) + { + message.AppendLine($"{key}: {value}"); + } + } + + return message.ToString().Trim(); + } + + private static string? TruncateTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return null; + } + + return title.Length > 250 ? title[..247] + "..." : title; + } + + private string? GetDevicesString() + { + string[] devices = Config.Devices + .Where(d => !string.IsNullOrWhiteSpace(d)) + .Select(d => d.Trim()) + .ToArray(); + + return devices.Length > 0 ? string.Join(",", devices) : null; + } + + private string? GetTagsString() + { + string[] tags = Config.Tags + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t.Trim()) + .ToArray(); + + return tags.Length > 0 ? string.Join(",", tags) : null; + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverProxy.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverProxy.cs new file mode 100644 index 00000000..a5ebba53 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverProxy.cs @@ -0,0 +1,79 @@ +using System.Net; +using Cleanuparr.Shared.Helpers; +using Newtonsoft.Json; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Pushover; + +public sealed class PushoverProxy : IPushoverProxy +{ + private const string ApiUrl = "https://api.pushover.net/1/messages.json"; + private readonly HttpClient _httpClient; + + public PushoverProxy(IHttpClientFactory httpClientFactory) + { + _httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName); + } + + public async Task SendNotification(PushoverPayload payload) + { + try + { + var formData = new Dictionary + { + ["token"] = payload.Token, + ["user"] = payload.User, + ["message"] = payload.Message, + ["priority"] = payload.Priority.ToString() + }; + + if (!string.IsNullOrWhiteSpace(payload.Title)) + formData["title"] = payload.Title; + + if (!string.IsNullOrWhiteSpace(payload.Device)) + formData["device"] = payload.Device; + + if (!string.IsNullOrWhiteSpace(payload.Sound)) + formData["sound"] = payload.Sound; + + // Emergency priority requires retry and expire + if (payload.Priority == 2) + { + if (payload.Retry.HasValue) + formData["retry"] = payload.Retry.Value.ToString(); + if (payload.Expire.HasValue) + formData["expire"] = payload.Expire.Value.ToString(); + } + + if (!string.IsNullOrWhiteSpace(payload.Tags)) + formData["tags"] = payload.Tags; + + using var content = new FormUrlEncodedContent(formData); + using var response = await _httpClient.PostAsync(ApiUrl, content); + + var responseBody = await response.Content.ReadAsStringAsync(); + var pushoverResponse = JsonConvert.DeserializeObject(responseBody); + + if (!response.IsSuccessStatusCode || pushoverResponse?.IsSuccess != true) + { + var errorMessage = pushoverResponse?.Errors?.FirstOrDefault() + ?? $"Pushover API error: {response.StatusCode}"; + + throw response.StatusCode switch + { + HttpStatusCode.BadRequest => new PushoverException($"Bad request: {errorMessage}"), + HttpStatusCode.Unauthorized => new PushoverException("Invalid API token or user key"), + (HttpStatusCode)429 => new PushoverException("Rate limit exceeded - monthly quota reached"), + _ => new PushoverException($"Failed to send notification: {errorMessage}") + }; + } + } + catch (PushoverException) + { + throw; + } + catch (HttpRequestException ex) + { + throw new PushoverException("Unable to connect to Pushover API", ex); + } + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverResponse.cs b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverResponse.cs new file mode 100644 index 00000000..1c9bb055 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/Notifications/Pushover/PushoverResponse.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace Cleanuparr.Infrastructure.Features.Notifications.Pushover; + +public sealed record PushoverResponse +{ + [JsonProperty("status")] + public int Status { get; init; } + + [JsonProperty("request")] + public string? Request { get; init; } + + [JsonProperty("receipt")] + public string? Receipt { get; init; } + + [JsonProperty("errors")] + public List? Errors { get; init; } + + public bool IsSuccess => Status == 1; +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Cleanuparr.Persistence.Tests.csproj b/code/backend/Cleanuparr.Persistence.Tests/Cleanuparr.Persistence.Tests.csproj new file mode 100644 index 00000000..61a9b443 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Cleanuparr.Persistence.Tests.csproj @@ -0,0 +1,31 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/code/backend/Cleanuparr.Persistence.Tests/Converters/LowercaseEnumConverterTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Converters/LowercaseEnumConverterTests.cs new file mode 100644 index 00000000..9ad6485f --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Converters/LowercaseEnumConverterTests.cs @@ -0,0 +1,192 @@ +using Cleanuparr.Persistence.Converters; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Persistence.Tests.Converters; + +public sealed class LowercaseEnumConverterTests +{ + public enum TestEnum + { + FirstValue, + SecondValue, + ALLCAPS, + lowercase, + MixedCase + } + + [Flags] + public enum TestFlagsEnum + { + None = 0, + Flag1 = 1, + Flag2 = 2, + Flag3 = 4 + } + + private readonly LowercaseEnumConverter _converter = new(); + + #region ConvertToProvider - Enum to String + + [Fact] + public void ConvertToProvider_WithPascalCaseValue_ReturnsLowercaseString() + { + var result = (string?)_converter.ConvertToProvider(TestEnum.FirstValue); + + result.ShouldBe("firstvalue"); + } + + [Fact] + public void ConvertToProvider_WithAllCapsValue_ReturnsLowercaseString() + { + var result = (string?)_converter.ConvertToProvider(TestEnum.ALLCAPS); + + result.ShouldBe("allcaps"); + } + + [Fact] + public void ConvertToProvider_WithLowercaseValue_ReturnsLowercaseString() + { + var result = (string?)_converter.ConvertToProvider(TestEnum.lowercase); + + result.ShouldBe("lowercase"); + } + + [Fact] + public void ConvertToProvider_WithMixedCaseValue_ReturnsLowercaseString() + { + var result = (string?)_converter.ConvertToProvider(TestEnum.MixedCase); + + result.ShouldBe("mixedcase"); + } + + [Theory] + [InlineData(TestEnum.FirstValue, "firstvalue")] + [InlineData(TestEnum.SecondValue, "secondvalue")] + [InlineData(TestEnum.ALLCAPS, "allcaps")] + [InlineData(TestEnum.lowercase, "lowercase")] + [InlineData(TestEnum.MixedCase, "mixedcase")] + public void ConvertToProvider_WithVariousValues_ReturnsExpectedLowercaseString(TestEnum input, string expected) + { + var result = (string?)_converter.ConvertToProvider(input); + + result.ShouldBe(expected); + } + + #endregion + + #region ConvertFromProvider - String to Enum + + [Fact] + public void ConvertFromProvider_WithLowercaseString_ReturnsEnumValue() + { + var result = (TestEnum?)_converter.ConvertFromProvider("firstvalue"); + + result.ShouldBe(TestEnum.FirstValue); + } + + [Fact] + public void ConvertFromProvider_WithUppercaseString_ReturnsEnumValue() + { + var result = (TestEnum?)_converter.ConvertFromProvider("FIRSTVALUE"); + + result.ShouldBe(TestEnum.FirstValue); + } + + [Fact] + public void ConvertFromProvider_WithMixedCaseString_ReturnsEnumValue() + { + var result = (TestEnum?)_converter.ConvertFromProvider("FirstValue"); + + result.ShouldBe(TestEnum.FirstValue); + } + + [Fact] + public void ConvertFromProvider_WithOriginalEnumName_ReturnsEnumValue() + { + var result = (TestEnum?)_converter.ConvertFromProvider("ALLCAPS"); + + result.ShouldBe(TestEnum.ALLCAPS); + } + + [Theory] + [InlineData("firstvalue", TestEnum.FirstValue)] + [InlineData("FIRSTVALUE", TestEnum.FirstValue)] + [InlineData("FirstValue", TestEnum.FirstValue)] + [InlineData("secondvalue", TestEnum.SecondValue)] + [InlineData("SECONDVALUE", TestEnum.SecondValue)] + [InlineData("allcaps", TestEnum.ALLCAPS)] + [InlineData("ALLCAPS", TestEnum.ALLCAPS)] + public void ConvertFromProvider_WithVariousCasings_ReturnsExpectedEnumValue(string input, TestEnum expected) + { + var result = (TestEnum?)_converter.ConvertFromProvider(input); + + result.ShouldBe(expected); + } + + [Fact] + public void ConvertFromProvider_WithInvalidString_ThrowsArgumentException() + { + Should.Throw(() => _converter.ConvertFromProvider("nonexistent")); + } + + [Fact] + public void ConvertFromProvider_WithEmptyString_ThrowsArgumentException() + { + Should.Throw(() => _converter.ConvertFromProvider(string.Empty)); + } + + #endregion + + #region Roundtrip Tests + + [Theory] + [InlineData(TestEnum.FirstValue)] + [InlineData(TestEnum.SecondValue)] + [InlineData(TestEnum.ALLCAPS)] + [InlineData(TestEnum.lowercase)] + [InlineData(TestEnum.MixedCase)] + public void Roundtrip_ConvertToProviderThenBack_ReturnsOriginalValue(TestEnum original) + { + var providerValue = (string?)_converter.ConvertToProvider(original); + var result = (TestEnum?)_converter.ConvertFromProvider(providerValue!); + + result.ShouldBe(original); + } + + #endregion + + #region Flags Enum Tests + + [Fact] + public void ConvertToProvider_WithFlagsEnum_ConvertsSingleFlag() + { + var flagsConverter = new LowercaseEnumConverter(); + + var result = (string?)flagsConverter.ConvertToProvider(TestFlagsEnum.Flag1); + + result.ShouldBe("flag1"); + } + + [Fact] + public void ConvertToProvider_WithFlagsEnum_ConvertsNone() + { + var flagsConverter = new LowercaseEnumConverter(); + + var result = (string?)flagsConverter.ConvertToProvider(TestFlagsEnum.None); + + result.ShouldBe("none"); + } + + [Fact] + public void ConvertFromProvider_WithFlagsEnum_ParsesSingleFlag() + { + var flagsConverter = new LowercaseEnumConverter(); + + var result = (TestFlagsEnum?)flagsConverter.ConvertFromProvider("flag2"); + + result.ShouldBe(TestFlagsEnum.Flag2); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Converters/UtcDateTimeConverterTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Converters/UtcDateTimeConverterTests.cs new file mode 100644 index 00000000..f418e001 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Converters/UtcDateTimeConverterTests.cs @@ -0,0 +1,200 @@ +using Cleanuparr.Persistence.Converters; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Persistence.Tests.Converters; + +public sealed class UtcDateTimeConverterTests +{ + private readonly UtcDateTimeConverter _converter = new(); + + #region ConvertToProvider - DateTime to Database + + [Fact] + public void ConvertToProvider_WithUtcDateTime_ReturnsSameValue() + { + var utcDateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc); + + var result = (DateTime?)_converter.ConvertToProvider(utcDateTime); + + result.ShouldBe(utcDateTime); + } + + [Fact] + public void ConvertToProvider_WithLocalDateTime_ReturnsSameValue() + { + var localDateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Local); + + var result = (DateTime?)_converter.ConvertToProvider(localDateTime); + + result.ShouldBe(localDateTime); + } + + [Fact] + public void ConvertToProvider_WithUnspecifiedDateTime_ReturnsSameValue() + { + var unspecifiedDateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Unspecified); + + var result = (DateTime?)_converter.ConvertToProvider(unspecifiedDateTime); + + result.ShouldBe(unspecifiedDateTime); + } + + [Fact] + public void ConvertToProvider_PreservesDateTimeKind() + { + var localDateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Local); + + var result = (DateTime?)_converter.ConvertToProvider(localDateTime); + + result!.Value.Kind.ShouldBe(DateTimeKind.Local); + } + + [Fact] + public void ConvertToProvider_PreservesAllComponents() + { + var dateTime = new DateTime(2024, 6, 15, 10, 30, 45, 123, DateTimeKind.Utc); + + var result = (DateTime?)_converter.ConvertToProvider(dateTime); + + result!.Value.Year.ShouldBe(2024); + result.Value.Month.ShouldBe(6); + result.Value.Day.ShouldBe(15); + result.Value.Hour.ShouldBe(10); + result.Value.Minute.ShouldBe(30); + result.Value.Second.ShouldBe(45); + result.Value.Millisecond.ShouldBe(123); + } + + #endregion + + #region ConvertFromProvider - Database to DateTime + + [Fact] + public void ConvertFromProvider_WithUnspecifiedDateTime_ReturnsUtcKind() + { + var unspecifiedDateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Unspecified); + + var result = (DateTime?)_converter.ConvertFromProvider(unspecifiedDateTime); + + result!.Value.Kind.ShouldBe(DateTimeKind.Utc); + } + + [Fact] + public void ConvertFromProvider_WithUtcDateTime_ReturnsUtcKind() + { + var utcDateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Utc); + + var result = (DateTime?)_converter.ConvertFromProvider(utcDateTime); + + result!.Value.Kind.ShouldBe(DateTimeKind.Utc); + } + + [Fact] + public void ConvertFromProvider_WithLocalDateTime_ForcesUtcKind() + { + // Note: DateTime.SpecifyKind does NOT convert the time, just changes the Kind + var localDateTime = new DateTime(2024, 6, 15, 10, 30, 0, DateTimeKind.Local); + + var result = (DateTime?)_converter.ConvertFromProvider(localDateTime); + + result!.Value.Kind.ShouldBe(DateTimeKind.Utc); + // Time components remain the same (no conversion) + result.Value.Hour.ShouldBe(10); + result.Value.Minute.ShouldBe(30); + } + + [Fact] + public void ConvertFromProvider_PreservesAllComponents() + { + var dateTime = new DateTime(2024, 6, 15, 10, 30, 45, 123, DateTimeKind.Unspecified); + + var result = (DateTime?)_converter.ConvertFromProvider(dateTime); + + result!.Value.Year.ShouldBe(2024); + result.Value.Month.ShouldBe(6); + result.Value.Day.ShouldBe(15); + result.Value.Hour.ShouldBe(10); + result.Value.Minute.ShouldBe(30); + result.Value.Second.ShouldBe(45); + result.Value.Millisecond.ShouldBe(123); + } + + [Fact] + public void ConvertFromProvider_WithMinValue_ReturnsUtcKind() + { + var result = (DateTime?)_converter.ConvertFromProvider(DateTime.MinValue); + + result!.Value.Kind.ShouldBe(DateTimeKind.Utc); + result.ShouldBe(DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc)); + } + + [Fact] + public void ConvertFromProvider_WithMaxValue_ReturnsUtcKind() + { + var result = (DateTime?)_converter.ConvertFromProvider(DateTime.MaxValue); + + result!.Value.Kind.ShouldBe(DateTimeKind.Utc); + result.ShouldBe(DateTime.SpecifyKind(DateTime.MaxValue, DateTimeKind.Utc)); + } + + #endregion + + #region Roundtrip Tests + + [Fact] + public void Roundtrip_WithUtcDateTime_PreservesValue() + { + var original = new DateTime(2024, 6, 15, 10, 30, 45, DateTimeKind.Utc); + + var providerValue = (DateTime?)_converter.ConvertToProvider(original); + var result = (DateTime?)_converter.ConvertFromProvider(providerValue!.Value); + + result.ShouldBe(original); + result!.Value.Kind.ShouldBe(DateTimeKind.Utc); + } + + [Fact] + public void Roundtrip_WithUnspecifiedDateTime_EndsUpAsUtc() + { + var original = new DateTime(2024, 6, 15, 10, 30, 45, DateTimeKind.Unspecified); + + var providerValue = (DateTime?)_converter.ConvertToProvider(original); + var result = (DateTime?)_converter.ConvertFromProvider(providerValue!.Value); + + result!.Value.Ticks.ShouldBe(original.Ticks); + result.Value.Kind.ShouldBe(DateTimeKind.Utc); + } + + #endregion + + #region Edge Cases + + [Theory] + [InlineData(DateTimeKind.Utc)] + [InlineData(DateTimeKind.Local)] + [InlineData(DateTimeKind.Unspecified)] + public void ConvertFromProvider_AlwaysReturnsUtcKind(DateTimeKind inputKind) + { + var dateTime = new DateTime(2024, 6, 15, 10, 30, 0, inputKind); + + var result = (DateTime?)_converter.ConvertFromProvider(dateTime); + + result!.Value.Kind.ShouldBe(DateTimeKind.Utc); + } + + [Fact] + public void ConvertFromProvider_DoesNotConvertTime() + { + // This is important to understand - SpecifyKind does NOT convert time zones + // It just changes the metadata about what time zone the DateTime represents + var dateTime = new DateTime(2024, 6, 15, 15, 0, 0, DateTimeKind.Local); + + var result = (DateTime?)_converter.ConvertFromProvider(dateTime); + + // The hour should still be 15, not converted to UTC + result!.Value.Hour.ShouldBe(15); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/BlacklistSync/BlacklistSyncConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/BlacklistSync/BlacklistSyncConfigTests.cs new file mode 100644 index 00000000..4c9b2c5d --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/BlacklistSync/BlacklistSyncConfigTests.cs @@ -0,0 +1,157 @@ +using Cleanuparr.Persistence.Models.Configuration.BlacklistSync; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.BlacklistSync; + +public sealed class BlacklistSyncConfigTests +{ + #region Validate - Disabled Config + + [Fact] + public void Validate_WhenDisabled_DoesNotThrow() + { + var config = new BlacklistSyncConfig + { + Enabled = false, + BlacklistPath = null + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WhenDisabledWithEmptyPath_DoesNotThrow() + { + var config = new BlacklistSyncConfig + { + Enabled = false, + BlacklistPath = "" + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - Enabled Config Path Validation + + [Fact] + public void Validate_WhenEnabledWithNullPath_ThrowsValidationException() + { + var config = new BlacklistSyncConfig + { + Enabled = true, + BlacklistPath = null + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Blacklist sync is enabled but the path is not configured"); + } + + [Fact] + public void Validate_WhenEnabledWithEmptyPath_ThrowsValidationException() + { + var config = new BlacklistSyncConfig + { + Enabled = true, + BlacklistPath = "" + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Blacklist sync is enabled but the path is not configured"); + } + + [Fact] + public void Validate_WhenEnabledWithWhitespacePath_ThrowsValidationException() + { + var config = new BlacklistSyncConfig + { + Enabled = true, + BlacklistPath = " " + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Blacklist sync is enabled but the path is not configured"); + } + + #endregion + + #region Validate - URL Paths + + [Theory] + [InlineData("http://example.com/blacklist.txt")] + [InlineData("https://example.com/blacklist.txt")] + [InlineData("http://localhost:8080/api/blacklist")] + [InlineData("https://raw.githubusercontent.com/user/repo/main/blacklist.txt")] + public void Validate_WhenEnabledWithValidHttpUrl_DoesNotThrow(string url) + { + var config = new BlacklistSyncConfig + { + Enabled = true, + BlacklistPath = url + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - Invalid Paths + + [Fact] + public void Validate_WhenEnabledWithNonExistentFilePath_ThrowsValidationException() + { + var config = new BlacklistSyncConfig + { + Enabled = true, + BlacklistPath = "/non/existent/path/blacklist.txt" + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Blacklist path must be a valid URL or an existing local file path"); + } + + [Fact] + public void Validate_WhenEnabledWithInvalidPath_ThrowsValidationException() + { + var config = new BlacklistSyncConfig + { + Enabled = true, + BlacklistPath = "not-a-valid-url-or-path" + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Blacklist path must be a valid URL or an existing local file path"); + } + + [Theory] + [InlineData("ftp://example.com/blacklist.txt")] + [InlineData("file:///path/to/file")] + public void Validate_WhenEnabledWithNonHttpUrl_ThrowsValidationException(string url) + { + var config = new BlacklistSyncConfig + { + Enabled = true, + BlacklistPath = url + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Blacklist path must be a valid URL or an existing local file path"); + } + + #endregion + + #region CronExpression Default + + [Fact] + public void CronExpression_HasDefaultValue() + { + var config = new BlacklistSyncConfig(); + + config.CronExpression.ShouldBe("0 0 * * * ?"); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/CleanCategoryTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/CleanCategoryTests.cs new file mode 100644 index 00000000..a9914f49 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/CleanCategoryTests.cs @@ -0,0 +1,234 @@ +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.DownloadCleaner; + +public sealed class CleanCategoryTests +{ + #region Validate - Valid Configurations + + [Fact] + public void Validate_WithValidMaxRatio_DoesNotThrow() + { + var config = new CleanCategory + { + Name = "test-category", + MaxRatio = 2.0, + MinSeedTime = 0, + MaxSeedTime = -1 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithValidMaxSeedTime_DoesNotThrow() + { + var config = new CleanCategory + { + Name = "test-category", + MaxRatio = -1, + MinSeedTime = 0, + MaxSeedTime = 24 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithBothMaxRatioAndMaxSeedTime_DoesNotThrow() + { + var config = new CleanCategory + { + Name = "test-category", + MaxRatio = 2.0, + MinSeedTime = 1, + MaxSeedTime = 48 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithZeroMaxRatio_DoesNotThrow() + { + var config = new CleanCategory + { + Name = "test-category", + MaxRatio = 0, + MinSeedTime = 0, + MaxSeedTime = -1 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithZeroMaxSeedTime_DoesNotThrow() + { + var config = new CleanCategory + { + Name = "test-category", + MaxRatio = -1, + MinSeedTime = 0, + MaxSeedTime = 0 + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - Name Validation + + [Fact] + public void Validate_WithEmptyName_ThrowsValidationException() + { + var config = new CleanCategory + { + Name = "", + MaxRatio = 2.0, + MinSeedTime = 0, + MaxSeedTime = -1 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Category name can not be empty"); + } + + [Fact] + public void Validate_WithWhitespaceName_ThrowsValidationException() + { + var config = new CleanCategory + { + Name = " ", + MaxRatio = 2.0, + MinSeedTime = 0, + MaxSeedTime = -1 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Category name can not be empty"); + } + + [Fact] + public void Validate_WithTabOnlyName_ThrowsValidationException() + { + var config = new CleanCategory + { + Name = "\t", + MaxRatio = 2.0, + MinSeedTime = 0, + MaxSeedTime = -1 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Category name can not be empty"); + } + + #endregion + + #region Validate - MaxRatio and MaxSeedTime Validation + + [Fact] + public void Validate_WithBothNegative_ThrowsValidationException() + { + var config = new CleanCategory + { + Name = "test-category", + MaxRatio = -1, + MinSeedTime = 0, + MaxSeedTime = -1 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Either max ratio or max seed time must be set to a non-negative value"); + } + + [Theory] + [InlineData(-1, -0.1)] + [InlineData(-0.5, -1)] + [InlineData(-100, -100)] + public void Validate_WithVariousNegativeValues_ThrowsValidationException(double maxRatio, double maxSeedTime) + { + var config = new CleanCategory + { + Name = "test-category", + MaxRatio = maxRatio, + MinSeedTime = 0, + MaxSeedTime = maxSeedTime + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Either max ratio or max seed time must be set to a non-negative value"); + } + + #endregion + + #region Validate - MinSeedTime Validation + + [Fact] + public void Validate_WithNegativeMinSeedTime_ThrowsValidationException() + { + var config = new CleanCategory + { + Name = "test-category", + MaxRatio = 2.0, + MinSeedTime = -1, + MaxSeedTime = -1 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Min seed time can not be negative"); + } + + [Theory] + [InlineData(-0.1)] + [InlineData(-1)] + [InlineData(-100)] + public void Validate_WithVariousNegativeMinSeedTime_ThrowsValidationException(double minSeedTime) + { + var config = new CleanCategory + { + Name = "test-category", + MaxRatio = 2.0, + MinSeedTime = minSeedTime, + MaxSeedTime = -1 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Min seed time can not be negative"); + } + + [Fact] + public void Validate_WithZeroMinSeedTime_DoesNotThrow() + { + var config = new CleanCategory + { + Name = "test-category", + MaxRatio = 2.0, + MinSeedTime = 0, + MaxSeedTime = -1 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithPositiveMinSeedTime_DoesNotThrow() + { + var config = new CleanCategory + { + Name = "test-category", + MaxRatio = 2.0, + MinSeedTime = 24, + MaxSeedTime = -1 + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/DownloadCleanerConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/DownloadCleanerConfigTests.cs new file mode 100644 index 00000000..5856417a --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadCleaner/DownloadCleanerConfigTests.cs @@ -0,0 +1,293 @@ +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.DownloadCleaner; + +public sealed class DownloadCleanerConfigTests +{ + #region Validate - Disabled Config + + [Fact] + public void Validate_WhenDisabled_DoesNotThrow() + { + var config = new DownloadCleanerConfig + { + Enabled = false + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WhenDisabledWithNoFeatures_DoesNotThrow() + { + var config = new DownloadCleanerConfig + { + Enabled = false, + Categories = [], + UnlinkedEnabled = false + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - No Features Configured + + [Fact] + public void Validate_WhenEnabledWithNoFeatures_ThrowsValidationException() + { + var config = new DownloadCleanerConfig + { + Enabled = true, + Categories = [], + UnlinkedEnabled = false + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("No features are enabled"); + } + + [Fact] + public void Validate_WhenEnabledWithUnlinkedEnabledButNoCategories_ThrowsValidationException() + { + var config = new DownloadCleanerConfig + { + Enabled = true, + Categories = [], + UnlinkedEnabled = true, + UnlinkedCategories = [], + UnlinkedTargetCategory = "target" + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("No features are enabled"); + } + + #endregion + + #region Validate - Categories Feature + + [Fact] + public void Validate_WhenEnabledWithValidCategories_DoesNotThrow() + { + var config = new DownloadCleanerConfig + { + Enabled = true, + Categories = + [ + new CleanCategory { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1 }, + new CleanCategory { Name = "tv", MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1 } + ], + UnlinkedEnabled = false + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WhenEnabledWithDuplicateCategoryNames_ThrowsValidationException() + { + var config = new DownloadCleanerConfig + { + Enabled = true, + Categories = + [ + new CleanCategory { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1 }, + new CleanCategory { Name = "movies", MaxRatio = 1.5, MinSeedTime = 24, MaxSeedTime = -1 } + ], + UnlinkedEnabled = false + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Duplicated clean categories found"); + } + + [Fact] + public void Validate_WhenEnabledWithInvalidCategory_ThrowsValidationException() + { + var config = new DownloadCleanerConfig + { + Enabled = true, + Categories = + [ + new CleanCategory { Name = "", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1 } + ], + UnlinkedEnabled = false + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Category name can not be empty"); + } + + #endregion + + #region Validate - Unlinked Feature + + [Fact] + public void Validate_WhenEnabledWithValidUnlinkedConfig_DoesNotThrow() + { + var config = new DownloadCleanerConfig + { + Enabled = true, + Categories = [], + UnlinkedEnabled = true, + UnlinkedTargetCategory = "cleanuparr-unlinked", + UnlinkedCategories = ["movies", "tv"] + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WhenUnlinkedEnabledWithEmptyTargetCategory_ThrowsValidationException() + { + // Need valid categories to pass the "no features enabled" check first + var config = new DownloadCleanerConfig + { + Enabled = true, + Categories = + [ + new CleanCategory { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1 } + ], + UnlinkedEnabled = true, + UnlinkedTargetCategory = "", + UnlinkedCategories = ["tv"] + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("unlinked target category is required"); + } + + [Fact] + public void Validate_WhenUnlinkedEnabledWithNoUnlinkedCategories_ThrowsValidationException() + { + // Need valid categories to pass the "no features enabled" check first + var config = new DownloadCleanerConfig + { + Enabled = true, + Categories = + [ + new CleanCategory { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1 } + ], + UnlinkedEnabled = true, + UnlinkedTargetCategory = "cleanuparr-unlinked", + UnlinkedCategories = [] + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("No unlinked categories configured"); + } + + [Fact] + public void Validate_WhenUnlinkedTargetCategoryInUnlinkedCategories_ThrowsValidationException() + { + var config = new DownloadCleanerConfig + { + Enabled = true, + Categories = [], + UnlinkedEnabled = true, + UnlinkedTargetCategory = "cleanuparr-unlinked", + UnlinkedCategories = ["movies", "cleanuparr-unlinked"] + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("The unlinked target category should not be present in unlinked categories"); + } + + [Fact] + public void Validate_WhenUnlinkedCategoriesContainsEmpty_ThrowsValidationException() + { + var config = new DownloadCleanerConfig + { + Enabled = true, + Categories = [], + UnlinkedEnabled = true, + UnlinkedTargetCategory = "cleanuparr-unlinked", + UnlinkedCategories = ["movies", ""] + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Empty unlinked category filter found"); + } + + [Fact] + public void Validate_WhenUnlinkedIgnoredRootDirDoesNotExist_ThrowsValidationException() + { + var config = new DownloadCleanerConfig + { + Enabled = true, + Categories = [], + UnlinkedEnabled = true, + UnlinkedTargetCategory = "cleanuparr-unlinked", + UnlinkedCategories = ["movies"], + UnlinkedIgnoredRootDir = "/non/existent/directory" + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldContain("root directory does not exist"); + } + + [Fact] + public void Validate_WhenUnlinkedIgnoredRootDirIsEmpty_DoesNotThrow() + { + var config = new DownloadCleanerConfig + { + Enabled = true, + Categories = [], + UnlinkedEnabled = true, + UnlinkedTargetCategory = "cleanuparr-unlinked", + UnlinkedCategories = ["movies"], + UnlinkedIgnoredRootDir = "" + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - Combined Features + + [Fact] + public void Validate_WhenBothFeaturesEnabled_DoesNotThrow() + { + var config = new DownloadCleanerConfig + { + Enabled = true, + Categories = + [ + new CleanCategory { Name = "movies", MaxRatio = 2.0, MinSeedTime = 0, MaxSeedTime = -1 } + ], + UnlinkedEnabled = true, + UnlinkedTargetCategory = "cleanuparr-unlinked", + UnlinkedCategories = ["tv"] + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Default Values + + [Fact] + public void CronExpression_HasDefaultValue() + { + var config = new DownloadCleanerConfig(); + + config.CronExpression.ShouldBe("0 0 * * * ?"); + } + + [Fact] + public void UnlinkedTargetCategory_HasDefaultValue() + { + var config = new DownloadCleanerConfig(); + + config.UnlinkedTargetCategory.ShouldBe("cleanuparr-unlinked"); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs new file mode 100644 index 00000000..8ba5761a --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs @@ -0,0 +1,189 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration; + +public sealed class DownloadClientConfigTests +{ + #region Validate - Valid Configurations + + [Fact] + public void Validate_WithValidConfig_DoesNotThrow() + { + var config = new DownloadClientConfig + { + Name = "My qBittorrent", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080") + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithHttpsHost_DoesNotThrow() + { + var config = new DownloadClientConfig + { + Name = "Remote Client", + TypeName = DownloadClientTypeName.Transmission, + Type = DownloadClientType.Torrent, + Host = new Uri("https://remote.example.com:9091") + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - Name Validation + + [Fact] + public void Validate_WithEmptyName_ThrowsValidationException() + { + var config = new DownloadClientConfig + { + Name = "", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080") + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Client name cannot be empty"); + } + + [Fact] + public void Validate_WithWhitespaceName_ThrowsValidationException() + { + var config = new DownloadClientConfig + { + Name = " ", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080") + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Client name cannot be empty"); + } + + [Fact] + public void Validate_WithTabOnlyName_ThrowsValidationException() + { + var config = new DownloadClientConfig + { + Name = "\t", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080") + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Client name cannot be empty"); + } + + #endregion + + #region Validate - Host Validation + + [Fact] + public void Validate_WithNullHost_ThrowsValidationException() + { + var config = new DownloadClientConfig + { + Name = "My Client", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = null + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Host cannot be empty"); + } + + #endregion + + #region Url Property Tests + + [Fact] + public void Url_WithHostAndNoUrlBase_ReturnsHostWithTrailingSlash() + { + var config = new DownloadClientConfig + { + Name = "My Client", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + UrlBase = null + }; + + config.Url.ToString().ShouldBe("http://localhost:8080/"); + } + + [Fact] + public void Url_WithHostAndUrlBase_ReturnsCombinedUrl() + { + var config = new DownloadClientConfig + { + Name = "My Client", + TypeName = DownloadClientTypeName.Transmission, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:9091"), + UrlBase = "transmission/rpc" + }; + + config.Url.ToString().ShouldBe("http://localhost:9091/transmission/rpc"); + } + + [Fact] + public void Url_WithUrlBaseWithLeadingSlash_TrimsLeadingSlash() + { + var config = new DownloadClientConfig + { + Name = "My Client", + TypeName = DownloadClientTypeName.Deluge, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8112"), + UrlBase = "/json" + }; + + config.Url.ToString().ShouldBe("http://localhost:8112/json"); + } + + [Fact] + public void Url_WithUrlBaseWithTrailingSlash_TrimsTrailingSlash() + { + var config = new DownloadClientConfig + { + Name = "My Client", + TypeName = DownloadClientTypeName.Transmission, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:9091"), + UrlBase = "transmission/rpc/" + }; + + config.Url.ToString().ShouldBe("http://localhost:9091/transmission/rpc"); + } + + [Fact] + public void Url_WithHostTrailingSlash_HandlesCorrectly() + { + var config = new DownloadClientConfig + { + Name = "My Client", + TypeName = DownloadClientTypeName.Transmission, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:9091/"), + UrlBase = "transmission/rpc" + }; + + config.Url.ToString().ShouldBe("http://localhost:9091/transmission/rpc"); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/GeneralConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/GeneralConfigTests.cs new file mode 100644 index 00000000..f6d8a4ea --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/GeneralConfigTests.cs @@ -0,0 +1,146 @@ +using Cleanuparr.Persistence.Models.Configuration.General; +using Cleanuparr.Shared.Helpers; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.General; + +public sealed class GeneralConfigTests +{ + #region Validate - Valid Configurations + + [Fact] + public void Validate_WithDefaultConfig_DoesNotThrow() + { + var config = new GeneralConfig(); + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithMinimumSearchDelay_DoesNotThrow() + { + var config = new GeneralConfig + { + HttpTimeout = 100, + SearchDelay = (ushort)Constants.MinSearchDelaySeconds + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithAboveMinimumSearchDelay_DoesNotThrow() + { + var config = new GeneralConfig + { + HttpTimeout = 100, + SearchDelay = 300 + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - HttpTimeout Validation + + [Fact] + public void Validate_WithZeroHttpTimeout_ThrowsValidationException() + { + var config = new GeneralConfig + { + HttpTimeout = 0, + SearchDelay = (ushort)Constants.MinSearchDelaySeconds + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("HttpTimeout must be greater than 0"); + } + + [Theory] + [InlineData((ushort)1)] + [InlineData((ushort)50)] + [InlineData((ushort)100)] + [InlineData((ushort)65535)] + public void Validate_WithPositiveHttpTimeout_DoesNotThrow(ushort httpTimeout) + { + var config = new GeneralConfig + { + HttpTimeout = httpTimeout, + SearchDelay = (ushort)Constants.MinSearchDelaySeconds + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - SearchDelay Validation + + [Fact] + public void Validate_WithBelowMinimumSearchDelay_ThrowsValidationException() + { + var config = new GeneralConfig + { + HttpTimeout = 100, + SearchDelay = (ushort)(Constants.MinSearchDelaySeconds - 1) + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe($"SearchDelay must be at least {Constants.MinSearchDelaySeconds} seconds"); + } + + [Fact] + public void Validate_WithZeroSearchDelay_ThrowsValidationException() + { + var config = new GeneralConfig + { + HttpTimeout = 100, + SearchDelay = 0 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe($"SearchDelay must be at least {Constants.MinSearchDelaySeconds} seconds"); + } + + [Theory] + [InlineData((ushort)1)] + [InlineData((ushort)30)] + [InlineData((ushort)59)] + public void Validate_WithVariousBelowMinimumSearchDelay_ThrowsValidationException(ushort searchDelay) + { + var config = new GeneralConfig + { + HttpTimeout = 100, + SearchDelay = searchDelay + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe($"SearchDelay must be at least {Constants.MinSearchDelaySeconds} seconds"); + } + + #endregion + + #region Validate - Calls LoggingConfig.Validate + + [Fact] + public void Validate_WithInvalidLoggingConfig_ThrowsValidationException() + { + var config = new GeneralConfig + { + HttpTimeout = 100, + SearchDelay = (ushort)Constants.MinSearchDelaySeconds, + Log = new LoggingConfig + { + RollingSizeMB = 101 // Exceeds max of 100 + } + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Log rolling size cannot exceed 100 MB"); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/LoggingConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/LoggingConfigTests.cs new file mode 100644 index 00000000..f46523c1 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/General/LoggingConfigTests.cs @@ -0,0 +1,267 @@ +using Cleanuparr.Persistence.Models.Configuration.General; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.General; + +public sealed class LoggingConfigTests +{ + #region Validate - Valid Configurations + + [Fact] + public void Validate_WithDefaultConfig_DoesNotThrow() + { + var config = new LoggingConfig(); + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithAllMaxValues_DoesNotThrow() + { + var config = new LoggingConfig + { + RollingSizeMB = 100, + RetainedFileCount = 50, + TimeLimitHours = 1440, + ArchiveEnabled = true, + ArchiveRetainedCount = 100, + ArchiveTimeLimitHours = 1440 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithArchiveDisabled_DoesNotRequireRetentionPolicy() + { + var config = new LoggingConfig + { + ArchiveEnabled = false, + ArchiveRetainedCount = 0, + ArchiveTimeLimitHours = 0 + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - RollingSizeMB Validation + + [Fact] + public void Validate_WithRollingSizeExceedingMax_ThrowsValidationException() + { + var config = new LoggingConfig + { + RollingSizeMB = 101 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Log rolling size cannot exceed 100 MB"); + } + + [Theory] + [InlineData((ushort)0)] + [InlineData((ushort)1)] + [InlineData((ushort)50)] + [InlineData((ushort)100)] + public void Validate_WithValidRollingSize_DoesNotThrow(ushort rollingSizeMB) + { + var config = new LoggingConfig + { + RollingSizeMB = rollingSizeMB, + ArchiveEnabled = false + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - RetainedFileCount Validation + + [Fact] + public void Validate_WithRetainedFileCountExceedingMax_ThrowsValidationException() + { + var config = new LoggingConfig + { + RetainedFileCount = 51 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Log retained file count cannot exceed 50"); + } + + [Theory] + [InlineData((ushort)0)] + [InlineData((ushort)1)] + [InlineData((ushort)25)] + [InlineData((ushort)50)] + public void Validate_WithValidRetainedFileCount_DoesNotThrow(ushort retainedFileCount) + { + var config = new LoggingConfig + { + RetainedFileCount = retainedFileCount, + ArchiveEnabled = false + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - TimeLimitHours Validation + + [Fact] + public void Validate_WithTimeLimitExceedingMax_ThrowsValidationException() + { + var config = new LoggingConfig + { + TimeLimitHours = 1441 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Log time limit cannot exceed 60 days"); + } + + [Theory] + [InlineData((ushort)0)] + [InlineData((ushort)24)] + [InlineData((ushort)720)] + [InlineData((ushort)1440)] + public void Validate_WithValidTimeLimitHours_DoesNotThrow(ushort timeLimitHours) + { + var config = new LoggingConfig + { + TimeLimitHours = timeLimitHours, + ArchiveEnabled = false + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - ArchiveRetainedCount Validation + + [Fact] + public void Validate_WithArchiveRetainedCountExceedingMax_ThrowsValidationException() + { + var config = new LoggingConfig + { + ArchiveRetainedCount = 101 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Log archive retained count cannot exceed 100"); + } + + [Theory] + [InlineData((ushort)1)] + [InlineData((ushort)50)] + [InlineData((ushort)100)] + public void Validate_WithValidArchiveRetainedCount_DoesNotThrow(ushort archiveRetainedCount) + { + var config = new LoggingConfig + { + ArchiveEnabled = true, + ArchiveRetainedCount = archiveRetainedCount, + ArchiveTimeLimitHours = 0 + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - ArchiveTimeLimitHours Validation + + [Fact] + public void Validate_WithArchiveTimeLimitExceedingMax_ThrowsValidationException() + { + var config = new LoggingConfig + { + ArchiveTimeLimitHours = 1441 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Log archive time limit cannot exceed 60 days"); + } + + [Theory] + [InlineData((ushort)1)] + [InlineData((ushort)720)] + [InlineData((ushort)1440)] + public void Validate_WithValidArchiveTimeLimitHours_DoesNotThrow(ushort archiveTimeLimitHours) + { + var config = new LoggingConfig + { + ArchiveEnabled = true, + ArchiveRetainedCount = 0, + ArchiveTimeLimitHours = archiveTimeLimitHours + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - Archive Retention Policy Validation + + [Fact] + public void Validate_WithArchiveEnabledAndNoRetentionPolicy_ThrowsValidationException() + { + var config = new LoggingConfig + { + ArchiveEnabled = true, + ArchiveRetainedCount = 0, + ArchiveTimeLimitHours = 0 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Archiving is enabled, but no retention policy is set. Please set either a retained file count or time limit"); + } + + [Fact] + public void Validate_WithArchiveEnabledAndOnlyRetainedCount_DoesNotThrow() + { + var config = new LoggingConfig + { + ArchiveEnabled = true, + ArchiveRetainedCount = 10, + ArchiveTimeLimitHours = 0 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithArchiveEnabledAndOnlyTimeLimitHours_DoesNotThrow() + { + var config = new LoggingConfig + { + ArchiveEnabled = true, + ArchiveRetainedCount = 0, + ArchiveTimeLimitHours = 720 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithArchiveEnabledAndBothRetentionPolicies_DoesNotThrow() + { + var config = new LoggingConfig + { + ArchiveEnabled = true, + ArchiveRetainedCount = 10, + ArchiveTimeLimitHours = 720 + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/MalwareBlocker/ContentBlockerConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/MalwareBlocker/ContentBlockerConfigTests.cs new file mode 100644 index 00000000..d0542df3 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/MalwareBlocker/ContentBlockerConfigTests.cs @@ -0,0 +1,285 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; +using Shouldly; +using Xunit; +using ValidationException = System.ComponentModel.DataAnnotations.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.MalwareBlocker; + +public sealed class ContentBlockerConfigTests +{ + #region Validate - Disabled Config + + [Fact] + public void Validate_WhenDisabled_DoesNotThrow() + { + var config = new ContentBlockerConfig + { + Enabled = false + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WhenDisabledWithNoBlocklists_DoesNotThrow() + { + var config = new ContentBlockerConfig + { + Enabled = false, + Sonarr = new BlocklistSettings { Enabled = false }, + Radarr = new BlocklistSettings { Enabled = false }, + Lidarr = new BlocklistSettings { Enabled = false }, + Readarr = new BlocklistSettings { Enabled = false }, + Whisparr = new BlocklistSettings { Enabled = false } + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - No Blocklists Enabled + + [Fact] + public void Validate_WhenEnabledWithNoBlocklists_ThrowsValidationException() + { + var config = new ContentBlockerConfig + { + Enabled = true, + Sonarr = new BlocklistSettings { Enabled = false }, + Radarr = new BlocklistSettings { Enabled = false }, + Lidarr = new BlocklistSettings { Enabled = false }, + Readarr = new BlocklistSettings { Enabled = false }, + Whisparr = new BlocklistSettings { Enabled = false } + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("At least one blocklist must be configured when Malware Blocker is enabled"); + } + + #endregion + + #region Validate - Blocklist Settings Validation + + [Fact] + public void Validate_WhenEnabledWithValidSonarrBlocklist_DoesNotThrow() + { + var config = new ContentBlockerConfig + { + Enabled = true, + Sonarr = new BlocklistSettings + { + Enabled = true, + BlocklistPath = "https://example.com/blocklist.txt" + } + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WhenEnabledWithValidRadarrBlocklist_DoesNotThrow() + { + var config = new ContentBlockerConfig + { + Enabled = true, + Radarr = new BlocklistSettings + { + Enabled = true, + BlocklistPath = "http://example.com/blocklist.txt" + } + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WhenBlocklistEnabledWithEmptyPath_ThrowsValidationException() + { + var config = new ContentBlockerConfig + { + Enabled = true, + Sonarr = new BlocklistSettings + { + Enabled = true, + BlocklistPath = "" + } + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Sonarr blocklist is enabled but path is not specified"); + } + + [Fact] + public void Validate_WhenBlocklistEnabledWithNullPath_ThrowsValidationException() + { + var config = new ContentBlockerConfig + { + Enabled = true, + Radarr = new BlocklistSettings + { + Enabled = true, + BlocklistPath = null + } + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Radarr blocklist is enabled but path is not specified"); + } + + [Fact] + public void Validate_WhenBlocklistEnabledWithWhitespacePath_ThrowsValidationException() + { + var config = new ContentBlockerConfig + { + Enabled = true, + Lidarr = new BlocklistSettings + { + Enabled = true, + BlocklistPath = " " + } + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Lidarr blocklist is enabled but path is not specified"); + } + + [Fact] + public void Validate_WhenBlocklistEnabledWithNonExistentFilePath_ThrowsValidationException() + { + var config = new ContentBlockerConfig + { + Enabled = true, + Readarr = new BlocklistSettings + { + Enabled = true, + BlocklistPath = "/non/existent/path/blocklist.txt" + } + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldContain("Readarr blocklist does not exist"); + } + + [Fact] + public void Validate_WhenBlocklistEnabledWithInvalidPath_ThrowsValidationException() + { + var config = new ContentBlockerConfig + { + Enabled = true, + Whisparr = new BlocklistSettings + { + Enabled = true, + BlocklistPath = "not-a-valid-path" + } + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldContain("Whisparr blocklist does not exist"); + } + + #endregion + + #region Validate - URL Paths + + [Theory] + [InlineData("http://example.com/blocklist.txt")] + [InlineData("https://example.com/blocklist.txt")] + [InlineData("http://localhost:8080/api/blocklist")] + [InlineData("https://raw.githubusercontent.com/user/repo/main/blocklist.txt")] + public void Validate_WhenBlocklistEnabledWithValidHttpUrl_DoesNotThrow(string url) + { + var config = new ContentBlockerConfig + { + Enabled = true, + Sonarr = new BlocklistSettings + { + Enabled = true, + BlocklistPath = url + } + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData("ftp://example.com/blocklist.txt")] + [InlineData("file:///path/to/file")] + public void Validate_WhenBlocklistEnabledWithNonHttpUrl_ThrowsValidationException(string url) + { + var config = new ContentBlockerConfig + { + Enabled = true, + Sonarr = new BlocklistSettings + { + Enabled = true, + BlocklistPath = url + } + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldContain("Sonarr blocklist does not exist"); + } + + #endregion + + #region Validate - Multiple Blocklists + + [Fact] + public void Validate_WhenMultipleBlocklistsEnabled_ValidatesAll() + { + var config = new ContentBlockerConfig + { + Enabled = true, + Sonarr = new BlocklistSettings + { + Enabled = true, + BlocklistPath = "https://example.com/sonarr-blocklist.txt" + }, + Radarr = new BlocklistSettings + { + Enabled = true, + BlocklistPath = "https://example.com/radarr-blocklist.txt" + } + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WhenOneBlocklistInvalid_ThrowsValidationException() + { + var config = new ContentBlockerConfig + { + Enabled = true, + Sonarr = new BlocklistSettings + { + Enabled = true, + BlocklistPath = "https://example.com/sonarr-blocklist.txt" + }, + Radarr = new BlocklistSettings + { + Enabled = true, + BlocklistPath = "" // Invalid + } + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Radarr blocklist is enabled but path is not specified"); + } + + #endregion + + #region Default Values + + [Fact] + public void CronExpression_HasDefaultValue() + { + var config = new ContentBlockerConfig(); + + config.CronExpression.ShouldBe("0/5 * * * * ?"); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/AppriseConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/AppriseConfigTests.cs new file mode 100644 index 00000000..e375ae8f --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/AppriseConfigTests.cs @@ -0,0 +1,203 @@ +using Cleanuparr.Persistence.Models.Configuration.Notification; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.Notification; + +public sealed class AppriseConfigTests +{ + #region IsValid Tests + + [Fact] + public void IsValid_WithValidUrlAndKey_ReturnsTrue() + { + var config = new AppriseConfig + { + Url = "https://apprise.example.com", + Key = "my-config-key" + }; + + config.IsValid().ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsValid_WithEmptyOrNullUrl_ReturnsFalse(string? url) + { + var config = new AppriseConfig + { + Url = url ?? string.Empty, + Key = "my-config-key" + }; + + config.IsValid().ShouldBeFalse(); + } + + [Fact] + public void IsValid_WithInvalidUrl_ReturnsFalse() + { + var config = new AppriseConfig + { + Url = "not-a-valid-url", + Key = "my-config-key" + }; + + config.IsValid().ShouldBeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsValid_WithEmptyOrNullKey_ReturnsFalse(string? key) + { + var config = new AppriseConfig + { + Url = "https://apprise.example.com", + Key = key ?? string.Empty + }; + + config.IsValid().ShouldBeFalse(); + } + + [Fact] + public void IsValid_WithHttpUrl_ReturnsTrue() + { + var config = new AppriseConfig + { + Url = "http://apprise.local:8080", + Key = "config-key" + }; + + config.IsValid().ShouldBeTrue(); + } + + #endregion + + #region Uri Property Tests + + [Fact] + public void Uri_WithValidUrl_ReturnsUri() + { + var config = new AppriseConfig + { + Url = "https://apprise.example.com/notify" + }; + + config.Uri.ShouldNotBeNull(); + config.Uri.ToString().ShouldBe("https://apprise.example.com/notify"); + } + + [Fact] + public void Uri_WithInvalidUrl_ReturnsNull() + { + var config = new AppriseConfig + { + Url = "not-a-url" + }; + + config.Uri.ShouldBeNull(); + } + + [Fact] + public void Uri_WithEmptyUrl_ReturnsNull() + { + var config = new AppriseConfig + { + Url = string.Empty + }; + + config.Uri.ShouldBeNull(); + } + + #endregion + + #region Validate Tests + + [Fact] + public void Validate_WithValidConfig_DoesNotThrow() + { + var config = new AppriseConfig + { + Url = "https://apprise.example.com", + Key = "my-config-key" + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_WithEmptyOrNullUrl_ThrowsValidationException(string? url) + { + var config = new AppriseConfig + { + Url = url ?? string.Empty, + Key = "my-config-key" + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Apprise server URL is required"); + } + + [Fact] + public void Validate_WithInvalidUrl_ThrowsValidationException() + { + var config = new AppriseConfig + { + Url = "not-a-valid-url", + Key = "my-config-key" + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Apprise server URL must be a valid HTTP or HTTPS URL"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_WithEmptyOrNullKey_ThrowsValidationException(string? key) + { + var config = new AppriseConfig + { + Url = "https://apprise.example.com", + Key = key ?? string.Empty + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Apprise configuration key is required"); + } + + [Fact] + public void Validate_WithShortKey_ThrowsValidationException() + { + var config = new AppriseConfig + { + Url = "https://apprise.example.com", + Key = "a" + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Apprise configuration key must be at least 2 characters long"); + } + + [Fact] + public void Validate_WithMinimumLengthKey_DoesNotThrow() + { + var config = new AppriseConfig + { + Url = "https://apprise.example.com", + Key = "ab" + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/NotifiarrConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/NotifiarrConfigTests.cs new file mode 100644 index 00000000..95dcdf46 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/NotifiarrConfigTests.cs @@ -0,0 +1,172 @@ +using Cleanuparr.Persistence.Models.Configuration.Notification; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.Notification; + +public sealed class NotifiarrConfigTests +{ + #region IsValid Tests + + [Fact] + public void IsValid_WithValidApiKeyAndChannelId_ReturnsTrue() + { + var config = new NotifiarrConfig + { + ApiKey = "valid-api-key-12345", + ChannelId = "123456789012345678" + }; + + config.IsValid().ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsValid_WithEmptyOrNullApiKey_ReturnsFalse(string? apiKey) + { + var config = new NotifiarrConfig + { + ApiKey = apiKey ?? string.Empty, + ChannelId = "123456789012345678" + }; + + config.IsValid().ShouldBeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsValid_WithEmptyOrNullChannelId_ReturnsFalse(string? channelId) + { + var config = new NotifiarrConfig + { + ApiKey = "valid-api-key-12345", + ChannelId = channelId ?? string.Empty + }; + + config.IsValid().ShouldBeFalse(); + } + + [Fact] + public void IsValid_WithBothFieldsEmpty_ReturnsFalse() + { + var config = new NotifiarrConfig + { + ApiKey = string.Empty, + ChannelId = string.Empty + }; + + config.IsValid().ShouldBeFalse(); + } + + #endregion + + #region Validate Tests + + [Fact] + public void Validate_WithValidConfig_DoesNotThrow() + { + var config = new NotifiarrConfig + { + ApiKey = "valid-api-key-12345", + ChannelId = "123456789012345678" + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_WithEmptyOrNullApiKey_ThrowsValidationException(string? apiKey) + { + var config = new NotifiarrConfig + { + ApiKey = apiKey ?? string.Empty, + ChannelId = "123456789012345678" + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Notifiarr API key is required"); + } + + [Fact] + public void Validate_WithShortApiKey_ThrowsValidationException() + { + var config = new NotifiarrConfig + { + ApiKey = "short", + ChannelId = "123456789012345678" + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Notifiarr API key must be at least 10 characters long"); + } + + [Fact] + public void Validate_WithMinimumLengthApiKey_DoesNotThrow() + { + var config = new NotifiarrConfig + { + ApiKey = "1234567890", + ChannelId = "123456789012345678" + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_WithEmptyOrNullChannelId_ThrowsValidationException(string? channelId) + { + var config = new NotifiarrConfig + { + ApiKey = "valid-api-key-12345", + ChannelId = channelId ?? string.Empty + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Discord channel ID is required"); + } + + [Theory] + [InlineData("not-a-number")] + [InlineData("abc123")] + [InlineData("12.34")] + [InlineData("-123")] + public void Validate_WithNonNumericChannelId_ThrowsValidationException(string channelId) + { + var config = new NotifiarrConfig + { + ApiKey = "valid-api-key-12345", + ChannelId = channelId + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Discord channel ID must be a valid numeric ID"); + } + + [Theory] + [InlineData("0")] + [InlineData("123456789012345678")] + [InlineData("18446744073709551615")] + public void Validate_WithValidNumericChannelId_DoesNotThrow(string channelId) + { + var config = new NotifiarrConfig + { + ApiKey = "valid-api-key-12345", + ChannelId = channelId + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/NtfyConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/NtfyConfigTests.cs new file mode 100644 index 00000000..e63928a5 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/NtfyConfigTests.cs @@ -0,0 +1,403 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.Notification; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.Notification; + +public sealed class NtfyConfigTests +{ + #region IsValid Tests + + [Fact] + public void IsValid_WithValidUrlAndTopics_ReturnsTrue() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.None + }; + + config.IsValid().ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsValid_WithEmptyOrNullServerUrl_ReturnsFalse(string? serverUrl) + { + var config = new NtfyConfig + { + ServerUrl = serverUrl ?? string.Empty, + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.None + }; + + config.IsValid().ShouldBeFalse(); + } + + [Fact] + public void IsValid_WithInvalidServerUrl_ReturnsFalse() + { + var config = new NtfyConfig + { + ServerUrl = "not-a-valid-url", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.None + }; + + config.IsValid().ShouldBeFalse(); + } + + [Fact] + public void IsValid_WithEmptyTopicsList_ReturnsFalse() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = [], + AuthenticationType = NtfyAuthenticationType.None + }; + + config.IsValid().ShouldBeFalse(); + } + + [Fact] + public void IsValid_WithOnlyWhitespaceTopics_ReturnsFalse() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["", " "], + AuthenticationType = NtfyAuthenticationType.None + }; + + config.IsValid().ShouldBeFalse(); + } + + [Fact] + public void IsValid_WithMixedValidAndEmptyTopics_ReturnsTrue() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["", "valid-topic", " "], + AuthenticationType = NtfyAuthenticationType.None + }; + + config.IsValid().ShouldBeTrue(); + } + + #endregion + + #region IsValid Authentication Tests + + [Fact] + public void IsValid_WithBasicAuth_ValidCredentials_ReturnsTrue() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.BasicAuth, + Username = "user", + Password = "pass" + }; + + config.IsValid().ShouldBeTrue(); + } + + [Theory] + [InlineData(null, "pass")] + [InlineData("", "pass")] + [InlineData(" ", "pass")] + [InlineData("user", null)] + [InlineData("user", "")] + [InlineData("user", " ")] + [InlineData(null, null)] + [InlineData("", "")] + public void IsValid_WithBasicAuth_InvalidCredentials_ReturnsFalse(string? username, string? password) + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.BasicAuth, + Username = username, + Password = password + }; + + config.IsValid().ShouldBeFalse(); + } + + [Fact] + public void IsValid_WithAccessToken_ValidToken_ReturnsTrue() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.AccessToken, + AccessToken = "tk_valid_token" + }; + + config.IsValid().ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsValid_WithAccessToken_InvalidToken_ReturnsFalse(string? accessToken) + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.AccessToken, + AccessToken = accessToken + }; + + config.IsValid().ShouldBeFalse(); + } + + [Fact] + public void IsValid_WithNoAuth_IgnoresCredentials_ReturnsTrue() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.None, + Username = null, + Password = null, + AccessToken = null + }; + + config.IsValid().ShouldBeTrue(); + } + + #endregion + + #region Uri Property Tests + + [Fact] + public void Uri_WithValidUrl_ReturnsUri() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh/my-topic" + }; + + config.Uri.ShouldNotBeNull(); + config.Uri.ToString().ShouldBe("https://ntfy.sh/my-topic"); + } + + [Fact] + public void Uri_WithInvalidUrl_ReturnsNull() + { + var config = new NtfyConfig + { + ServerUrl = "not-a-url" + }; + + config.Uri.ShouldBeNull(); + } + + [Fact] + public void Uri_WithEmptyUrl_ReturnsNull() + { + var config = new NtfyConfig + { + ServerUrl = string.Empty + }; + + config.Uri.ShouldBeNull(); + } + + #endregion + + #region Validate Tests + + [Fact] + public void Validate_WithValidConfig_DoesNotThrow() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.None + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_WithEmptyOrNullServerUrl_ThrowsValidationException(string? serverUrl) + { + var config = new NtfyConfig + { + ServerUrl = serverUrl ?? string.Empty, + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.None + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("ntfy server URL is required"); + } + + [Fact] + public void Validate_WithInvalidServerUrl_ThrowsValidationException() + { + var config = new NtfyConfig + { + ServerUrl = "not-a-valid-url", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.None + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("ntfy server URL must be a valid HTTP or HTTPS URL"); + } + + [Fact] + public void Validate_WithEmptyTopicsList_ThrowsValidationException() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = [], + AuthenticationType = NtfyAuthenticationType.None + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("At least one ntfy topic is required"); + } + + [Fact] + public void Validate_WithOnlyWhitespaceTopics_ThrowsValidationException() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["", " "], + AuthenticationType = NtfyAuthenticationType.None + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("At least one ntfy topic is required"); + } + + #endregion + + #region Validate Authentication Tests + + [Fact] + public void Validate_WithBasicAuth_ValidCredentials_DoesNotThrow() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.BasicAuth, + Username = "user", + Password = "pass" + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_WithBasicAuth_MissingUsername_ThrowsValidationException(string? username) + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.BasicAuth, + Username = username, + Password = "pass" + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Username is required for Basic Auth"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_WithBasicAuth_MissingPassword_ThrowsValidationException(string? password) + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.BasicAuth, + Username = "user", + Password = password + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Password is required for Basic Auth"); + } + + [Fact] + public void Validate_WithAccessToken_ValidToken_DoesNotThrow() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.AccessToken, + AccessToken = "tk_valid_token" + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_WithAccessToken_MissingToken_ThrowsValidationException(string? accessToken) + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.AccessToken, + AccessToken = accessToken + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Access token is required for Token authentication"); + } + + [Fact] + public void Validate_WithNoAuth_DoesNotRequireCredentials() + { + var config = new NtfyConfig + { + ServerUrl = "https://ntfy.sh", + Topics = ["my-topic"], + AuthenticationType = NtfyAuthenticationType.None, + Username = null, + Password = null, + AccessToken = null + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/PushoverConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/PushoverConfigTests.cs new file mode 100644 index 00000000..0d91d5a5 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/Notification/PushoverConfigTests.cs @@ -0,0 +1,512 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.Notification; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.Notification; + +public sealed class PushoverConfigTests +{ + #region IsValid Tests + + [Fact] + public void IsValid_WithValidApiTokenAndUserKey_ReturnsTrue() + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal + }; + + config.IsValid().ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsValid_WithEmptyOrNullApiToken_ReturnsFalse(string? apiToken) + { + var config = new PushoverConfig + { + ApiToken = apiToken ?? string.Empty, + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal + }; + + config.IsValid().ShouldBeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsValid_WithEmptyOrNullUserKey_ReturnsFalse(string? userKey) + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = userKey ?? string.Empty, + Priority = PushoverPriority.Normal + }; + + config.IsValid().ShouldBeFalse(); + } + + #endregion + + #region IsValid Emergency Priority Tests + + [Fact] + public void IsValid_WithEmergencyPriority_ValidRetryAndExpire_ReturnsTrue() + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Emergency, + Retry = 30, + Expire = 3600 + }; + + config.IsValid().ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData(29)] + [InlineData(0)] + [InlineData(-1)] + public void IsValid_WithEmergencyPriority_InvalidRetry_ReturnsFalse(int? retry) + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Emergency, + Retry = retry, + Expire = 3600 + }; + + config.IsValid().ShouldBeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData(0)] + [InlineData(-1)] + [InlineData(10801)] + public void IsValid_WithEmergencyPriority_InvalidExpire_ReturnsFalse(int? expire) + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Emergency, + Retry = 30, + Expire = expire + }; + + config.IsValid().ShouldBeFalse(); + } + + [Theory] + [InlineData(PushoverPriority.Lowest)] + [InlineData(PushoverPriority.Low)] + [InlineData(PushoverPriority.Normal)] + [InlineData(PushoverPriority.High)] + public void IsValid_WithNonEmergencyPriority_DoesNotRequireRetryAndExpire(PushoverPriority priority) + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = priority, + Retry = null, + Expire = null + }; + + config.IsValid().ShouldBeTrue(); + } + + #endregion + + #region IsValid Sound Tests + + [Theory] + [InlineData(null)] + [InlineData("pushover")] + [InlineData("bike")] + [InlineData("custom-sound")] + public void IsValid_WithValidOrNullSound_ReturnsTrue(string? sound) + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal, + Sound = sound + }; + + config.IsValid().ShouldBeTrue(); + } + + [Theory] + [InlineData(" ")] + [InlineData(" ")] + [InlineData("\t")] + public void IsValid_WithWhitespaceOnlySound_ReturnsFalse(string sound) + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal, + Sound = sound + }; + + config.IsValid().ShouldBeFalse(); + } + + [Fact] + public void IsValid_WithEmptyStringSound_ReturnsTrue() + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal, + Sound = string.Empty + }; + + // Empty string has Length 0, so Sound.Length > 0 is false - the condition is skipped + config.IsValid().ShouldBeTrue(); + } + + #endregion + + #region Validate Tests + + [Fact] + public void Validate_WithValidConfig_DoesNotThrow() + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_WithEmptyOrNullApiToken_ThrowsValidationException(string? apiToken) + { + var config = new PushoverConfig + { + ApiToken = apiToken ?? string.Empty, + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Pushover API token is required"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_WithEmptyOrNullUserKey_ThrowsValidationException(string? userKey) + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = userKey ?? string.Empty, + Priority = PushoverPriority.Normal + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Pushover user key is required"); + } + + #endregion + + #region Validate Emergency Priority Tests + + [Fact] + public void Validate_WithEmergencyPriority_ValidRetryAndExpire_DoesNotThrow() + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Emergency, + Retry = 30, + Expire = 3600 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithEmergencyPriority_MinimumValidRetry_DoesNotThrow() + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Emergency, + Retry = 30, + Expire = 1 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithEmergencyPriority_MaximumValidExpire_DoesNotThrow() + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Emergency, + Retry = 30, + Expire = 10800 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData(null)] + [InlineData(29)] + [InlineData(0)] + public void Validate_WithEmergencyPriority_RetryTooLowOrNull_ThrowsValidationException(int? retry) + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Emergency, + Retry = retry, + Expire = 3600 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Retry interval must be at least 30 seconds for emergency priority"); + } + + [Theory] + [InlineData(null)] + [InlineData(0)] + public void Validate_WithEmergencyPriority_ExpireNullOrZero_ThrowsValidationException(int? expire) + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Emergency, + Retry = 30, + Expire = expire + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Expire time is required for emergency priority"); + } + + [Fact] + public void Validate_WithEmergencyPriority_ExpireTooHigh_ThrowsValidationException() + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Emergency, + Retry = 30, + Expire = 10801 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Expire time cannot exceed 10800 seconds (3 hours)"); + } + + #endregion + + #region Validate Device Tests + + [Fact] + public void Validate_WithValidDeviceNames_DoesNotThrow() + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal, + Devices = ["iphone", "android-phone", "my_device"] + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithEmptyDevicesList_DoesNotThrow() + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal, + Devices = [] + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithDeviceNameTooLong_ThrowsValidationException() + { + var longDeviceName = new string('a', 26); + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal, + Devices = [longDeviceName] + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe($"Device name '{longDeviceName}' exceeds 25 character limit"); + } + + [Fact] + public void Validate_WithDeviceNameAtMaxLength_DoesNotThrow() + { + var maxLengthDeviceName = new string('a', 25); + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal, + Devices = [maxLengthDeviceName] + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData("device@name")] + [InlineData("device name")] + [InlineData("device.name")] + [InlineData("device!name")] + public void Validate_WithDeviceNameInvalidCharacters_ThrowsValidationException(string deviceName) + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal, + Devices = [deviceName] + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe($"Device name '{deviceName}' contains invalid characters. Only letters, numbers, underscores, and hyphens are allowed."); + } + + [Theory] + [InlineData("device-name")] + [InlineData("device_name")] + [InlineData("DeviceName123")] + [InlineData("DEVICE")] + [InlineData("a")] + public void Validate_WithDeviceNameValidCharacters_DoesNotThrow(string deviceName) + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal, + Devices = [deviceName] + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithEmptyOrWhitespaceDeviceNames_SkipsThem() + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal, + Devices = ["", " ", "valid-device"] + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate Sound Tests + + [Theory] + [InlineData(null)] + [InlineData("pushover")] + [InlineData("bike")] + [InlineData("custom-sound")] + public void Validate_WithValidOrNullSound_DoesNotThrow(string? sound) + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal, + Sound = sound + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData(" ")] + [InlineData(" ")] + [InlineData("\t")] + public void Validate_WithWhitespaceOnlySound_ThrowsValidationException(string sound) + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal, + Sound = sound + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Sound name cannot be empty or whitespace when specified"); + } + + [Fact] + public void Validate_WithEmptyStringSound_DoesNotThrow() + { + var config = new PushoverConfig + { + ApiToken = "test-api-token-1234567890", + UserKey = "test-user-key-1234567890", + Priority = PushoverPriority.Normal, + Sound = string.Empty + }; + + // Empty string has Length 0, so Sound.Length > 0 is false - the condition is skipped + Should.NotThrow(() => config.Validate()); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/FailedImportConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/FailedImportConfigTests.cs new file mode 100644 index 00000000..92e5e3b8 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/FailedImportConfigTests.cs @@ -0,0 +1,170 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.QueueCleaner; + +public sealed class FailedImportConfigTests +{ + #region Validate - Valid Configurations + + [Fact] + public void Validate_WithDisabledConfig_DoesNotThrow() + { + var config = new FailedImportConfig + { + MaxStrikes = 0 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithValidMaxStrikesAndIncludePatterns_DoesNotThrow() + { + var config = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Include, + Patterns = ["pattern1", "pattern2"] + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithValidMaxStrikesAndExcludeMode_DoesNotThrow() + { + var config = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Exclude, + Patterns = [] + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithHighMaxStrikes_DoesNotThrow() + { + var config = new FailedImportConfig + { + MaxStrikes = 100, + PatternMode = PatternMode.Include, + Patterns = ["pattern"] + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - MaxStrikes Validation + + [Theory] + [InlineData((ushort)1)] + [InlineData((ushort)2)] + public void Validate_WithMaxStrikesBetween1And2_ThrowsValidationException(ushort maxStrikes) + { + var config = new FailedImportConfig + { + MaxStrikes = maxStrikes, + PatternMode = PatternMode.Include, + Patterns = ["pattern"] + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("The minimum value for failed imports max strikes must be 3"); + } + + [Fact] + public void Validate_WithMinimumValidMaxStrikes_DoesNotThrow() + { + var config = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Include, + Patterns = ["pattern"] + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - Pattern Mode Validation + + [Fact] + public void Validate_WithIncludeModeAndNoPatterns_ThrowsValidationException() + { + var config = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Include, + Patterns = [] + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("At least one pattern must be specified when using the Include pattern mode"); + } + + [Fact] + public void Validate_WithExcludeModeAndNoPatterns_DoesNotThrow() + { + var config = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Exclude, + Patterns = [] + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithExcludeModeAndPatterns_DoesNotThrow() + { + var config = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Exclude, + Patterns = ["excluded-pattern"] + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithDisabledAndIncludeModeNoPatterns_DoesNotThrow() + { + // When MaxStrikes is 0 (disabled), patterns are not required + var config = new FailedImportConfig + { + MaxStrikes = 0, + PatternMode = PatternMode.Include, + Patterns = [] + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithLowMaxStrikesAndIncludeModeNoPatterns_ThrowsMaxStrikesException() + { + // MaxStrikes validation happens before pattern validation + var config = new FailedImportConfig + { + MaxStrikes = 2, + PatternMode = PatternMode.Include, + Patterns = [] + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("The minimum value for failed imports max strikes must be 3"); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/QueueCleanerConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/QueueCleanerConfigTests.cs new file mode 100644 index 00000000..a6ed31f2 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/QueueCleanerConfigTests.cs @@ -0,0 +1,283 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.QueueCleaner; + +public sealed class QueueCleanerConfigTests +{ + #region Validate - Valid Configurations + + [Fact] + public void Validate_WithDefaultConfig_DoesNotThrow() + { + var config = new QueueCleanerConfig(); + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithValidStallRules_DoesNotThrow() + { + var config = new QueueCleanerConfig + { + StallRules = + [ + new StallRule { Name = "rule1", MaxStrikes = 3, Enabled = true }, + new StallRule { Name = "rule2", MaxStrikes = 5, Enabled = true } + ] + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithValidSlowRules_DoesNotThrow() + { + var config = new QueueCleanerConfig + { + SlowRules = + [ + new SlowRule { Name = "slow1", MaxStrikes = 3, MinSpeed = "100KB", Enabled = true }, + new SlowRule { Name = "slow2", MaxStrikes = 5, MaxTimeHours = 24, Enabled = true } + ] + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - DownloadingMetadataMaxStrikes Validation + + [Fact] + public void Validate_WithZeroDownloadingMetadataMaxStrikes_DoesNotThrow() + { + var config = new QueueCleanerConfig + { + DownloadingMetadataMaxStrikes = 0 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithMinimumValidDownloadingMetadataMaxStrikes_DoesNotThrow() + { + var config = new QueueCleanerConfig + { + DownloadingMetadataMaxStrikes = 3 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData((ushort)1)] + [InlineData((ushort)2)] + public void Validate_WithDownloadingMetadataMaxStrikesBetween1And2_ThrowsValidationException(ushort maxStrikes) + { + var config = new QueueCleanerConfig + { + DownloadingMetadataMaxStrikes = maxStrikes + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("the minimum value for downloading metadata max strikes must be 3"); + } + + [Theory] + [InlineData((ushort)3)] + [InlineData((ushort)5)] + [InlineData((ushort)100)] + public void Validate_WithValidDownloadingMetadataMaxStrikes_DoesNotThrow(ushort maxStrikes) + { + var config = new QueueCleanerConfig + { + DownloadingMetadataMaxStrikes = maxStrikes + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - FailedImport Validation + + [Fact] + public void Validate_WithInvalidFailedImportConfig_ThrowsValidationException() + { + var config = new QueueCleanerConfig + { + FailedImport = new FailedImportConfig + { + MaxStrikes = 1 // Invalid - must be 0 or >= 3 + } + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("The minimum value for failed imports max strikes must be 3"); + } + + #endregion + + #region Validate - StallRule Validation + + [Fact] + public void Validate_WithInvalidStallRule_ThrowsValidationException() + { + var config = new QueueCleanerConfig + { + StallRules = + [ + new StallRule { Name = "", MaxStrikes = 3 } // Invalid name + ] + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Rule name cannot be empty"); + } + + [Fact] + public void Validate_WithDuplicateEnabledStallRuleNames_ThrowsValidationException() + { + var config = new QueueCleanerConfig + { + StallRules = + [ + new StallRule { Name = "duplicate", MaxStrikes = 3, Enabled = true }, + new StallRule { Name = "duplicate", MaxStrikes = 5, Enabled = true } + ] + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Duplicate stall rule names found"); + } + + [Fact] + public void Validate_WithDuplicateDisabledStallRuleNames_DoesNotThrow() + { + var config = new QueueCleanerConfig + { + StallRules = + [ + new StallRule { Name = "duplicate", MaxStrikes = 3, Enabled = false }, + new StallRule { Name = "duplicate", MaxStrikes = 5, Enabled = false } + ] + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithDuplicateButOneDisabledStallRule_DoesNotThrow() + { + var config = new QueueCleanerConfig + { + StallRules = + [ + new StallRule { Name = "duplicate", MaxStrikes = 3, Enabled = true }, + new StallRule { Name = "duplicate", MaxStrikes = 5, Enabled = false } + ] + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - SlowRule Validation + + [Fact] + public void Validate_WithInvalidSlowRule_ThrowsValidationException() + { + var config = new QueueCleanerConfig + { + SlowRules = + [ + new SlowRule { Name = "", MaxStrikes = 3, MinSpeed = "100KB" } // Invalid name + ] + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Rule name cannot be empty"); + } + + [Fact] + public void Validate_WithDuplicateEnabledSlowRuleNames_ThrowsValidationException() + { + var config = new QueueCleanerConfig + { + SlowRules = + [ + new SlowRule { Name = "duplicate", MaxStrikes = 3, MinSpeed = "100KB", Enabled = true }, + new SlowRule { Name = "duplicate", MaxStrikes = 5, MaxTimeHours = 24, Enabled = true } + ] + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Duplicate slow rule names found"); + } + + [Fact] + public void Validate_WithDuplicateDisabledSlowRuleNames_DoesNotThrow() + { + var config = new QueueCleanerConfig + { + SlowRules = + [ + new SlowRule { Name = "duplicate", MaxStrikes = 3, MinSpeed = "100KB", Enabled = false }, + new SlowRule { Name = "duplicate", MaxStrikes = 5, MaxTimeHours = 24, Enabled = false } + ] + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - Mixed Rules + + [Fact] + public void Validate_WithSameNameAcrossStallAndSlowRules_DoesNotThrow() + { + // Same name is allowed between stall and slow rules + var config = new QueueCleanerConfig + { + StallRules = + [ + new StallRule { Name = "samename", MaxStrikes = 3, Enabled = true } + ], + SlowRules = + [ + new SlowRule { Name = "samename", MaxStrikes = 3, MinSpeed = "100KB", Enabled = true } + ] + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Default Values + + [Fact] + public void CronExpression_HasDefaultValue() + { + var config = new QueueCleanerConfig(); + + config.CronExpression.ShouldBe("0 0/5 * * * ?"); + } + + [Fact] + public void UseAdvancedScheduling_DefaultsToFalse() + { + var config = new QueueCleanerConfig(); + + config.UseAdvancedScheduling.ShouldBeFalse(); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/QueueRuleTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/QueueRuleTests.cs new file mode 100644 index 00000000..fb521777 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/QueueRuleTests.cs @@ -0,0 +1,279 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.QueueCleaner; + +/// +/// Tests for the abstract QueueRule base class validation logic. +/// Uses StallRule as a concrete implementation for testing. +/// +public sealed class QueueRuleTests +{ + #region Validate - Valid Configurations + + [Fact] + public void Validate_WithValidConfig_DoesNotThrow() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinCompletionPercentage = 0, + MaxCompletionPercentage = 100 + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Fact] + public void Validate_WithMinimumValidMaxStrikes_DoesNotThrow() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinCompletionPercentage = 0, + MaxCompletionPercentage = 100 + }; + + Should.NotThrow(() => rule.Validate()); + } + + #endregion + + #region Validate - Name Validation + + [Fact] + public void Validate_WithEmptyName_ThrowsValidationException() + { + var rule = new StallRule + { + Name = "", + MaxStrikes = 3, + MinCompletionPercentage = 0, + MaxCompletionPercentage = 100 + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Rule name cannot be empty"); + } + + [Fact] + public void Validate_WithWhitespaceName_ThrowsValidationException() + { + var rule = new StallRule + { + Name = " ", + MaxStrikes = 3, + MinCompletionPercentage = 0, + MaxCompletionPercentage = 100 + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Rule name cannot be empty"); + } + + [Fact] + public void Validate_WithTabOnlyName_ThrowsValidationException() + { + var rule = new StallRule + { + Name = "\t", + MaxStrikes = 3, + MinCompletionPercentage = 0, + MaxCompletionPercentage = 100 + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Rule name cannot be empty"); + } + + #endregion + + #region Validate - MaxStrikes Validation + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Validate_WithMaxStrikesLessThan3_ThrowsValidationException(int maxStrikes) + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = maxStrikes, + MinCompletionPercentage = 0, + MaxCompletionPercentage = 100 + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Max strikes must be at least 3"); + } + + [Theory] + [InlineData(3)] + [InlineData(5)] + [InlineData(100)] + public void Validate_WithValidMaxStrikes_DoesNotThrow(int maxStrikes) + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = maxStrikes, + MinCompletionPercentage = 0, + MaxCompletionPercentage = 100 + }; + + Should.NotThrow(() => rule.Validate()); + } + + #endregion + + #region Validate - MinCompletionPercentage Validation + + [Theory] + [InlineData((ushort)101)] + [InlineData((ushort)150)] + [InlineData((ushort)255)] + public void Validate_WithMinCompletionPercentageExceeding100_ThrowsValidationException(ushort minCompletionPercentage) + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinCompletionPercentage = minCompletionPercentage, + MaxCompletionPercentage = 100 + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Minimum completion percentage must be between 0 and 100"); + } + + [Theory] + [InlineData((ushort)0)] + [InlineData((ushort)50)] + [InlineData((ushort)100)] + public void Validate_WithValidMinCompletionPercentage_DoesNotThrow(ushort minCompletionPercentage) + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinCompletionPercentage = minCompletionPercentage, + MaxCompletionPercentage = 100 + }; + + Should.NotThrow(() => rule.Validate()); + } + + #endregion + + #region Validate - MaxCompletionPercentage Validation + + [Theory] + [InlineData((ushort)101)] + [InlineData((ushort)150)] + [InlineData((ushort)255)] + public void Validate_WithMaxCompletionPercentageExceeding100_ThrowsValidationException(ushort maxCompletionPercentage) + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinCompletionPercentage = 0, + MaxCompletionPercentage = maxCompletionPercentage + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Maximum completion percentage must be between 0 and 100"); + } + + [Theory] + [InlineData((ushort)0)] + [InlineData((ushort)50)] + [InlineData((ushort)100)] + public void Validate_WithValidMaxCompletionPercentage_DoesNotThrow(ushort maxCompletionPercentage) + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinCompletionPercentage = 0, + MaxCompletionPercentage = maxCompletionPercentage + }; + + Should.NotThrow(() => rule.Validate()); + } + + #endregion + + #region Validate - Completion Percentage Range Validation + + [Fact] + public void Validate_WithMaxLessThanMin_ThrowsValidationException() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinCompletionPercentage = 50, + MaxCompletionPercentage = 25 + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Maximum completion percentage must be greater than or equal to the minimum completion percentage"); + } + + [Fact] + public void Validate_WithMaxEqualToMin_DoesNotThrow() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinCompletionPercentage = 50, + MaxCompletionPercentage = 50 + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Fact] + public void Validate_WithMaxGreaterThanMin_DoesNotThrow() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinCompletionPercentage = 25, + MaxCompletionPercentage = 75 + }; + + Should.NotThrow(() => rule.Validate()); + } + + #endregion + + #region PrivacyType Tests + + [Theory] + [InlineData(TorrentPrivacyType.Public)] + [InlineData(TorrentPrivacyType.Private)] + [InlineData(TorrentPrivacyType.Both)] + public void PrivacyType_WithDifferentValues_SetsCorrectly(TorrentPrivacyType privacyType) + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + PrivacyType = privacyType + }; + + rule.PrivacyType.ShouldBe(privacyType); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/SlowConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/SlowConfigTests.cs new file mode 100644 index 00000000..5f83776d --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/SlowConfigTests.cs @@ -0,0 +1,275 @@ +using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.QueueCleaner; + +public sealed class SlowConfigTests +{ + #region Validate - Valid Configurations + + [Fact] + public void Validate_WithDisabledConfig_DoesNotThrow() + { + var config = new SlowConfig + { + MaxStrikes = 0 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithValidMinSpeed_DoesNotThrow() + { + var config = new SlowConfig + { + MaxStrikes = 3, + MinSpeed = "100KB", + MaxTime = 0 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithValidMaxTime_DoesNotThrow() + { + var config = new SlowConfig + { + MaxStrikes = 3, + MinSpeed = "", + MaxTime = 24 + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithBothMinSpeedAndMaxTime_DoesNotThrow() + { + var config = new SlowConfig + { + MaxStrikes = 3, + MinSpeed = "1MB", + MaxTime = 48 + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - MaxStrikes Validation + + [Theory] + [InlineData((ushort)1)] + [InlineData((ushort)2)] + public void Validate_WithMaxStrikesBetween1And2_ThrowsValidationException(ushort maxStrikes) + { + var config = new SlowConfig + { + MaxStrikes = maxStrikes, + MinSpeed = "100KB", + MaxTime = 0 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("the minimum value for slow max strikes must be 3"); + } + + [Fact] + public void Validate_WithMinimumValidMaxStrikes_DoesNotThrow() + { + var config = new SlowConfig + { + MaxStrikes = 3, + MinSpeed = "100KB", + MaxTime = 0 + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - MinSpeed Validation + + [Theory] + [InlineData("invalid")] + [InlineData("abc")] + [InlineData("100")] + [InlineData("KB")] + public void Validate_WithInvalidMinSpeedFormat_ThrowsValidationException(string minSpeed) + { + var config = new SlowConfig + { + MaxStrikes = 3, + MinSpeed = minSpeed, + MaxTime = 0 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("invalid value for slow min speed"); + } + + [Theory] + [InlineData("1KB")] + [InlineData("100KB")] + [InlineData("1MB")] + [InlineData("1GB")] + public void Validate_WithValidMinSpeedFormats_DoesNotThrow(string minSpeed) + { + var config = new SlowConfig + { + MaxStrikes = 3, + MinSpeed = minSpeed, + MaxTime = 0 + }; + + Should.NotThrow(() => config.Validate()); + } + + #endregion + + #region Validate - MaxTime Validation + + [Fact] + public void Validate_WithNegativeMaxTime_ThrowsValidationException() + { + var config = new SlowConfig + { + MaxStrikes = 3, + MinSpeed = "100KB", + MaxTime = -1 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("invalid value for slow max time"); + } + + #endregion + + #region Validate - MinSpeed and MaxTime Required + + [Fact] + public void Validate_WithNoMinSpeedAndNoMaxTime_ThrowsValidationException() + { + var config = new SlowConfig + { + MaxStrikes = 3, + MinSpeed = "", + MaxTime = 0 + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("either slow min speed or slow max time must be set"); + } + + #endregion + + #region Validate - IgnoreAboveSize Validation + + [Theory] + [InlineData("100MB")] + [InlineData("1GB")] + [InlineData("10GB")] + public void Validate_WithValidIgnoreAboveSize_DoesNotThrow(string ignoreAboveSize) + { + var config = new SlowConfig + { + MaxStrikes = 3, + MinSpeed = "100KB", + MaxTime = 0, + IgnoreAboveSize = ignoreAboveSize + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithEmptyIgnoreAboveSize_DoesNotThrow() + { + var config = new SlowConfig + { + MaxStrikes = 3, + MinSpeed = "100KB", + MaxTime = 0, + IgnoreAboveSize = "" + }; + + Should.NotThrow(() => config.Validate()); + } + + [Theory] + [InlineData("invalid")] + [InlineData("abc")] + [InlineData("100")] + public void Validate_WithInvalidIgnoreAboveSizeFormat_ThrowsValidationException(string ignoreAboveSize) + { + var config = new SlowConfig + { + MaxStrikes = 3, + MinSpeed = "100KB", + MaxTime = 0, + IgnoreAboveSize = ignoreAboveSize + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldContain("invalid value for slow ignore above size"); + } + + #endregion + + #region ByteSize Property Tests + + [Fact] + public void MinSpeedByteSize_WithValidSpeed_ParsesCorrectly() + { + var config = new SlowConfig + { + MinSpeed = "1MB" + }; + + config.MinSpeedByteSize.Bytes.ShouldBe(1024 * 1024); + } + + [Fact] + public void MinSpeedByteSize_WithEmptySpeed_ReturnsZero() + { + var config = new SlowConfig + { + MinSpeed = "" + }; + + config.MinSpeedByteSize.Bytes.ShouldBe(0); + } + + [Fact] + public void IgnoreAboveSizeByteSize_WithValidSize_ParsesCorrectly() + { + var config = new SlowConfig + { + MinSpeed = "100KB", + IgnoreAboveSize = "1GB" + }; + + config.IgnoreAboveSizeByteSize.ShouldNotBeNull(); + config.IgnoreAboveSizeByteSize!.Value.Bytes.ShouldBe(1024L * 1024 * 1024); + } + + [Fact] + public void IgnoreAboveSizeByteSize_WithEmptySize_ReturnsNull() + { + var config = new SlowConfig + { + MinSpeed = "100KB", + IgnoreAboveSize = "" + }; + + config.IgnoreAboveSizeByteSize.ShouldBeNull(); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/SlowRuleTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/SlowRuleTests.cs new file mode 100644 index 00000000..f59b9e91 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/SlowRuleTests.cs @@ -0,0 +1,317 @@ +using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.QueueCleaner; + +public sealed class SlowRuleTests +{ + #region Validate - Valid Configurations + + [Fact] + public void Validate_WithValidMinSpeed_DoesNotThrow() + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "100KB", + MaxTimeHours = 0 + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Fact] + public void Validate_WithValidMaxTimeHours_DoesNotThrow() + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "", + MaxTimeHours = 24 + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Fact] + public void Validate_WithBothMinSpeedAndMaxTimeHours_DoesNotThrow() + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "1MB", + MaxTimeHours = 48 + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Theory] + [InlineData("1KB")] + [InlineData("100KB")] + [InlineData("1MB")] + [InlineData("10MB")] + [InlineData("1GB")] + public void Validate_WithVariousValidMinSpeedFormats_DoesNotThrow(string minSpeed) + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = minSpeed, + MaxTimeHours = 0 + }; + + Should.NotThrow(() => rule.Validate()); + } + + #endregion + + #region Validate - MaxStrikes Validation (Override) + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Validate_WithMaxStrikesLessThan3_ThrowsValidationException(int maxStrikes) + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = maxStrikes, + MinSpeed = "100KB", + MaxTimeHours = 0 + }; + + // Base class validation runs first + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Max strikes must be at least 3"); + } + + #endregion + + #region Validate - MaxTimeHours Validation + + [Fact] + public void Validate_WithNegativeMaxTimeHours_ThrowsValidationException() + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "100KB", + MaxTimeHours = -1 + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Maximum time cannot be negative"); + } + + [Fact] + public void Validate_WithZeroMaxTimeHoursAndValidMinSpeed_DoesNotThrow() + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "100KB", + MaxTimeHours = 0 + }; + + Should.NotThrow(() => rule.Validate()); + } + + #endregion + + #region Validate - MinSpeed and MaxTime Required + + [Fact] + public void Validate_WithNoMinSpeedAndNoMaxTime_ThrowsValidationException() + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "", + MaxTimeHours = 0 + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Either minimum speed or maximum time must be specified"); + } + + [Fact] + public void Validate_WithEmptyMinSpeedAndZeroMaxTime_ThrowsValidationException() + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = string.Empty, + MaxTimeHours = 0 + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Either minimum speed or maximum time must be specified"); + } + + #endregion + + #region Validate - MinSpeed Format Validation + + [Theory] + [InlineData("invalid")] + [InlineData("abc")] + [InlineData("100")] + [InlineData("KB")] + public void Validate_WithInvalidMinSpeedFormat_ThrowsValidationException(string minSpeed) + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = minSpeed, + MaxTimeHours = 0 + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Invalid minimum speed format"); + } + + #endregion + + #region Validate - IgnoreAboveSize Validation + + [Theory] + [InlineData("100MB")] + [InlineData("1GB")] + [InlineData("10GB")] + public void Validate_WithValidIgnoreAboveSize_DoesNotThrow(string ignoreAboveSize) + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "100KB", + MaxTimeHours = 0, + IgnoreAboveSize = ignoreAboveSize + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Fact] + public void Validate_WithEmptyIgnoreAboveSize_DoesNotThrow() + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "100KB", + MaxTimeHours = 0, + IgnoreAboveSize = "" + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Fact] + public void Validate_WithNullIgnoreAboveSize_DoesNotThrow() + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "100KB", + MaxTimeHours = 0, + IgnoreAboveSize = null + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Theory] + [InlineData("invalid")] + [InlineData("abc")] + [InlineData("100")] + public void Validate_WithInvalidIgnoreAboveSizeFormat_ThrowsValidationException(string ignoreAboveSize) + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "100KB", + MaxTimeHours = 0, + IgnoreAboveSize = ignoreAboveSize + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldContain("invalid value for slow ignore above size"); + } + + #endregion + + #region ByteSize Property Tests + + [Fact] + public void MinSpeedByteSize_WithValidSpeed_ParsesCorrectly() + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "1MB" + }; + + rule.MinSpeedByteSize.Bytes.ShouldBe(1024 * 1024); + } + + [Fact] + public void MinSpeedByteSize_WithEmptySpeed_ReturnsZero() + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "" + }; + + rule.MinSpeedByteSize.Bytes.ShouldBe(0); + } + + [Fact] + public void IgnoreAboveSizeByteSize_WithValidSize_ParsesCorrectly() + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "100KB", + IgnoreAboveSize = "1GB" + }; + + rule.IgnoreAboveSizeByteSize.ShouldNotBeNull(); + rule.IgnoreAboveSizeByteSize!.Value.Bytes.ShouldBe(1024L * 1024 * 1024); + } + + [Fact] + public void IgnoreAboveSizeByteSize_WithEmptySize_ReturnsNull() + { + var rule = new SlowRule + { + Name = "test-rule", + MaxStrikes = 3, + MinSpeed = "100KB", + IgnoreAboveSize = "" + }; + + rule.IgnoreAboveSizeByteSize.ShouldBeNull(); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/StallRuleTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/StallRuleTests.cs new file mode 100644 index 00000000..d7455567 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/StallRuleTests.cs @@ -0,0 +1,243 @@ +using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using Shouldly; +using Xunit; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Tests.Models.Configuration.QueueCleaner; + +public sealed class StallRuleTests +{ + #region Validate - Valid Configurations + + [Fact] + public void Validate_WithValidConfig_DoesNotThrow() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3 + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Fact] + public void Validate_WithValidMinimumProgress_DoesNotThrow() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinimumProgress = "1MB" + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Fact] + public void Validate_WithNullMinimumProgress_DoesNotThrow() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinimumProgress = null + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Fact] + public void Validate_WithEmptyMinimumProgress_DoesNotThrow() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinimumProgress = "" + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Fact] + public void Validate_WithWhitespaceMinimumProgress_DoesNotThrow() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinimumProgress = " " + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Theory] + [InlineData("1KB")] + [InlineData("100KB")] + [InlineData("1MB")] + [InlineData("10MB")] + [InlineData("1GB")] + public void Validate_WithVariousValidMinimumProgressFormats_DoesNotThrow(string minimumProgress) + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinimumProgress = minimumProgress + }; + + Should.NotThrow(() => rule.Validate()); + } + + #endregion + + #region Validate - MaxStrikes Validation (Override) + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void Validate_WithMaxStrikesLessThan3_ThrowsValidationException(int maxStrikes) + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = maxStrikes + }; + + // Base class validation runs first + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Max strikes must be at least 3"); + } + + #endregion + + #region Validate - MinimumProgress Validation + + [Theory] + [InlineData("invalid")] + [InlineData("abc")] + [InlineData("100")] + [InlineData("KB")] + public void Validate_WithInvalidMinimumProgressFormat_ThrowsValidationException(string minimumProgress) + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinimumProgress = minimumProgress + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldContain("Invalid minimum progress value"); + } + + #endregion + + #region ByteSize Property Tests + + [Fact] + public void MinimumProgressByteSize_WithValidProgress_ParsesCorrectly() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinimumProgress = "1MB" + }; + + rule.MinimumProgressByteSize.ShouldNotBeNull(); + rule.MinimumProgressByteSize!.Value.Bytes.ShouldBe(1024 * 1024); + } + + [Fact] + public void MinimumProgressByteSize_WithNullProgress_ReturnsNull() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinimumProgress = null + }; + + rule.MinimumProgressByteSize.ShouldBeNull(); + } + + [Fact] + public void MinimumProgressByteSize_WithEmptyProgress_ReturnsNull() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinimumProgress = "" + }; + + rule.MinimumProgressByteSize.ShouldBeNull(); + } + + [Fact] + public void MinimumProgressByteSize_WithWhitespaceProgress_ReturnsNull() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinimumProgress = " " + }; + + rule.MinimumProgressByteSize.ShouldBeNull(); + } + + [Theory] + [InlineData("1KB", 1024)] + [InlineData("1MB", 1024 * 1024)] + [InlineData("1GB", 1024L * 1024 * 1024)] + public void MinimumProgressByteSize_WithDifferentUnits_ParsesCorrectly(string minimumProgress, long expectedBytes) + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + MinimumProgress = minimumProgress + }; + + rule.MinimumProgressByteSize.ShouldNotBeNull(); + rule.MinimumProgressByteSize!.Value.Bytes.ShouldBe(expectedBytes); + } + + #endregion + + #region ResetStrikesOnProgress Tests + + [Fact] + public void ResetStrikesOnProgress_DefaultsToTrue() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3 + }; + + rule.ResetStrikesOnProgress.ShouldBeTrue(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ResetStrikesOnProgress_CanBeSet(bool resetStrikesOnProgress) + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + ResetStrikesOnProgress = resetStrikesOnProgress + }; + + rule.ResetStrikesOnProgress.ShouldBe(resetStrikesOnProgress); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Persistence.Tests/xunit.runner.json b/code/backend/Cleanuparr.Persistence.Tests/xunit.runner.json new file mode 100644 index 00000000..403739b7 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/xunit.runner.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": true, + "maxParallelThreads": -1, + "methodDisplay": "classAndMethod", + "diagnosticMessages": false, + "parallelAlgorithm": "aggressive" +} diff --git a/code/backend/Cleanuparr.Persistence/DataContext.cs b/code/backend/Cleanuparr.Persistence/DataContext.cs index 70106743..8d78c3fc 100644 --- a/code/backend/Cleanuparr.Persistence/DataContext.cs +++ b/code/backend/Cleanuparr.Persistence/DataContext.cs @@ -52,6 +52,8 @@ public class DataContext : DbContext public DbSet NtfyConfigs { get; set; } + public DbSet PushoverConfigs { get; set; } + public DbSet BlacklistSyncHistory { get; set; } public DbSet BlacklistSyncConfigs { get; set; } @@ -141,10 +143,29 @@ public class DataContext : DbContext .WithOne(c => c.NotificationConfig) .HasForeignKey(c => c.NotificationConfigId) .OnDelete(DeleteBehavior.Cascade); - + + entity.HasOne(p => p.PushoverConfiguration) + .WithOne(c => c.NotificationConfig) + .HasForeignKey(c => c.NotificationConfigId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasIndex(p => p.Name).IsUnique(); }); + // Configure PushoverConfig List conversions + modelBuilder.Entity(entity => + { + entity.Property(p => p.Devices) + .HasConversion( + v => string.Join(',', v), + v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()); + + entity.Property(p => p.Tags) + .HasConversion( + v => string.Join(',', v), + v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()); + }); + // Configure BlacklistSyncState relationships and indexes modelBuilder.Entity(entity => { diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20251211102657_AddPushoverProvider.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20251211102657_AddPushoverProvider.Designer.cs new file mode 100644 index 00000000..b824eef7 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20251211102657_AddPushoverProvider.Designer.cs @@ -0,0 +1,1091 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20251211102657_AddPushoverProvider")] + partial class AddPushoverProvider + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_cleaner_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_clean_categories"); + + b.HasIndex("DownloadCleanerConfigId") + .HasDatabaseName("ix_clean_categories_download_cleaner_config_id"); + + b.ToTable("clean_categories", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.PrimitiveCollection("UnlinkedCategories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_categories"); + + b.Property("UnlinkedEnabled") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_enabled"); + + b.Property("UnlinkedIgnoredRootDir") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_ignored_root_dir"); + + b.Property("UnlinkedTargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_target_category"); + + b.Property("UnlinkedUseTag") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_use_tag"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("SearchDelay") + .HasColumnType("INTEGER") + .HasColumnName("search_delay"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.ComplexProperty>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeleteKnownMalware") + .HasColumnType("INTEGER") + .HasColumnName("delete_known_malware"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("TEXT") + .HasColumnName("ignore_above_size"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("REAL") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("TEXT") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig") + .WithMany("Categories") + .HasForeignKey("DownloadCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id"); + + b.Navigation("DownloadCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Navigation("Categories"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20251211102657_AddPushoverProvider.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20251211102657_AddPushoverProvider.cs new file mode 100644 index 00000000..7be3f265 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20251211102657_AddPushoverProvider.cs @@ -0,0 +1,54 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddPushoverProvider : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "pushover_configs", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + notification_config_id = table.Column(type: "TEXT", nullable: false), + api_token = table.Column(type: "TEXT", maxLength: 50, nullable: false), + user_key = table.Column(type: "TEXT", maxLength: 50, nullable: false), + devices = table.Column(type: "TEXT", nullable: false), + priority = table.Column(type: "TEXT", nullable: false), + sound = table.Column(type: "TEXT", maxLength: 50, nullable: true), + retry = table.Column(type: "INTEGER", nullable: true), + expire = table.Column(type: "INTEGER", nullable: true), + tags = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_pushover_configs", x => x.id); + table.ForeignKey( + name: "fk_pushover_configs_notification_configs_notification_config_id", + column: x => x.notification_config_id, + principalTable: "notification_configs", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_pushover_configs_notification_config_id", + table: "pushover_configs", + column: "notification_config_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "pushover_configs"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index 8c7cc0b2..fdcdd7f3 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -663,6 +663,67 @@ namespace Cleanuparr.Persistence.Migrations.Data b.ToTable("ntfy_configs", (string)null); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => { b.Property("Id") @@ -946,6 +1007,18 @@ namespace Cleanuparr.Persistence.Migrations.Data b.Navigation("NotificationConfig"); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => { b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") @@ -999,6 +1072,8 @@ namespace Cleanuparr.Persistence.Migrations.Data b.Navigation("NotifiarrConfiguration"); b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); }); modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs index 2c91c7dd..b19945e3 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/NotificationConfig.cs @@ -40,14 +40,17 @@ public sealed record NotificationConfig public AppriseConfig? AppriseConfiguration { get; init; } public NtfyConfig? NtfyConfiguration { get; init; } - + + public PushoverConfig? PushoverConfiguration { get; init; } + [NotMapped] public bool IsConfigured => Type switch { NotificationProviderType.Notifiarr => NotifiarrConfiguration?.IsValid() == true, NotificationProviderType.Apprise => AppriseConfiguration?.IsValid() == true, NotificationProviderType.Ntfy => NtfyConfiguration?.IsValid() == true, - _ => false + NotificationProviderType.Pushover => PushoverConfiguration?.IsValid() == true, + _ => throw new ArgumentOutOfRangeException(nameof(Type), $"Invalid notification provider type {Type}") }; [NotMapped] diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/PushoverConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/PushoverConfig.cs new file mode 100644 index 00000000..48464f0d --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/Notification/PushoverConfig.cs @@ -0,0 +1,148 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.RegularExpressions; +using Cleanuparr.Domain.Enums; +using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; + +namespace Cleanuparr.Persistence.Models.Configuration.Notification; + +public sealed partial record PushoverConfig : IConfig +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid Id { get; init; } = Guid.NewGuid(); + + [Required] + public Guid NotificationConfigId { get; init; } + + public NotificationConfig NotificationConfig { get; init; } = null!; + + /// + /// Application API token (30 characters, [A-Za-z0-9]) + /// + [Required] + [MaxLength(50)] + public string ApiToken { get; init; } = string.Empty; + + /// + /// User/group key (30 characters, [A-Za-z0-9]) + /// + [Required] + [MaxLength(50)] + public string UserKey { get; init; } = string.Empty; + + /// + /// Target specific devices (comma-separated when sent to API) + /// + public List Devices { get; init; } = []; + + /// + /// Notification priority (-2 to 2) + /// + [Required] + public PushoverPriority Priority { get; init; } = PushoverPriority.Normal; + + /// + /// Notification sound (built-in or custom) + /// + [MaxLength(50)] + public string? Sound { get; init; } + + /// + /// Retry interval in seconds for emergency priority (min 30) + /// + public int? Retry { get; init; } + + /// + /// Expiration time in seconds for emergency priority (max 10800) + /// + public int? Expire { get; init; } + + /// + /// Tags for receipt tracking and batch cancellation + /// + public List Tags { get; init; } = []; + + [GeneratedRegex(@"^[A-Za-z0-9_-]+$")] + private static partial Regex DeviceNameRegex(); + + public bool IsValid() + { + if (string.IsNullOrWhiteSpace(ApiToken) || string.IsNullOrWhiteSpace(UserKey)) + { + return false; + } + + if (Priority == PushoverPriority.Emergency) + { + if (Retry is null or < 30) + { + return false; + } + + if (Expire is null or < 1 or > 10800) + { + return false; + } + } + + // Sound, if provided, must not be whitespace-only + if (Sound is not null && Sound.Length > 0 && string.IsNullOrWhiteSpace(Sound)) + { + return false; + } + + return true; + } + + public void Validate() + { + if (string.IsNullOrWhiteSpace(ApiToken)) + { + throw new ValidationException("Pushover API token is required"); + } + + if (string.IsNullOrWhiteSpace(UserKey)) + { + throw new ValidationException("Pushover user key is required"); + } + + if (Priority == PushoverPriority.Emergency) + { + if (!Retry.HasValue || Retry.Value < 30) + { + throw new ValidationException("Retry interval must be at least 30 seconds for emergency priority"); + } + + if (!Expire.HasValue || Expire.Value < 1) + { + throw new ValidationException("Expire time is required for emergency priority"); + } + + if (Expire.Value > 10800) + { + throw new ValidationException("Expire time cannot exceed 10800 seconds (3 hours)"); + } + } + + // Validate device names if provided + foreach (string device in Devices.Where(d => !string.IsNullOrWhiteSpace(d))) + { + if (device.Length > 25) + { + throw new ValidationException($"Device name '{device}' exceeds 25 character limit"); + } + + if (!DeviceNameRegex().IsMatch(device)) + { + throw new ValidationException($"Device name '{device}' contains invalid characters. Only letters, numbers, underscores, and hyphens are allowed."); + } + } + + // Validate sound - if provided, must not be whitespace-only + if (Sound is not null && Sound.Length > 0 && string.IsNullOrWhiteSpace(Sound)) + { + throw new ValidationException("Sound name cannot be empty or whitespace when specified"); + } + } +} diff --git a/code/backend/cleanuparr.sln b/code/backend/cleanuparr.sln index 72e0e356..f1368676 100644 --- a/code/backend/cleanuparr.sln +++ b/code/backend/cleanuparr.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cleanuparr.Domain", "Cleanuparr.Domain\Cleanuparr.Domain.csproj", "{88DFBF8D-733A-45B4-B254-908D818E5D44}" EndProject @@ -12,6 +12,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cleanuparr.Api", "Cleanupar EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cleanuparr.Infrastructure.Tests", "Cleanuparr.Infrastructure.Tests\Cleanuparr.Infrastructure.Tests.csproj", "{8487A062-9977-408D-8496-CEAD966CEF6F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cleanuparr.Persistence.Tests", "Cleanuparr.Persistence.Tests\Cleanuparr.Persistence.Tests.csproj", "{7037FF30-4890-4435-B4A9-04A7A48188CE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,5 +44,9 @@ Global {8487A062-9977-408D-8496-CEAD966CEF6F}.Debug|Any CPU.Build.0 = Debug|Any CPU {8487A062-9977-408D-8496-CEAD966CEF6F}.Release|Any CPU.ActiveCfg = Release|Any CPU {8487A062-9977-408D-8496-CEAD966CEF6F}.Release|Any CPU.Build.0 = Release|Any CPU + {7037FF30-4890-4435-B4A9-04A7A48188CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7037FF30-4890-4435-B4A9-04A7A48188CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7037FF30-4890-4435-B4A9-04A7A48188CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7037FF30-4890-4435-B4A9-04A7A48188CE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/code/backend/coverage.runsettings b/code/backend/coverage.runsettings new file mode 100644 index 00000000..9a37510a --- /dev/null +++ b/code/backend/coverage.runsettings @@ -0,0 +1,13 @@ + + + + + + + [*]*.Migrations.* + **/Migrations/**/*.cs + + + + + diff --git a/code/frontend/public/icons/ext/pushover-light.svg b/code/frontend/public/icons/ext/pushover-light.svg new file mode 100644 index 00000000..f21599f9 --- /dev/null +++ b/code/frontend/public/icons/ext/pushover-light.svg @@ -0,0 +1 @@ + diff --git a/code/frontend/public/icons/ext/pushover.svg b/code/frontend/public/icons/ext/pushover.svg new file mode 100644 index 00000000..8ba9109c --- /dev/null +++ b/code/frontend/public/icons/ext/pushover.svg @@ -0,0 +1 @@ + diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index f8ff65a2..a2e47a31 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -133,6 +133,16 @@ export class DocumentationService { 'ntfy.priority': 'priority', 'ntfy.tags': 'tags' }, + 'notifications/pushover': { + 'pushover.apiToken': 'pushover.apiToken', + 'pushover.userKey': 'pushover.userKey', + 'pushover.devices': 'pushover.devices', + 'pushover.priority': 'pushover.priority', + 'pushover.retry': 'pushover.retry', + 'pushover.expire': 'pushover.expire', + 'pushover.sound': 'pushover.sound', + 'pushover.tags': 'pushover.tags' + }, }; constructor(private applicationPathService: ApplicationPathService) {} diff --git a/code/frontend/src/app/core/services/notification-provider.service.ts b/code/frontend/src/app/core/services/notification-provider.service.ts index 034950c1..171eee6d 100644 --- a/code/frontend/src/app/core/services/notification-provider.service.ts +++ b/code/frontend/src/app/core/services/notification-provider.service.ts @@ -2,14 +2,15 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { ApplicationPathService } from './base-path.service'; -import { - NotificationProvidersConfig, - NotificationProviderDto, +import { + NotificationProvidersConfig, + NotificationProviderDto, TestNotificationResult } from '../../shared/models/notification-provider.model'; import { NotificationProviderType } from '../../shared/models/enums'; import { NtfyAuthenticationType } from '../../shared/models/ntfy-authentication-type.enum'; import { NtfyPriority } from '../../shared/models/ntfy-priority.enum'; +import { PushoverPriority } from '../../shared/models/pushover-priority.enum'; // Provider-specific interfaces export interface CreateNotifiarrProviderRequest { @@ -126,6 +127,55 @@ export interface TestNtfyProviderRequest { tags: string[]; } +export interface CreatePushoverProviderRequest { + name: string; + isEnabled: boolean; + onFailedImportStrike: boolean; + onStalledStrike: boolean; + onSlowStrike: boolean; + onQueueItemDeleted: boolean; + onDownloadCleaned: boolean; + onCategoryChanged: boolean; + apiToken: string; + userKey: string; + devices: string[]; + priority: PushoverPriority; + sound: string | null; + retry: number | null; + expire: number | null; + tags: string[]; +} + +export interface UpdatePushoverProviderRequest { + name: string; + isEnabled: boolean; + onFailedImportStrike: boolean; + onStalledStrike: boolean; + onSlowStrike: boolean; + onQueueItemDeleted: boolean; + onDownloadCleaned: boolean; + onCategoryChanged: boolean; + apiToken: string; + userKey: string; + devices: string[]; + priority: PushoverPriority; + sound: string | null; + retry: number | null; + expire: number | null; + tags: string[]; +} + +export interface TestPushoverProviderRequest { + apiToken: string; + userKey: string; + devices: string[]; + priority: PushoverPriority; + sound: string | null; + retry: number | null; + expire: number | null; + tags: string[]; +} + @Injectable({ providedIn: 'root' }) @@ -162,6 +212,13 @@ export class NotificationProviderService { return this.http.post(`${this.baseUrl}/ntfy`, provider); } + /** + * Create a new Pushover provider + */ + createPushoverProvider(provider: CreatePushoverProviderRequest): Observable { + return this.http.post(`${this.baseUrl}/pushover`, provider); + } + /** * Update an existing Notifiarr provider */ @@ -183,6 +240,13 @@ export class NotificationProviderService { return this.http.put(`${this.baseUrl}/ntfy/${id}`, provider); } + /** + * Update an existing Pushover provider + */ + updatePushoverProvider(id: string, provider: UpdatePushoverProviderRequest): Observable { + return this.http.put(`${this.baseUrl}/pushover/${id}`, provider); + } + /** * Delete a notification provider */ @@ -211,17 +275,26 @@ export class NotificationProviderService { return this.http.post(`${this.baseUrl}/ntfy/test`, testRequest); } + /** + * Test a Pushover provider (without ID - for testing configuration before saving) + */ + testPushoverProvider(testRequest: TestPushoverProviderRequest): Observable { + return this.http.post(`${this.baseUrl}/pushover/test`, testRequest); + } + /** * Generic create method that delegates to provider-specific methods */ createProvider(provider: any, type: NotificationProviderType): Observable { switch (type) { case NotificationProviderType.Notifiarr: - return this.createNotifiarrProvider(provider as CreateNotifiarrProviderRequest); + return this.createNotifiarrProvider(provider as CreateNotifiarrProviderRequest); case NotificationProviderType.Apprise: - return this.createAppriseProvider(provider as CreateAppriseProviderRequest); + return this.createAppriseProvider(provider as CreateAppriseProviderRequest); case NotificationProviderType.Ntfy: - return this.createNtfyProvider(provider as CreateNtfyProviderRequest); + return this.createNtfyProvider(provider as CreateNtfyProviderRequest); + case NotificationProviderType.Pushover: + return this.createPushoverProvider(provider as CreatePushoverProviderRequest); default: throw new Error(`Unsupported provider type: ${type}`); } @@ -233,11 +306,13 @@ export class NotificationProviderService { updateProvider(id: string, provider: any, type: NotificationProviderType): Observable { switch (type) { case NotificationProviderType.Notifiarr: - return this.updateNotifiarrProvider(id, provider as UpdateNotifiarrProviderRequest); + return this.updateNotifiarrProvider(id, provider as UpdateNotifiarrProviderRequest); case NotificationProviderType.Apprise: - return this.updateAppriseProvider(id, provider as UpdateAppriseProviderRequest); + return this.updateAppriseProvider(id, provider as UpdateAppriseProviderRequest); case NotificationProviderType.Ntfy: - return this.updateNtfyProvider(id, provider as UpdateNtfyProviderRequest); + return this.updateNtfyProvider(id, provider as UpdateNtfyProviderRequest); + case NotificationProviderType.Pushover: + return this.updatePushoverProvider(id, provider as UpdatePushoverProviderRequest); default: throw new Error(`Unsupported provider type: ${type}`); } @@ -249,11 +324,13 @@ export class NotificationProviderService { testProvider(testRequest: any, type: NotificationProviderType): Observable { switch (type) { case NotificationProviderType.Notifiarr: - return this.testNotifiarrProvider(testRequest as TestNotifiarrProviderRequest); + return this.testNotifiarrProvider(testRequest as TestNotifiarrProviderRequest); case NotificationProviderType.Apprise: - return this.testAppriseProvider(testRequest as TestAppriseProviderRequest); + return this.testAppriseProvider(testRequest as TestAppriseProviderRequest); case NotificationProviderType.Ntfy: - return this.testNtfyProvider(testRequest as TestNtfyProviderRequest); + return this.testNtfyProvider(testRequest as TestNtfyProviderRequest); + case NotificationProviderType.Pushover: + return this.testPushoverProvider(testRequest as TestPushoverProviderRequest); default: throw new Error(`Unsupported provider type: ${type}`); } diff --git a/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.ts b/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.ts index 1bc0c142..7cfced87 100644 --- a/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.ts +++ b/code/frontend/src/app/settings/notification-settings/modals/provider-type-selection/provider-type-selection.component.ts @@ -43,6 +43,13 @@ export class ProviderTypeSelectionComponent { iconUrl: 'icons/ext/ntfy-light.svg', iconUrlHover: 'icons/ext/ntfy.svg', description: 'https://ntfy.sh/' + }, + { + type: NotificationProviderType.Pushover, + name: 'Pushover', + iconUrl: 'icons/ext/pushover-light.svg', + iconUrlHover: 'icons/ext/pushover.svg', + description: 'https://pushover.net/' } ]; diff --git a/code/frontend/src/app/settings/notification-settings/modals/pushover-provider/pushover-provider.component.html b/code/frontend/src/app/settings/notification-settings/modals/pushover-provider/pushover-provider.component.html new file mode 100644 index 00000000..d55dcb4a --- /dev/null +++ b/code/frontend/src/app/settings/notification-settings/modals/pushover-provider/pushover-provider.component.html @@ -0,0 +1,203 @@ + + +
+ +
+ + + API token is required + Your application API token from Pushover. Create one at pushover.net/apps/build. +
+ + +
+ + + User key is required + Your user/group key from your Pushover dashboard. +
+ + +
+ + + Leave empty to send to all devices, or enter specific device names. +
+ + +
+ + + Priority is required + The priority level for notifications. Emergency priority will repeat until acknowledged. +
+ + +
+ + + Retry is required for emergency priority + Minimum retry is 30 seconds + How often (in seconds) the notification will be resent until acknowledged. Minimum 30 seconds. +
+ + +
+ + + Expire is required for emergency priority + Expire must be at least 1 second + Expire cannot exceed 10800 seconds (3 hours) + How long (in seconds) the notification will continue to be retried. Maximum 10800 seconds (3 hours). +
+ + +
+ + + Choose a notification sound, or select Custom to enter your own. +
+ + +
+ + + Custom sound name is required + Enter the name of a custom sound you've uploaded to Pushover. +
+ + +
+ + + Tags for receipt tracking and batch cancellation of emergency notifications. +
+
+
diff --git a/code/frontend/src/app/settings/notification-settings/modals/pushover-provider/pushover-provider.component.scss b/code/frontend/src/app/settings/notification-settings/modals/pushover-provider/pushover-provider.component.scss new file mode 100644 index 00000000..cd71ecb6 --- /dev/null +++ b/code/frontend/src/app/settings/notification-settings/modals/pushover-provider/pushover-provider.component.scss @@ -0,0 +1 @@ +@use '../../../styles/settings-shared.scss'; diff --git a/code/frontend/src/app/settings/notification-settings/modals/pushover-provider/pushover-provider.component.ts b/code/frontend/src/app/settings/notification-settings/modals/pushover-provider/pushover-provider.component.ts new file mode 100644 index 00000000..20020707 --- /dev/null +++ b/code/frontend/src/app/settings/notification-settings/modals/pushover-provider/pushover-provider.component.ts @@ -0,0 +1,264 @@ +import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core'; +import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { InputTextModule } from 'primeng/inputtext'; +import { InputNumberModule } from 'primeng/inputnumber'; +import { SelectModule } from 'primeng/select'; +import { MobileAutocompleteComponent } from '../../../../shared/components/mobile-autocomplete/mobile-autocomplete.component'; +import { PushoverFormData, BaseProviderFormData } from '../../models/provider-modal.model'; +import { DocumentationService } from '../../../../core/services/documentation.service'; +import { NotificationProviderDto } from '../../../../shared/models/notification-provider.model'; +import { NotificationProviderBaseComponent } from '../base/notification-provider-base.component'; +import { PushoverPriority } from '../../../../shared/models/pushover-priority.enum'; +import { PushoverSounds } from '../../../../shared/models/pushover-sounds'; + +@Component({ + selector: 'app-pushover-provider', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + InputTextModule, + InputNumberModule, + SelectModule, + MobileAutocompleteComponent, + NotificationProviderBaseComponent + ], + templateUrl: './pushover-provider.component.html', + styleUrls: ['./pushover-provider.component.scss'] +}) +export class PushoverProviderComponent implements OnInit, OnChanges { + @Input() visible = false; + @Input() editingProvider: NotificationProviderDto | null = null; + @Input() saving = false; + @Input() testing = false; + + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + @Output() test = new EventEmitter(); + + // Provider-specific form controls + apiTokenControl = new FormControl('', [Validators.required]); + userKeyControl = new FormControl('', [Validators.required]); + devicesControl = new FormControl([]); + priorityControl = new FormControl(PushoverPriority.Normal, [Validators.required]); + soundControl = new FormControl(''); + customSoundControl = new FormControl(''); + retryControl = new FormControl(null); + expireControl = new FormControl(null); + tagsControl = new FormControl([]); + + private documentationService = inject(DocumentationService); + + // Enum reference for template + readonly PushoverPriority = PushoverPriority; + + // Priority dropdown options + priorityOptions = [ + { label: 'Lowest (-2) - No notification', value: PushoverPriority.Lowest }, + { label: 'Low (-1) - No sound/vibration', value: PushoverPriority.Low }, + { label: 'Normal (0) - Default', value: PushoverPriority.Normal }, + { label: 'High (1) - Bypass quiet hours', value: PushoverPriority.High }, + { label: 'Emergency (2) - Repeat until acknowledged', value: PushoverPriority.Emergency } + ]; + + // Sound dropdown options - built-in sounds + custom option + soundOptions = [ + { label: '(Use default)', value: '' }, + ...PushoverSounds.map(s => ({ label: s.label, value: s.value })), + { label: 'Custom...', value: '__custom__' } + ]; + + // Track if custom sound is selected + isCustomSound = false; + + /** + * Exposed for template to open documentation for pushover fields + */ + openFieldDocs(fieldName: string): void { + this.documentationService.openFieldDocumentation('notifications/pushover', fieldName); + } + + ngOnInit(): void { + // Set up conditional validation for emergency priority fields + this.priorityControl.valueChanges.subscribe(priority => { + this.updateEmergencyFieldValidation(priority); + }); + + // Track custom sound selection and update validation + this.soundControl.valueChanges.subscribe(value => { + this.isCustomSound = value === '__custom__'; + this.updateCustomSoundValidation(this.isCustomSound); + if (!this.isCustomSound) { + this.customSoundControl.setValue(''); + } + }); + } + + ngOnChanges(changes: SimpleChanges): void { + // Populate provider-specific fields when editingProvider input changes + if (changes['editingProvider']) { + if (this.editingProvider) { + this.populateProviderFields(); + } else { + // Reset fields when editingProvider is cleared + this.resetProviderFields(); + } + } + } + + private populateProviderFields(): void { + if (this.editingProvider) { + const config = this.editingProvider.configuration as any; + + this.apiTokenControl.setValue(config?.apiToken || ''); + this.userKeyControl.setValue(config?.userKey || ''); + this.devicesControl.setValue(config?.devices || []); + this.priorityControl.setValue(config?.priority || PushoverPriority.Normal); + + // Handle sound - check if it's a built-in sound or custom + const savedSound = config?.sound || ''; + const isBuiltIn = PushoverSounds.some(s => s.value === savedSound) || savedSound === ''; + if (isBuiltIn) { + this.soundControl.setValue(savedSound); + this.customSoundControl.setValue(''); + this.isCustomSound = false; + } else { + this.soundControl.setValue('__custom__'); + this.customSoundControl.setValue(savedSound); + this.isCustomSound = true; + } + this.updateCustomSoundValidation(this.isCustomSound); + + this.retryControl.setValue(config?.retry || null); + this.expireControl.setValue(config?.expire || null); + this.tagsControl.setValue(config?.tags || []); + + // Update validation based on loaded priority + this.updateEmergencyFieldValidation(config?.priority || PushoverPriority.Normal); + } + } + + private resetProviderFields(): void { + this.apiTokenControl.setValue(''); + this.userKeyControl.setValue(''); + this.devicesControl.setValue([]); + this.priorityControl.setValue(PushoverPriority.Normal); + this.soundControl.setValue(''); + this.customSoundControl.setValue(''); + this.isCustomSound = false; + this.retryControl.setValue(null); + this.expireControl.setValue(null); + this.tagsControl.setValue([]); + + // Reset validation + this.updateEmergencyFieldValidation(PushoverPriority.Normal); + } + + private updateEmergencyFieldValidation(priority: PushoverPriority | null): void { + this.retryControl.clearValidators(); + this.expireControl.clearValidators(); + + if (priority === PushoverPriority.Emergency) { + this.retryControl.setValidators([Validators.required, Validators.min(30)]); + this.expireControl.setValidators([Validators.required, Validators.min(1), Validators.max(10800)]); + } + + this.retryControl.updateValueAndValidity(); + this.expireControl.updateValueAndValidity(); + } + + private updateCustomSoundValidation(isCustom: boolean): void { + this.customSoundControl.clearValidators(); + + if (isCustom) { + this.customSoundControl.setValidators([Validators.required]); + } + + this.customSoundControl.updateValueAndValidity(); + } + + protected hasFieldError(control: FormControl, errorType: string): boolean { + return !!(control && control.errors?.[errorType] && (control.dirty || control.touched)); + } + + private isFormValid(): boolean { + const baseValid = this.apiTokenControl.valid && + this.userKeyControl.valid && + this.priorityControl.valid; + + let valid = baseValid; + + if (this.currentPriority === PushoverPriority.Emergency) { + valid = valid && this.retryControl.valid && this.expireControl.valid; + } + + if (this.isCustomSound) { + valid = valid && this.customSoundControl.valid; + } + + return valid; + } + + private getEffectiveSound(): string { + if (this.isCustomSound) { + return this.customSoundControl.value || ''; + } + return this.soundControl.value || ''; + } + + private buildPushoverData(baseData: BaseProviderFormData): PushoverFormData { + return { + ...baseData, + apiToken: this.apiTokenControl.value || '', + userKey: this.userKeyControl.value || '', + devices: this.devicesControl.value || [], + priority: this.priorityControl.value || PushoverPriority.Normal, + sound: this.getEffectiveSound(), + retry: this.currentPriority === PushoverPriority.Emergency ? this.retryControl.value : null, + expire: this.currentPriority === PushoverPriority.Emergency ? this.expireControl.value : null, + tags: this.tagsControl.value || [] + }; + } + + onSave(baseData: BaseProviderFormData): void { + if (this.isFormValid()) { + const pushoverData = this.buildPushoverData(baseData); + this.save.emit(pushoverData); + } else { + // Mark provider-specific fields as touched to show validation errors + this.apiTokenControl.markAsTouched(); + this.userKeyControl.markAsTouched(); + this.priorityControl.markAsTouched(); + this.retryControl.markAsTouched(); + this.expireControl.markAsTouched(); + this.customSoundControl.markAsTouched(); + } + } + + onCancel(): void { + this.cancel.emit(); + } + + onTest(baseData: BaseProviderFormData): void { + if (this.isFormValid()) { + const pushoverData = this.buildPushoverData(baseData); + this.test.emit(pushoverData); + } else { + // Mark provider-specific fields as touched to show validation errors + this.apiTokenControl.markAsTouched(); + this.userKeyControl.markAsTouched(); + this.priorityControl.markAsTouched(); + this.retryControl.markAsTouched(); + this.expireControl.markAsTouched(); + this.customSoundControl.markAsTouched(); + } + } + + /** + * Get current priority for template conditionals + */ + get currentPriority(): PushoverPriority | null { + return this.priorityControl.value; + } +} diff --git a/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts b/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts index c0c1dfa4..529308b3 100644 --- a/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts +++ b/code/frontend/src/app/settings/notification-settings/models/provider-modal.model.ts @@ -1,6 +1,7 @@ import { NotificationProviderType } from '../../../shared/models/enums'; import { NtfyAuthenticationType } from '../../../shared/models/ntfy-authentication-type.enum'; import { NtfyPriority } from '../../../shared/models/ntfy-priority.enum'; +import { PushoverPriority } from '../../../shared/models/pushover-priority.enum'; export interface ProviderTypeInfo { type: NotificationProviderType; @@ -49,6 +50,17 @@ export interface NtfyFormData extends BaseProviderFormData { tags: string[]; } +export interface PushoverFormData extends BaseProviderFormData { + apiToken: string; + userKey: string; + devices: string[]; + priority: PushoverPriority; + sound: string; + retry: number | null; + expire: number | null; + tags: string[]; +} + // Events for modal communication export interface ProviderModalEvents { save: (data: any) => void; diff --git a/code/frontend/src/app/settings/notification-settings/notification-settings.component.html b/code/frontend/src/app/settings/notification-settings/notification-settings.component.html index bd8e7357..c20280a5 100644 --- a/code/frontend/src/app/settings/notification-settings/notification-settings.component.html +++ b/code/frontend/src/app/settings/notification-settings/notification-settings.component.html @@ -187,6 +187,17 @@ (test)="onNtfyTest($event)" > + + + diff --git a/code/frontend/src/app/settings/notification-settings/notification-settings.component.ts b/code/frontend/src/app/settings/notification-settings/notification-settings.component.ts index 60175d25..9091c808 100644 --- a/code/frontend/src/app/settings/notification-settings/notification-settings.component.ts +++ b/code/frontend/src/app/settings/notification-settings/notification-settings.component.ts @@ -8,7 +8,7 @@ import { } from "../../shared/models/notification-provider.model"; import { NotificationProviderType } from "../../shared/models/enums"; import { DocumentationService } from "../../core/services/documentation.service"; -import { NotifiarrFormData, AppriseFormData, NtfyFormData } from "./models/provider-modal.model"; +import { NotifiarrFormData, AppriseFormData, NtfyFormData, PushoverFormData } from "./models/provider-modal.model"; import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component"; // New modal components @@ -16,6 +16,7 @@ import { ProviderTypeSelectionComponent } from "./modals/provider-type-selection import { NotifiarrProviderComponent } from "./modals/notifiarr-provider/notifiarr-provider.component"; import { AppriseProviderComponent } from "./modals/apprise-provider/apprise-provider.component"; import { NtfyProviderComponent } from "./modals/ntfy-provider/ntfy-provider.component"; +import { PushoverProviderComponent } from "./modals/pushover-provider/pushover-provider.component"; // PrimeNG Components import { CardModule } from "primeng/card"; @@ -51,6 +52,7 @@ import { NotificationService } from "../../core/services/notification.service"; NotifiarrProviderComponent, AppriseProviderComponent, NtfyProviderComponent, + PushoverProviderComponent, ], providers: [NotificationProviderConfigStore, ConfirmationService, MessageService], templateUrl: "./notification-settings.component.html", @@ -66,6 +68,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea showNotifiarrModal = false; // New: Notifiarr provider modal showAppriseModal = false; // New: Apprise provider modal showNtfyModal = false; // New: Ntfy provider modal + showPushoverModal = false; // New: Pushover provider modal modalMode: 'add' | 'edit' = 'add'; editingProvider: NotificationProviderDto | null = null; @@ -179,6 +182,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea case NotificationProviderType.Ntfy: this.showNtfyModal = true; break; + case NotificationProviderType.Pushover: + this.showPushoverModal = true; + break; default: // For unsupported types, show the legacy modal with info message this.showProviderModal = true; @@ -229,6 +235,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea case NotificationProviderType.Ntfy: this.showNtfyModal = true; break; + case NotificationProviderType.Pushover: + this.showPushoverModal = true; + break; default: // For unsupported types, show the legacy modal with info message this.showProviderModal = true; @@ -290,6 +299,19 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea tags: ntfyConfig.tags || "", }; break; + case NotificationProviderType.Pushover: + const pushoverConfig = provider.configuration as any; + testRequest = { + apiToken: pushoverConfig.apiToken, + userKey: pushoverConfig.userKey, + devices: pushoverConfig.devices || [], + priority: pushoverConfig.priority, + sound: pushoverConfig.sound || "", + retry: pushoverConfig.retry, + expire: pushoverConfig.expire, + tags: pushoverConfig.tags || [], + }; + break; default: this.notificationService.showError("Testing not supported for this provider type"); return; @@ -328,6 +350,8 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea return "Apprise"; case NotificationProviderType.Ntfy: return "ntfy"; + case NotificationProviderType.Pushover: + return "Pushover"; default: return "Unknown"; } @@ -434,6 +458,38 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea }); } + /** + * Handle Pushover provider save + */ + onPushoverSave(data: PushoverFormData): void { + if (this.modalMode === "edit" && this.editingProvider) { + this.updatePushoverProvider(data); + } else { + this.createPushoverProvider(data); + } + } + + /** + * Handle Pushover provider test + */ + onPushoverTest(data: PushoverFormData): void { + const testRequest = { + apiToken: data.apiToken, + userKey: data.userKey, + devices: data.devices, + priority: data.priority, + sound: data.sound, + retry: data.retry, + expire: data.expire, + tags: data.tags, + }; + + this.notificationProviderStore.testProvider({ + testRequest, + type: NotificationProviderType.Pushover, + }); + } + /** * Handle provider modal cancel */ @@ -449,6 +505,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea this.showNotifiarrModal = false; this.showAppriseModal = false; this.showNtfyModal = false; + this.showPushoverModal = false; this.showProviderModal = false; this.editingProvider = null; this.notificationProviderStore.clearTestResult(); @@ -621,6 +678,69 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea this.monitorProviderOperation("updated"); } + /** + * Create new Pushover provider + */ + private createPushoverProvider(data: PushoverFormData): void { + const createDto = { + name: data.name, + isEnabled: data.enabled, + onFailedImportStrike: data.onFailedImportStrike, + onStalledStrike: data.onStalledStrike, + onSlowStrike: data.onSlowStrike, + onQueueItemDeleted: data.onQueueItemDeleted, + onDownloadCleaned: data.onDownloadCleaned, + onCategoryChanged: data.onCategoryChanged, + apiToken: data.apiToken, + userKey: data.userKey, + devices: data.devices, + priority: data.priority, + sound: data.sound, + retry: data.retry, + expire: data.expire, + tags: data.tags, + }; + + this.notificationProviderStore.createProvider({ + provider: createDto, + type: NotificationProviderType.Pushover, + }); + this.monitorProviderOperation("created"); + } + + /** + * Update existing Pushover provider + */ + private updatePushoverProvider(data: PushoverFormData): void { + if (!this.editingProvider) return; + + const updateDto = { + name: data.name, + isEnabled: data.enabled, + onFailedImportStrike: data.onFailedImportStrike, + onStalledStrike: data.onStalledStrike, + onSlowStrike: data.onSlowStrike, + onQueueItemDeleted: data.onQueueItemDeleted, + onDownloadCleaned: data.onDownloadCleaned, + onCategoryChanged: data.onCategoryChanged, + apiToken: data.apiToken, + userKey: data.userKey, + devices: data.devices, + priority: data.priority, + sound: data.sound, + retry: data.retry, + expire: data.expire, + tags: data.tags, + }; + + this.notificationProviderStore.updateProvider({ + id: this.editingProvider.id, + provider: updateDto, + type: NotificationProviderType.Pushover, + }); + this.monitorProviderOperation("updated"); + } + /** * Monitor provider operation completion and close modals */ diff --git a/code/frontend/src/app/settings/settings-page/settings-page.component.scss b/code/frontend/src/app/settings/settings-page/settings-page.component.scss index c9034bf7..639b5ae2 100644 --- a/code/frontend/src/app/settings/settings-page/settings-page.component.scss +++ b/code/frontend/src/app/settings/settings-page/settings-page.component.scss @@ -49,25 +49,6 @@ padding-top: 0.5rem; } } - -.field-input { - width: 70%; -} - -.form-helper-text { - display: block; - color: var(--text-color-secondary); - margin-top: 0.5rem; - font-size: 0.85rem; -} - -.form-error-text { - display: block; - color: var(--text-color-secondary); - margin-top: 0.5rem; - font-size: 0.85rem; - color: red; -} /* Card styling */ ::ng-deep { diff --git a/code/frontend/src/app/settings/styles/settings-shared.scss b/code/frontend/src/app/settings/styles/settings-shared.scss index b644da29..70f96255 100644 --- a/code/frontend/src/app/settings/styles/settings-shared.scss +++ b/code/frontend/src/app/settings/styles/settings-shared.scss @@ -130,4 +130,23 @@ gap: 0.5rem; padding-top: 1rem; border-top: 1px solid var(--surface-border); +} + +.field-input { + width: 70%; +} + +.form-helper-text { + display: block; + color: var(--text-color-secondary); + margin-top: 0.5rem; + font-size: 0.85rem; +} + +.form-error-text { + display: block; + color: var(--text-color-secondary); + margin-top: 0.5rem; + font-size: 0.85rem; + color: red; } \ No newline at end of file diff --git a/code/frontend/src/app/shared/models/enums.ts b/code/frontend/src/app/shared/models/enums.ts index 6ee0631e..00d510b5 100644 --- a/code/frontend/src/app/shared/models/enums.ts +++ b/code/frontend/src/app/shared/models/enums.ts @@ -14,4 +14,5 @@ export enum NotificationProviderType { Notifiarr = "Notifiarr", Apprise = "Apprise", Ntfy = "Ntfy", + Pushover = "Pushover", } \ No newline at end of file diff --git a/code/frontend/src/app/shared/models/pushover-config.model.ts b/code/frontend/src/app/shared/models/pushover-config.model.ts new file mode 100644 index 00000000..e5928817 --- /dev/null +++ b/code/frontend/src/app/shared/models/pushover-config.model.ts @@ -0,0 +1,14 @@ +import { PushoverPriority } from './pushover-priority.enum'; + +export interface PushoverConfig { + id?: string; + notificationConfigId?: string; + apiToken?: string; + userKey?: string; + devices?: string[]; + priority?: PushoverPriority; + sound?: string; + retry?: number; + expire?: number; + tags?: string[]; +} diff --git a/code/frontend/src/app/shared/models/pushover-priority.enum.ts b/code/frontend/src/app/shared/models/pushover-priority.enum.ts new file mode 100644 index 00000000..8f2c80d6 --- /dev/null +++ b/code/frontend/src/app/shared/models/pushover-priority.enum.ts @@ -0,0 +1,7 @@ +export enum PushoverPriority { + Lowest = 'Lowest', + Low = 'Low', + Normal = 'Normal', + High = 'High', + Emergency = 'Emergency' +} diff --git a/code/frontend/src/app/shared/models/pushover-sounds.ts b/code/frontend/src/app/shared/models/pushover-sounds.ts new file mode 100644 index 00000000..a4b252c9 --- /dev/null +++ b/code/frontend/src/app/shared/models/pushover-sounds.ts @@ -0,0 +1,25 @@ +export const PushoverSounds = [ + { value: 'pushover', label: 'Pushover (Default)' }, + { value: 'bike', label: 'Bike' }, + { value: 'bugle', label: 'Bugle' }, + { value: 'cashregister', label: 'Cash Register' }, + { value: 'classical', label: 'Classical' }, + { value: 'cosmic', label: 'Cosmic' }, + { value: 'falling', label: 'Falling' }, + { value: 'gamelan', label: 'Gamelan' }, + { value: 'incoming', label: 'Incoming' }, + { value: 'intermission', label: 'Intermission' }, + { value: 'magic', label: 'Magic' }, + { value: 'mechanical', label: 'Mechanical' }, + { value: 'pianobar', label: 'Piano Bar' }, + { value: 'siren', label: 'Siren' }, + { value: 'spacealarm', label: 'Space Alarm' }, + { value: 'tugboat', label: 'Tugboat' }, + { value: 'alien', label: 'Alien (Long)' }, + { value: 'climb', label: 'Climb (Long)' }, + { value: 'persistent', label: 'Persistent (Long)' }, + { value: 'echo', label: 'Echo (Long)' }, + { value: 'updown', label: 'Up Down (Long)' }, + { value: 'vibrate', label: 'Vibrate Only' }, + { value: 'none', label: 'Silent' } +] as const; diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..fb5c74f2 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,11 @@ +coverage: + status: + project: + default: + target: auto + patch: + default: + target: auto + +ignore: + - "**/Migrations/**" diff --git a/docs/docs/configuration/notifications/pushover.mdx b/docs/docs/configuration/notifications/pushover.mdx new file mode 100644 index 00000000..b74d82a2 --- /dev/null +++ b/docs/docs/configuration/notifications/pushover.mdx @@ -0,0 +1,139 @@ +--- +sidebar_position: 4 +--- + +import { + ConfigSection, + ElementNavigator, + SectionTitle, + styles +} from '@site/src/components/documentation'; + +# Pushover + +Pushover is a service for sending real-time notifications to your Android, iPhone, iPad, and Desktop devices. + + + +
+ +
+ +Configuration + +

+ Configure Pushover to send push notifications to your devices. +

+ + + +Your Pushover application API token. Create one at [pushover.net/apps/build](https://pushover.net/apps/build). + +Each application you create on Pushover gets a unique API token that identifies it when sending notifications. + + + + + +Your Pushover user or group key. Find this on your [Pushover dashboard](https://pushover.net/). + +- **User Key**: Sends notifications to all your registered devices +- **Group Key**: Sends notifications to a group of users (useful for team notifications) + + + + + +Optionally specify device names to send notifications to specific devices only. Leave empty to send to all devices registered with your Pushover account. + +Device names can be found in your Pushover app settings on each device. + + + + + +The priority level for notifications: + +- **Lowest (-2)**: No notification/alert will be generated +- **Low (-1)**: Quiet notification, no sound or vibration but shown in notification list +- **Normal (0)**: Default priority with standard sound and vibration +- **High (1)**: Bypasses user's quiet hours settings +- **Emergency (2)**: Requires acknowledgment, will repeat notification until acknowledged + +Reference: [Pushover Message Priority](https://pushover.net/api#priority) + + + + + +**Only applicable for Emergency priority notifications.** + +How often (in seconds) the notification will be retried until acknowledged. The minimum value is 30 seconds. + +For example, setting this to 60 means the notification will be resent every 60 seconds until the user acknowledges it. + + + + + +**Only applicable for Emergency priority notifications.** + +How long (in seconds) the notification will continue to be retried. The maximum value is 10800 seconds (3 hours). + +After this time, if the notification hasn't been acknowledged, Pushover will stop retrying. + + + + + +The notification sound to play. Choose from built-in sounds or select "Custom" to use a custom sound you've uploaded to Pushover. + +Built-in sounds include: pushover, bike, bugle, cashregister, classical, cosmic, falling, gamelan, incoming, intermission, magic, mechanical, pianobar, siren, spacealarm, tugboat, alien, climb, persistent, echo, updown, vibrate, none. + +Reference: [Pushover Sounds](https://pushover.net/api#sounds) + + + + + +Tags for receipt tracking and batch cancellation of emergency notifications. + +Tags are useful when you want to cancel pending emergency notifications programmatically. + + + +
+ +
diff --git a/docs/static/img/icons/pushover-light.svg b/docs/static/img/icons/pushover-light.svg new file mode 100644 index 00000000..f21599f9 --- /dev/null +++ b/docs/static/img/icons/pushover-light.svg @@ -0,0 +1 @@ + diff --git a/docs/static/img/icons/pushover.svg b/docs/static/img/icons/pushover.svg new file mode 100644 index 00000000..8ba9109c --- /dev/null +++ b/docs/static/img/icons/pushover.svg @@ -0,0 +1 @@ +